diff --git a/internal/gateway/methods/heartbeat.go b/internal/gateway/methods/heartbeat.go index 3f828ddd..5e8a21c7 100644 --- a/internal/gateway/methods/heartbeat.go +++ b/internal/gateway/methods/heartbeat.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log/slog" "time" @@ -196,6 +197,13 @@ func (m *HeartbeatMethods) handleSet(ctx context.Context, client *gateway.Client hb.ActiveHoursEnd = params.ActiveHoursEnd } if params.Timezone != nil { + if *params.Timezone != "" { + if _, err := time.LoadLocation(*params.Timezone); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, + fmt.Sprintf("invalid timezone: %s", *params.Timezone))) + return + } + } hb.Timezone = params.Timezone } if params.Channel != nil { diff --git a/internal/store/pg/cron.go b/internal/store/pg/cron.go index 14a21259..d14ac5c4 100644 --- a/internal/store/pg/cron.go +++ b/internal/store/pg/cron.go @@ -49,6 +49,12 @@ func (s *PGCronStore) SetRetryConfig(cfg cron.RetryConfig) { // SetDefaultTimezone sets the fallback IANA timezone for cron expressions // when a job does not specify its own timezone. func (s *PGCronStore) SetDefaultTimezone(tz string) { + if tz != "" { + if _, err := time.LoadLocation(tz); err != nil { + slog.Warn("security.invalid_default_timezone", "tz", tz, "err", err) + return + } + } s.mu.Lock() defer s.mu.Unlock() s.defaultTZ = tz diff --git a/internal/store/sqlitestore/cron.go b/internal/store/sqlitestore/cron.go index 12351dd1..2e131206 100644 --- a/internal/store/sqlitestore/cron.go +++ b/internal/store/sqlitestore/cron.go @@ -51,6 +51,12 @@ func (s *SQLiteCronStore) SetRetryConfig(cfg cron.RetryConfig) { } func (s *SQLiteCronStore) SetDefaultTimezone(tz string) { + if tz != "" { + if _, err := time.LoadLocation(tz); err != nil { + slog.Warn("security.invalid_default_timezone", "tz", tz, "err", err) + return + } + } s.mu.Lock() defer s.mu.Unlock() s.defaultTZ = tz diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 81f742a0..8209b56d 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -83,26 +83,61 @@ export const TIMEZONE_OPTIONS = [ { value: "Australia/Sydney", label: "Sydney (AEST)" }, ] as const; -/** IANA timezones for server-side scheduling (cron, heartbeat). */ -export const IANA_TIMEZONES = [ - { value: "UTC", label: "UTC" }, - { value: "Asia/Ho_Chi_Minh", label: "Asia/Ho_Chi_Minh (UTC+7)" }, - { value: "Asia/Bangkok", label: "Asia/Bangkok (UTC+7)" }, - { value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" }, - { value: "Asia/Tokyo", label: "Asia/Tokyo (UTC+9)" }, - { value: "Asia/Seoul", label: "Asia/Seoul (UTC+9)" }, - { value: "Asia/Singapore", label: "Asia/Singapore (UTC+8)" }, - { value: "Asia/Kolkata", label: "Asia/Kolkata (UTC+5:30)" }, - { value: "Asia/Dubai", label: "Asia/Dubai (UTC+4)" }, - { value: "Europe/London", label: "Europe/London (UTC+0)" }, - { value: "Europe/Paris", label: "Europe/Paris (UTC+1)" }, - { value: "Europe/Berlin", label: "Europe/Berlin (UTC+1)" }, - { value: "Europe/Moscow", label: "Europe/Moscow (UTC+3)" }, - { value: "America/New_York", label: "America/New_York (UTC-5)" }, - { value: "America/Chicago", label: "America/Chicago (UTC-6)" }, - { value: "America/Denver", label: "America/Denver (UTC-7)" }, - { value: "America/Los_Angeles", label: "America/Los_Angeles (UTC-8)" }, - { value: "America/Sao_Paulo", label: "America/Sao_Paulo (UTC-3)" }, - { value: "Australia/Sydney", label: "Australia/Sydney (UTC+11)" }, - { value: "Pacific/Auckland", label: "Pacific/Auckland (UTC+13)" }, -] as const; +/** Compute UTC offset string for a timezone, e.g. "UTC+7", "UTC-5:30" */ +function formatUtcOffset(tz: string): string { + const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "shortOffset" }).formatToParts(new Date()); + const raw = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; + return raw.replace("GMT", "UTC").replace(/^UTC$/, "UTC+0"); +} + +function parseOffsetMinutes(offset: string): number { + const m = offset.match(/UTC([+-]?)(\d+)(?::(\d+))?/); + if (!m) return 0; + const sign = m[1] === "-" ? -1 : 1; + return sign * (parseInt(m[2]!, 10) * 60 + parseInt(m[3] ?? "0", 10)); +} + +type TzOption = { value: string; label: string }; +let _cachedTimezones: TzOption[] | undefined; + +/** Minimal fallback when Intl.supportedValuesOf is unavailable. */ +const FALLBACK_TIMEZONES: string[] = [ + "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Halifax", "America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin", + "Europe/Moscow", "Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Ho_Chi_Minh", + "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul", "Asia/Singapore", "Australia/Sydney", + "Pacific/Auckland", +]; + +/** All IANA timezones with dynamic UTC offsets, sorted by offset then name. */ +export function getAllIanaTimezones(): TzOption[] { + if (_cachedTimezones) return _cachedTimezones; + let tzNames: string[]; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tzNames = (Intl as any).supportedValuesOf("timeZone") as string[]; + } catch { + tzNames = FALLBACK_TIMEZONES; + } + // Chrome may omit "UTC" from the list + if (!tzNames.includes("UTC")) tzNames = ["UTC", ...tzNames]; + const entries = tzNames.map((tz: string) => { + const offset = formatUtcOffset(tz); + return { value: tz, label: `${tz} (${offset})`, _offset: parseOffsetMinutes(offset) }; + }); + entries.sort((a: { _offset: number; value: string }, b: { _offset: number; value: string }) => + a._offset !== b._offset ? a._offset - b._offset : a.value.localeCompare(b.value), + ); + _cachedTimezones = entries.map(({ value, label }: { value: string; label: string }) => ({ value, label })); + return _cachedTimezones; +} + +let _cachedTzSet: Set | undefined; + +/** Check if a value is a valid IANA timezone from the dynamic list. */ +export function isValidIanaTimezone(tz: string): boolean { + if (!_cachedTzSet) { + _cachedTzSet = new Set(getAllIanaTimezones().map((t) => t.value)); + } + return _cachedTzSet.has(tz); +} diff --git a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx index 8cfe8b7d..aabb2ab9 100644 --- a/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/heartbeat-config-dialog.tsx @@ -18,7 +18,9 @@ import { useChannels } from "@/pages/channels/hooks/use-channels"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useUiStore } from "@/stores/use-ui-store"; import { ProviderModelSelect } from "@/components/shared/provider-model-select"; -import { IANA_TIMEZONES } from "@/lib/constants"; +import { Combobox } from "@/components/ui/combobox"; +import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants"; +import { toast } from "@/stores/use-toast-store"; import type { HeartbeatConfig, DeliveryTarget } from "@/pages/agents/hooks/use-agent-heartbeat"; interface HeartbeatConfigDialogProps { @@ -127,6 +129,10 @@ export function HeartbeatConfigDialog({ }; const handleSave = async () => { + if (timezone && !isValidIanaTimezone(timezone)) { + toast.error(t("heartbeat.invalidTimezone", "Invalid timezone")); + return; + } try { const clampedMin = Math.max(5, intervalMin); await update({ @@ -326,17 +332,13 @@ export function HeartbeatConfigDialog({
- + setTimezone(v === "__auto__" ? "" : v)} + options={[{ value: "__auto__", label: defaultTz }, ...getAllIanaTimezones()]} + placeholder={t("heartbeat.timezone")} + className="text-base md:text-sm" + />

