All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
141 lines
3.3 KiB
Go
141 lines
3.3 KiB
Go
// 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)
|
|
}
|