// query_timezone.go parses and validates timezone query parameters. // Layer: adapters/inbound/httpapi request binding helpers. package httpapi import ( "fmt" "net/http" "strconv" "strings" "time" apierrors "gitea.maximumdirect.net/ejr/feedapi/errors" ) const maxUTCOffsetSeconds = 14 * 60 * 60 var usTimezoneAbbreviations = map[string]int{ "CDT": -5 * 60 * 60, "CST": -6 * 60 * 60, "EDT": -4 * 60 * 60, "EST": -5 * 60 * 60, "MDT": -6 * 60 * 60, "MST": -7 * 60 * 60, "PDT": -7 * 60 * 60, "PST": -8 * 60 * 60, } var timezoneAliases = map[string]string{ "chicago": "America/Chicago", "stl": "America/Chicago", } func parseTimezoneQuery(r *http.Request) (*time.Location, error) { lower := strings.TrimSpace(r.URL.Query().Get("tz")) upper := strings.TrimSpace(r.URL.Query().Get("TZ")) if lower != "" && upper != "" && !strings.EqualFold(lower, upper) { return nil, apierrors.InvalidParameter("tz and TZ must match when both are provided") } raw := lower if raw == "" { raw = upper } if raw == "" { return nil, nil } loc, err := parseTimezoneValue(raw) if err != nil { return nil, apierrors.InvalidParameter(err.Error()) } return loc, nil } func parseTimezoneValue(raw string) (*time.Location, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } if offsetSeconds, ok, err := parseUTCOffsetSeconds(raw); ok { if err != nil { return nil, err } return time.FixedZone(formatUTCOffsetName(offsetSeconds), offsetSeconds), nil } if offsetSeconds, ok := usTimezoneAbbreviations[strings.ToUpper(raw)]; ok { return time.FixedZone(strings.ToUpper(raw), offsetSeconds), nil } if alias, ok := timezoneAliases[strings.ToLower(raw)]; ok { raw = alias } loc, err := time.LoadLocation(raw) if err != nil { return nil, fmt.Errorf("tz must be a valid timezone") } return loc, nil } func parseUTCOffsetSeconds(raw string) (int, bool, error) { if len(raw) < 2 { return 0, false, nil } sign := raw[0] if sign != '+' && sign != '-' { return 0, false, nil } remainder := raw[1:] parts := strings.Split(remainder, ":") if len(parts) > 2 { return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") } hours, err := strconv.Atoi(parts[0]) if err != nil { return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") } if hours < 0 { return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") } minutes := 0 if len(parts) == 2 { if len(parts[1]) != 2 { return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") } minutes, err = strconv.Atoi(parts[1]) if err != nil { return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") } } if minutes < 0 || minutes > 59 { return 0, true, fmt.Errorf("tz offset minutes must be between 00 and 59") } offset := (hours * 60 * 60) + (minutes * 60) if sign == '-' { offset = -offset } if offset < -maxUTCOffsetSeconds || offset > maxUTCOffsetSeconds { return 0, true, fmt.Errorf("tz offset must be between -14 and +14 hours") } return offset, true, nil } func formatUTCOffsetName(offsetSeconds int) string { sign := "+" if offsetSeconds < 0 { sign = "-" offsetSeconds = -offsetSeconds } hours := offsetSeconds / 3600 minutes := (offsetSeconds % 3600) / 60 return fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes) }