diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 020909c0c..13e0ef804 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -73,7 +73,7 @@ export interface Monitor { gameId?: string; grpcServiceName?: string; group: string | null; - customUserAgent?: string; + customUserAgent?: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index c9ab10afc..110360f12 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -45,6 +45,8 @@ const httpSchema = baseSchema.extend({ /^[\t\x20-\x7E\x80-\xFF]*$/, "Only printable characters, spaces, and tabs are allowed (RFC 7230 ยง3.2.6)" ) + .transform((val) => (val.trim() === "" ? null : val.trim())) + .nullable() .optional(), }); diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts index 14084ebf3..6ed4f5e1e 100644 --- a/server/src/db/migration/timescaledb/index.ts +++ b/server/src/db/migration/timescaledb/index.ts @@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; +import { addUserAgentFields, dropUserAgentFields } from "./0022_add_user_agent_fields.js"; const SERVICE_NAME = "TimescaleDB Migrations"; @@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [ { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, + { name: "0022_add_user_agent_fields", up: addUserAgentFields, down: dropUserAgentFields }, ]; const ensureMigrationsTable = async (pool: Pool) => { diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts index a94d38504..5c42e3fd2 100644 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts @@ -37,6 +37,7 @@ interface MonitorRow { game_id: string | null; grpc_service_name: string | null; monitor_group: string | null; + custom_user_agent: string | null; geo_check_enabled: boolean; geo_check_locations: GeoContinent[] | null; geo_check_interval_ms: number; @@ -49,7 +50,7 @@ const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status, interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, + game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms, created_at, updated_at`; export class TimescaleMonitorsRepository implements IMonitorsRepository { @@ -62,8 +63,8 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { interval_ms, is_active, status_window, status_window_size, status_window_threshold, cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) + game_id, grpc_service_name, monitor_group, custom_user_agent, geo_check_enabled, geo_check_locations, geo_check_interval_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35) RETURNING ${MONITOR_COLUMNS}`, [ userId, @@ -97,6 +98,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { monitor.gameId ?? null, monitor.grpcServiceName ?? null, monitor.group ?? null, + monitor.customUserAgent ?? null, monitor.geoCheckEnabled ?? false, monitor.geoCheckLocations ?? [], monitor.geoCheckInterval ?? 300000, @@ -595,6 +597,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { ["gameId", "game_id"], ["grpcServiceName", "grpc_service_name"], ["group", "monitor_group"], + ["customUserAgent", "custom_user_agent"], ["geoCheckEnabled", "geo_check_enabled"], ["geoCheckLocations", "geo_check_locations"], ["geoCheckInterval", "geo_check_interval_ms"], @@ -1034,6 +1037,7 @@ export class TimescaleMonitorsRepository implements IMonitorsRepository { gameId: row.game_id ?? undefined, grpcServiceName: row.grpc_service_name ?? undefined, group: row.monitor_group, + customUserAgent: row.custom_user_agent ?? undefined, geoCheckEnabled: row.geo_check_enabled, geoCheckLocations: row.geo_check_locations ?? [], geoCheckInterval: row.geo_check_interval_ms, diff --git a/server/src/repositories/settings/TimescaleSettingsRepository.ts b/server/src/repositories/settings/TimescaleSettingsRepository.ts index 82038b23f..7856db8bf 100644 --- a/server/src/repositories/settings/TimescaleSettingsRepository.ts +++ b/server/src/repositories/settings/TimescaleSettingsRepository.ts @@ -21,6 +21,7 @@ interface SettingsRow { system_email_require_tls: boolean | null; system_email_reject_unauthorized: boolean | null; show_url: boolean | null; + default_user_agent: string | null; version: string | null; threshold_cpu_usage: number | null; threshold_memory_usage: number | null; @@ -34,7 +35,7 @@ const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key, system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, + show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, created_at, updated_at`; export class TimescaleSettingsRepository implements ISettingsRepository { @@ -46,8 +47,8 @@ export class TimescaleSettingsRepository implements ISettingsRepository { system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + show_url, default_user_agent, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) RETURNING ${COLUMNS}`, [ settings.checkTTL ?? 30, @@ -67,6 +68,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository { settings.systemEmailRequireTLS ?? false, settings.systemEmailRejectUnauthorized ?? true, settings.showURL ?? false, + settings.defaultUserAgent ?? null, settings.version ?? 1, settings.globalThresholds?.cpu ?? null, settings.globalThresholds?.memory ?? null, @@ -113,6 +115,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository { ["systemEmailRequireTLS", "system_email_require_tls"], ["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"], ["showURL", "show_url"], + ["defaultUserAgent", "default_user_agent"], ["version", "version"], ]; @@ -197,6 +200,7 @@ export class TimescaleSettingsRepository implements ISettingsRepository { systemEmailRequireTLS: row.system_email_require_tls ?? false, systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true, showURL: row.show_url ?? false, + defaultUserAgent: row.default_user_agent ?? undefined, singleton: true, version: Number(row.version ?? 1), globalThresholds: diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index f10335255..194a1c225 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -82,8 +82,13 @@ export class SettingsService implements ISettingsService { }; updateDbSettings = async (newSettings: SettingsUpdate) => { - this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null; - return await this.getRepository().update(newSettings); + const updated = await this.getRepository().update(newSettings); + // Only refresh the cache if defaultUserAgent was actually part of the update payload. + // `?? null` would clobber the cache on partial updates that don't include this field. + if ("defaultUserAgent" in newSettings) { + this.cachedDefaultUserAgent = newSettings.defaultUserAgent ?? null; + } + return updated; }; getDBSettings = async () => { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index cbe78aa4a..8a551e194 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -50,7 +50,7 @@ export interface Monitor { gameId?: string; grpcServiceName?: string; group: string | null; - customUserAgent?: string; + customUserAgent?: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index bd01aaa3a..f83f5b591 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -75,7 +75,7 @@ export const createMonitorBodyValidation = z.object({ grpcServiceName: z.union([z.string(), z.literal("")]).default(""), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - customUserAgent: httpHeaderValueSchema.optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -105,7 +105,7 @@ export const editMonitorBodyValidation = z.object({ grpcServiceName: z.union([z.string(), z.literal("")]).optional(), selectedDisks: z.array(z.string()).optional(), group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - customUserAgent: httpHeaderValueSchema.optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), @@ -159,7 +159,7 @@ const importedMonitorSchema = z.object({ gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), group: z.union([z.string().max(50).trim(), z.null()]).default(null), - customUserAgent: httpHeaderValueSchema.optional(), + customUserAgent: httpHeaderValueSchema.nullable().optional(), geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000),