From 6f9642c298dc3b2afab6cd6805b9cb2bff73ae94 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 11:48:56 -0700 Subject: [PATCH] tighten timezone validation --- server/src/api/validation/shared.ts | 15 +++++++++++++++ server/src/api/validation/statusPageValidation.ts | 4 ++-- .../status-pages/status-page-repository.mongo.ts | 5 +++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/server/src/api/validation/shared.ts b/server/src/api/validation/shared.ts index 18b8ba463..ef3ec24ef 100644 --- a/server/src/api/validation/shared.ts +++ b/server/src/api/validation/shared.ts @@ -25,6 +25,21 @@ export const dnsServerValidation = z .min(1, "DNS server is required") .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); +// Canonical casing matters: MongoDB's date operators reject non-canonical zone names (e.g. "america/toronto") that Intl accepts, +// so the resolved canonical form is what gets stored. +export const resolveTimezone = (value: string): string | undefined => { + try { + return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; + } catch { + return undefined; + } +}; + +export const timezoneValidation = z + .string() + .refine((v) => resolveTimezone(v) !== undefined, "Enter a valid IANA timezone (e.g. America/Toronto)") + .transform((v) => resolveTimezone(v) as string); + // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, // optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). // No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 1b07dc1dc..6b8011044 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; @@ -40,7 +40,7 @@ export const createStatusPageBodyValidation = z message: "URL can only contain letters, numbers, underscores, and hyphens", }), customDomain: customDomainValidation, - timezone: z.string().optional(), + timezone: timezoneValidation.optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), subMonitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/)).optional(), diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 931efa9d0..326db1493 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -116,7 +116,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const windowStart = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000); + // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket + const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); const results = await CheckModel.aggregate([ { $match: { @@ -132,7 +133,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { }, totalChecks: { $sum: 1 }, upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, - avgResponseTime: { $avg: { $ifNull: ["$responseTime", 0] } }, + avgResponseTime: { $avg: "$responseTime" }, }, }, { $sort: { "_id.day": 1 } },