package classes import ( "bytes" "encoding/json" "io" "log/slog" "os" "strings" "time" ) // ManifestMaxBytes is the largest updates manifest the sitemap reads. const ManifestMaxBytes = 5 << 20 // Manifest maps a normalized URL path to its last-modified date text, the // updates manifest a frontend build writes. type Manifest map[string]string // LoadManifest reads the updates manifest at path: a regular file of at // most ManifestMaxBytes holding a JSON object of URL path to date. It fails // safe like PHP: an empty path, a missing, unreadable, oversized or invalid // file is no manifest (nil), logged without the file's content. Keys are // normalized like PHP normalizeManifestPath; values that are not strings are // ignored. func LoadManifest(path string) Manifest { path = strings.TrimSpace(path) if path == "" { return nil } info, err := os.Stat(path) if err != nil || !info.Mode().IsRegular() { return nil } if info.Size() > ManifestMaxBytes { slog.Default().Warn("golem15.sitemap: updates manifest is too large; lastmod omitted", "path", path, "max_bytes", ManifestMaxBytes) return nil } f, err := os.Open(path) if err != nil { return nil } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, ManifestMaxBytes+1)) if err != nil || len(raw) > ManifestMaxBytes { return nil } raw = bytes.TrimSpace(raw) var decoded map[string]any if len(raw) == 0 || raw[0] != '{' || json.Unmarshal(raw, &decoded) != nil { slog.Default().Warn("golem15.sitemap: updates manifest is not a JSON object; lastmod omitted", "path", path) return nil } m := Manifest{} for key, value := range decoded { if s, ok := value.(string); ok { m[normalizeManifestPath(key)] = s } } return m } // normalizeManifestPath is PHP normalizeManifestPath: a leading slash, no // trailing slash except for the root. func normalizeManifestPath(path string) string { path = "/" + strings.TrimLeft(path, "/") if path == "/" { return path } return strings.TrimRight(path, "/") } // Lastmod is PHP lookupManifestLastmod: the date listed for the URL's path, // parsed like the ISO 8601 forms strtotime reads (a date without a zone is // UTC). ok is false when the path is not listed or its date does not parse. func (m Manifest) Lastmod(url string) (time.Time, bool) { if len(m) == 0 { return time.Time{}, false } path := URLPath(url) if path == "" { path = "/" } text, ok := m[normalizeManifestPath(path)] if !ok { return time.Time{}, false } t, ok := parseManifestDate(text) if !ok || t.Unix() == 0 { return time.Time{}, false } return t, true } var manifestLayouts = []string{ time.RFC3339Nano, "2006-01-02T15:04:05.999999999Z0700", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05Z07:00", "2006-01-02 15:04:05", "2006-01-02", } func parseManifestDate(s string) (time.Time, bool) { s = strings.TrimSpace(s) for _, layout := range manifestLayouts { if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil { return t, true } } return time.Time{}, false }