From 5ead25b09366fda6db0eef46b243d4cc5414b0c6 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Tue, 14 Apr 2026 09:07:13 +0200 Subject: [PATCH 1/2] fix: reject invalid server timezone (e.g. Etc/Unknown) before it reaches the browser On minimal Linux Docker containers without a TZ env var, Node's Intl.DateTimeFormat().resolvedOptions().timeZone can return "Etc/Unknown", which is accepted by Node but rejected by browsers with: RangeError: invalid time zone: Etc/Unknown Fix on two levels: - server/routes/config-routes.js: validate the resolved timezone with a try/catch Intl.DateTimeFormat call before sending it to the client; returns '' (browser default) for any invalid value. - src/hooks/app/useTimeState.js: validate the timezone before applying it to toLocaleTimeString/toLocaleDateString, so a bad value can never crash the dashboard even if it somehow slips through. Co-Authored-By: Claude Sonnet 4.6 --- server/routes/config-routes.js | 15 +++++++++++++-- src/hooks/app/useTimeState.js | 11 +++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/server/routes/config-routes.js b/server/routes/config-routes.js index e04eb7ab..d58d728c 100644 --- a/server/routes/config-routes.js +++ b/server/routes/config-routes.js @@ -173,8 +173,19 @@ module.exports = function (app, ctx) { // Whether config is incomplete (show setup wizard) configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare, - // Server timezone (from TZ env var or system) - timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '', + // Server timezone (from TZ env var or system), validated. + // On minimal Linux containers without TZ set, Intl can return "Etc/Unknown" + // which browsers reject with RangeError. Validate before sending. + timezone: (() => { + const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + if (!tz) return ''; + try { + Intl.DateTimeFormat('en-US', { timeZone: tz }); + return tz; + } catch (e) { + return ''; + } + })(), // Feature availability features: { diff --git a/src/hooks/app/useTimeState.js b/src/hooks/app/useTimeState.js index 5b34e822..869bc394 100644 --- a/src/hooks/app/useTimeState.js +++ b/src/hooks/app/useTimeState.js @@ -64,8 +64,15 @@ export default function useTimeState(configLocation, dxLocation, timezone) { const localTimeOpts = { hour12: use12Hour }; const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' }; if (timezone) { - localTimeOpts.timeZone = timezone; - localDateOpts.timeZone = timezone; + try { + // Validate before use — an invalid timezone (e.g. "Etc/Unknown" from a + // misconfigured Docker container) would throw a RangeError here. + Intl.DateTimeFormat('en-US', { timeZone: timezone }); + localTimeOpts.timeZone = timezone; + localDateOpts.timeZone = timezone; + } catch (e) { + // Fall back to browser timezone + } } const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts); const localDate = currentTime.toLocaleDateString('en-US', localDateOpts); From d045c4103f8619eb2efc008f575dd0cff4bd1134 Mon Sep 17 00:00:00 2001 From: ceotjoe Date: Tue, 14 Apr 2026 20:40:32 +0200 Subject: [PATCH 2/2] refactor(timezone): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Client: memoize timezone validation with useMemo so the Intl.DateTimeFormat probe runs only when the timezone value changes, not on every render. - Client/server: switch from Intl.DateTimeFormat('en-US', …) to new Intl.DateTimeFormat(undefined, …) — locale is irrelevant for timezone validation. - Server: emit a console.warn when an invalid timezone is detected so self-hosters see a clear message in their container logs rather than silently falling back. - Docs: note in DOCKER.md that TZ is optional after this fix but recommended for accurate server-side timestamps. Co-Authored-By: Claude Sonnet 4.6 --- docs/DOCKER.md | 4 ++++ server/routes/config-routes.js | 6 +++++- src/hooks/app/useTimeState.js | 26 ++++++++++++++++---------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 55e17f1f..59af9410 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -26,6 +26,10 @@ LOCATOR=FN20 TZ=America/New_York ``` +> **`TZ` is optional** — if omitted, each visitor's browser timezone is used for +> the local-time display. Setting it is still recommended so that server-side +> timestamps (logs, cache TTLs, etc.) match your local time. + Restart to apply: ```bash diff --git a/server/routes/config-routes.js b/server/routes/config-routes.js index d58d728c..ec9f88e3 100644 --- a/server/routes/config-routes.js +++ b/server/routes/config-routes.js @@ -180,9 +180,13 @@ module.exports = function (app, ctx) { const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || ''; if (!tz) return ''; try { - Intl.DateTimeFormat('en-US', { timeZone: tz }); + new Intl.DateTimeFormat(undefined, { timeZone: tz }); return tz; } catch (e) { + console.warn( + '[config] Invalid resolved timezone "%s" — falling back to empty (client will use browser TZ). Set TZ env var to silence.', + tz, + ); return ''; } })(), diff --git a/src/hooks/app/useTimeState.js b/src/hooks/app/useTimeState.js index 869bc394..b6ff7af2 100644 --- a/src/hooks/app/useTimeState.js +++ b/src/hooks/app/useTimeState.js @@ -61,18 +61,24 @@ export default function useTimeState(configLocation, dxLocation, timezone) { const utcTime = currentTime.toISOString().substr(11, 8); const utcDate = currentTime.toISOString().substr(0, 10); - const localTimeOpts = { hour12: use12Hour }; - const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' }; - if (timezone) { + // Validate the timezone once per changed value, not on every render. + // new Intl.DateTimeFormat throws a RangeError for invalid values such as + // "Etc/Unknown" (returned by Node on minimal Linux containers with no TZ set). + const safeTimezone = useMemo(() => { + if (!timezone) return ''; try { - // Validate before use — an invalid timezone (e.g. "Etc/Unknown" from a - // misconfigured Docker container) would throw a RangeError here. - Intl.DateTimeFormat('en-US', { timeZone: timezone }); - localTimeOpts.timeZone = timezone; - localDateOpts.timeZone = timezone; - } catch (e) { - // Fall back to browser timezone + new Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return timezone; + } catch { + return ''; } + }, [timezone]); + + const localTimeOpts = { hour12: use12Hour }; + const localDateOpts = { weekday: 'short', month: 'short', day: 'numeric' }; + if (safeTimezone) { + localTimeOpts.timeZone = safeTimezone; + localDateOpts.timeZone = safeTimezone; } const localTime = currentTime.toLocaleTimeString('en-US', localTimeOpts); const localDate = currentTime.toLocaleDateString('en-US', localDateOpts);