{t("heartbeat.scheduleHint")}

diff --git a/ui/web/src/pages/config/sections/cron-section.tsx b/ui/web/src/pages/config/sections/cron-section.tsx index 23a4fdaa..f894e797 100644 --- a/ui/web/src/pages/config/sections/cron-section.tsx +++ b/ui/web/src/pages/config/sections/cron-section.tsx @@ -3,10 +3,11 @@ import { Save } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Combobox } from "@/components/ui/combobox"; import { InfoLabel } from "@/components/shared/info-label"; -import { IANA_TIMEZONES } from "@/lib/constants"; +import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants"; +import { toast } from "@/stores/use-toast-store"; interface CronData { max_retries?: number; @@ -49,20 +50,15 @@ export function CronSection({ data, onSave, saving }: Props) {
{t("cron.defaultTimezone")} - + onChange={(v) => update({ default_timezone: v === "__system__" ? "" : v })} + options={[ + { value: "__system__", label: t("cron.defaultTimezonePlaceholder") }, + ...getAllIanaTimezones(), + ]} + placeholder={t("cron.defaultTimezonePlaceholder")} + />
@@ -96,7 +92,13 @@ export function CronSection({ data, onSave, saving }: Props) { {dirty && (
-
diff --git a/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx b/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx index de58ac73..09556a99 100644 --- a/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx +++ b/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx @@ -6,9 +6,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ConfigGroupHeader } from "@/components/shared/config-group-header"; -import { IANA_TIMEZONES } from "@/lib/constants"; +import { Combobox } from "@/components/ui/combobox"; +import { getAllIanaTimezones, isValidIanaTimezone } from "@/lib/constants"; +import { toast } from "@/stores/use-toast-store"; import type { CronJob, CronJobPatch } from "../hooks/use-cron"; interface CronAdvancedDialogProps { @@ -63,6 +64,10 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd onOpenChange(false); return; } + if (timezone && timezone !== "UTC" && !isValidIanaTimezone(timezone)) { + toast.error(t("detail.invalidTimezone", "Invalid timezone")); + return; + } setSaving(true); try { await onUpdate(job.id, { @@ -104,18 +109,13 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd />
- +

{t("detail.timezoneDesc")}