commit fe39e26ce0799f467d317b133b5740cdee940a58 Author: Jakub Zych Date: Mon Oct 12 16:53:09 2026 +0200 feat(17-03): sitemap plugin tracer, /sitemap.xml byte-identical to PHP - golem15.sitemap plugin: golem15_sitemap_definitions migration, Definition model (Fillable theme and data) with tolerant PHP-shaped item decoding and PHP toArray item encoding - Url::to and buildUrl port, libxml-format serializer, generator for url and registry items - GET /sitemap.xml serves the definition with theme default (404 without one, empty body without items) - tests/php/gen-goldens.php and the tracer golden; TestSitemapGolden and the Postgres TestSitemapTracer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59bebe9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +go.work.sum diff --git a/classes/generator.go b/classes/generator.go new file mode 100644 index 0000000..a4f5729 --- /dev/null +++ b/classes/generator.go @@ -0,0 +1,147 @@ +// Package classes generates the sitemap: it walks the definition items, +// resolves registry items through the framework's menu item type registry +// and writes the PHP-identical XML. +package classes + +import ( + "context" + "time" + + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// MaxURLs is the sitemap protocol's URL limit (PHP Definition::MAX_URLS): +// the 50,000th URL is written, the 50,001st is not. +const MaxURLs = 50000 + +// lastmodLayout is PHP date('c') in UTC: "+00:00", never "Z". +const lastmodLayout = "2006-01-02T15:04:05-07:00" + +// Resolver resolves a registry item; *signpost.Registry implements it. +type Resolver interface { + Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) +} + +// Generator writes the sitemap of a definition. Root is the backend root URL +// (scheme and host without a trailing slash) that Url::to builds absolute +// URLs from. Resolver resolves registry items; nil skips them. +// UnprefixedLocale is handed to every Resolve as the locale without a URL +// prefix ("" leaves it to the type). +type Generator struct { + Root string + Resolver Resolver + UnprefixedLocale string + + entries []URLEntry + count int +} + +// itemInfo is PHP's $itemInfo: the URL (set marks it present), the record's +// modification time and the hreflang alternates. +type itemInfo struct { + url string + set bool + mtime *time.Time + alternates []Alternate +} + +// Generate returns the sitemap XML of items, or "" when there are none (PHP +// generateSitemap). A url item is added with its URL; a registry item is +// resolved with its URL pattern and nesting, its URL added when it has one +// and its sub-items added recursively. A type the registry does not know, +// or a Resolve that finds nothing, adds nothing. +func (g *Generator) Generate(ctx context.Context, items []models.DefinitionItem) (string, error) { + if len(items) == 0 { + return "", nil + } + g.entries, g.count = nil, 0 + for i := range items { + item := &items[i] + if item.Type == models.ItemTypeURL { + g.addItem(item, itemInfo{}) + continue + } + resolved, err := g.resolve(ctx, item) + if err != nil { + return "", err + } + if resolved == nil { + continue + } + if resolved.URL != "" { + g.addItem(item, infoOf(*resolved)) + } + g.addChildren(item, resolved.Items) + } + return Serialize(g.Stylesheet(), g.entries), nil +} + +// Stylesheet is the stylesheet href PHP built: +// Url::buildUrl(Url::current(), ['path' => 'sitemap.xsl']). +func (g *Generator) Stylesheet() string { + return replacePath(URLTo(g.Root, "/sitemap.xml"), "sitemap.xsl") +} + +func (g *Generator) resolve(ctx context.Context, item *models.DefinitionItem) (*pact.ResolvedMenuItem, error) { + if g.Resolver == nil || item.Type == "" { + return nil, nil + } + if reg, ok := g.Resolver.(interface { + Type(string) (pact.MenuItemType, bool) + }); ok { + if _, known := reg.Type(item.Type); !known { + return nil, nil + } + } + return g.Resolver.Resolve(ctx, item.Type, models.Text(item.Reference), models.Text(item.URLPattern), item.Nesting, g.UnprefixedLocale) +} + +// addChildren adds every child with a URL, then its own children. +func (g *Generator) addChildren(item *models.DefinitionItem, children []pact.ResolvedMenuItem) { + for _, child := range children { + if child.URL != "" { + g.addItem(item, infoOf(child)) + } + g.addChildren(item, child.Items) + } +} + +func infoOf(r pact.ResolvedMenuItem) itemInfo { + info := itemInfo{url: r.URL, set: true, mtime: r.MTime} + for _, a := range r.Alternates { + info.alternates = append(info.alternates, Alternate{Hreflang: a.Locale, Href: a.URL}) + } + return info +} + +// addItem is PHP Definition::addItemToSet. +func (g *Generator) addItem(item *models.DefinitionItem, info itemInfo) { + if g.count >= MaxURLs { + return + } + if !info.set { + info.url = URLTo(g.Root, models.Text(item.URL)) + } + lastmod := "" + if info.mtime != nil && info.mtime.Unix() != 0 { + lastmod = info.mtime.UTC().Format(lastmodLayout) + } + if info.url == "" || info.url == "0" { + return + } + loc := URLTo(g.Root, info.url) + g.count++ + entry := URLEntry{ + Loc: loc, + Lastmod: lastmod, + Changefreq: models.Text(item.Changefreq), + Priority: models.Text(item.Priority), + } + if len(info.alternates) > 1 { + for _, a := range info.alternates { + entry.Alternates = append(entry.Alternates, Alternate{Hreflang: a.Hreflang, Href: URLTo(g.Root, a.Href)}) + } + } + g.entries = append(g.entries, entry) +} diff --git a/classes/golden_test.go b/classes/golden_test.go new file mode 100644 index 0000000..58a1682 --- /dev/null +++ b/classes/golden_test.go @@ -0,0 +1,109 @@ +package classes_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "git.golem15.com/golem15/sm-sitemap-plugin/classes" + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// The goldens in testdata are written by tests/php/gen-goldens.php from the +// same JSON inputs with the PHP plugin's generation code and the vendored +// Winter URL generator; these tests feed the inputs through the Go port and +// compare bytes. + +// goldenResolved is a registry item's resolve result in a golden input. +type goldenResolved struct { + URL string `json:"url"` + MTime *string `json:"mtime"` + Alternates [][2]string `json:"alternates"` + Items []goldenResolved `json:"items"` +} + +func (g goldenResolved) toPact(t *testing.T) pact.ResolvedMenuItem { + t.Helper() + out := pact.ResolvedMenuItem{URL: g.URL} + if g.MTime != nil { + at, err := time.Parse(time.RFC3339, *g.MTime) + if err != nil { + t.Fatalf("mtime %q: %v", *g.MTime, err) + } + out.MTime = &at + } + for _, a := range g.Alternates { + out.Alternates = append(out.Alternates, pact.MenuItemAlternate{Locale: a[0], URL: a[1]}) + } + for _, child := range g.Items { + out.Items = append(out.Items, child.toPact(t)) + } + return out +} + +// goldenCase is one sitemap case of a golden input. +type goldenCase struct { + Name string `json:"name"` + Root string `json:"root"` + Items models.DefinitionItems `json:"items"` + Types map[string]map[string]goldenResolved `json:"types"` +} + +// fakeResolver answers Resolve from a golden case's types. +type fakeResolver struct { + t *testing.T + types map[string]map[string]goldenResolved +} + +func (f fakeResolver) Type(key string) (pact.MenuItemType, bool) { + _, ok := f.types[key] + return pact.MenuItemType{Key: key}, ok +} + +func (f fakeResolver) Resolve(_ context.Context, key, reference, _ string, _ bool, _ string) (*pact.ResolvedMenuItem, error) { + r, ok := f.types[key][reference] + if !ok { + return nil, nil + } + out := r.toPact(f.t) + return &out, nil +} + +func renderCase(t *testing.T, c goldenCase) string { + t.Helper() + gen := &classes.Generator{Root: c.Root, Resolver: fakeResolver{t: t, types: c.Types}} + xml, err := gen.Generate(context.Background(), c.Items) + if err != nil { + t.Fatalf("generate %s: %v", c.Name, err) + } + return xml +} + +func readTestdata(t *testing.T, name string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + return raw +} + +// TestSitemapGolden compares the Go sitemap with the PHP goldens. +func TestSitemapGolden(t *testing.T) { + t.Run("tracer", func(t *testing.T) { + var in struct { + Case goldenCase `json:"case"` + } + if err := json.Unmarshal(readTestdata(t, "tracer.json"), &in); err != nil { + t.Fatal(err) + } + got := renderCase(t, in.Case) + if want := string(readTestdata(t, "tracer.golden")); got != want { + t.Fatalf("tracer sitemap differs from the PHP golden\n got: %q\nwant: %q", got, want) + } + }) +} diff --git a/classes/serialize.go b/classes/serialize.go new file mode 100644 index 0000000..5de12e1 --- /dev/null +++ b/classes/serialize.go @@ -0,0 +1,105 @@ +package classes + +import "strings" + +// The sitemap document is written by hand in the exact format PHP's +// DOMDocument::saveXML (libxml2) produced for the PHP plugin: the XML +// declaration and the stylesheet processing instruction each followed by a +// newline, everything else on one line, an empty urlset self-closed, +// xhtml:link elements self-closed without a space. The standard library's +// XML encoder escapes and closes elements differently, so the writer is a +// strings.Builder. + +// The urlset opening tag's attributes, in the order PHP set them. +const urlsetAttrs = ` xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"` + + ` xmlns:xhtml="http://www.w3.org/1999/xhtml"` + + ` xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"` + + ` xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"` + +// textEscaper is libxml's escaping of text nodes: &, <, > and carriage +// return; quotes, tabs, newlines and UTF-8 stay literal. +var textEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\r", " ") + +// attrEscaper is libxml's escaping of attribute values: also the double +// quote, tab and newline; the single quote stays literal. +var attrEscaper = strings.NewReplacer( + "&", "&", "<", "<", ">", ">", `"`, """, + "\r", " ", "\t", " ", "\n", " ", +) + +// EscapeText escapes s as libxml writes a text node. +func EscapeText(s string) string { return textEscaper.Replace(s) } + +// EscapeAttr escapes s as libxml writes an attribute value. +func EscapeAttr(s string) string { return attrEscaper.Replace(s) } + +// Alternate is one hreflang link of a sitemap URL. +type Alternate struct { + Hreflang string + Href string +} + +// URLEntry is one url element: Loc, an optional Lastmod, Changefreq and +// Priority as they are written, the alternates (written only when there are +// more than one) and Extra, pre-escaped child XML that AddItem listeners +// appended, written before the closing tag. +type URLEntry struct { + Loc string + Lastmod string + Changefreq string + Priority string + Alternates []Alternate + Extra []string +} + +// writeURL appends one url element. +func writeURL(b *strings.Builder, e URLEntry) { + b.WriteString("") + b.WriteString(EscapeText(e.Loc)) + b.WriteString("") + if e.Lastmod != "" { + b.WriteString("") + b.WriteString(EscapeText(e.Lastmod)) + b.WriteString("") + } + b.WriteString("") + b.WriteString(EscapeText(e.Changefreq)) + b.WriteString("") + b.WriteString(EscapeText(e.Priority)) + b.WriteString("") + if len(e.Alternates) > 1 { + for _, a := range e.Alternates { + b.WriteString(``) + } + } + for _, x := range e.Extra { + b.WriteString(x) + } + b.WriteString("") +} + +// Serialize writes the sitemap document: the XML declaration, the +// stylesheet processing instruction pointing at stylesheet (written as it +// is, like libxml writes PI data) and the urlset with entries. +func Serialize(stylesheet string, entries []URLEntry) string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(`\n\n") + return b.String() + } + b.WriteString(">") + for _, e := range entries { + writeURL(&b, e) + } + b.WriteString("\n") + return b.String() +} diff --git a/classes/testdata/tracer.golden b/classes/testdata/tracer.golden new file mode 100644 index 0000000..4a6dc3d --- /dev/null +++ b/classes/testdata/tracer.golden @@ -0,0 +1,3 @@ + + +https://api.example.test/always1.0https://api.example.test/news/hello2026-03-04T10:00:00+00:00weekly0.5 diff --git a/classes/testdata/tracer.json b/classes/testdata/tracer.json new file mode 100644 index 0000000..ff5db2b --- /dev/null +++ b/classes/testdata/tracer.json @@ -0,0 +1,18 @@ +{ + "case": { + "root": "https://api.example.test", + "items": [ + {"type": "url", "url": "/", "changefreq": "always", "priority": "1.0", "nesting": "0"}, + {"type": "acme-post", "reference": "7", "urlPattern": "", "changefreq": "weekly", "priority": "0.5", "nesting": "1"} + ], + "types": { + "acme-post": { + "7": { + "url": "/news/hello", + "mtime": "2026-03-04T10:00:00+00:00", + "alternates": [["pl", "/news/hello"], ["en", "/en/news/hello"]] + } + } + } + } +} diff --git a/classes/urlto.go b/classes/urlto.go new file mode 100644 index 0000000..b5a9a53 --- /dev/null +++ b/classes/urlto.go @@ -0,0 +1,533 @@ +package classes + +import ( + "regexp" + "strconv" + "strings" +) + +// This file ports the parts of WinterCMS's URL generator the PHP sitemap +// called, so the Go sitemap writes the same bytes: Laravel's Url::to plus +// Winter's UrlGenerator::buildUrl, PHP's parse_url, rawurlencode, +// rawurldecode and urldecode, and FILTER_VALIDATE_URL. It is deliberately +// hand written: net/url escapes differently. The golden table +// testdata/urlto.golden, generated from the vendored Winter router, pins +// it. + +// validURLPrefix is Laravel UrlGenerator::isValidUrl's fast path. +var validURLPrefix = regexp.MustCompile(`^(#|//|https?://|(mailto|tel|sms):)`) + +// URLTo is Winter's Url::to(path) for a request whose root URL is root +// (scheme and host, plus a base path if any, without a trailing slash). A +// valid URL is kept; anything else becomes root plus "/" plus the path +// trimmed of slashes, the query string appended as it is. The result then +// goes through BuildURL, which re-encodes every path segment, so "/" is the +// root with its slash, "/blog/" loses its trailing slash and ":" becomes +// "%3A". +func URLTo(root, path string) string { + if isValidURL(path) { + return BuildURL(path) + } + p, query := path, "" + if i := strings.IndexByte(path, '?'); i >= 0 { + p, query = path[:i], path[i:] + } + inner := strings.Trim(p+"/", "/") + return BuildURL(strings.Trim(root+"/"+inner, "/") + query) +} + +// isValidURL is Laravel UrlGenerator::isValidUrl. +func isValidURL(path string) bool { + if validURLPrefix.MatchString(path) { + return true + } + return filterValidateURL(path) +} + +// filterURLChars is the character set PHP's FILTER_VALIDATE_URL accepts; +// a value with any other byte fails. +const filterURLChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$-_.+!*'(),{}|\\^~[]`<>#%\";/?:@&=" + +// filterValidateURL approximates PHP filter_var(FILTER_VALIDATE_URL): only +// URL characters, a scheme, a host unless the scheme is mailto, news or +// file, and a valid host name for http and https. +func filterValidateURL(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if !strings.ContainsRune(filterURLChars, rune(s[i])) { + return false + } + } + u := parseURL(s) + if !u.has(partScheme) || u.scheme == "" { + return false + } + scheme := strings.ToLower(u.scheme) + if !u.has(partHost) || u.host == "" { + return scheme == "mailto" || scheme == "news" || scheme == "file" + } + if scheme == "http" || scheme == "https" { + return validHostName(u.host) + } + return true +} + +// validHostName reports whether host is dot-separated labels of letters, +// digits and inner hyphens. +func validHostName(host string) bool { + host = strings.TrimSuffix(host, ".") + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for i := 0; i < len(label); i++ { + c := label[i] + if !(isAlnum(c) || c == '-') { + return false + } + } + } + return true +} + +// The parts a parsed URL may hold, as PHP parse_url's array keys. +const ( + partScheme = 1 << iota + partHost + partUser + partPass + partPort + partPath + partQuery + partFragment +) + +// phpURL is the array PHP parse_url returns; set records which keys exist. +type phpURL struct { + set int + scheme, host, user, pass, port, path, query, frag string +} + +func (u phpURL) has(part int) bool { return u.set&part != 0 } + +// parseURL ports PHP parse_url for the URL shapes a sitemap meets: an +// optional scheme, an authority after "//", a path, a query and a fragment. +// Control characters in a part are replaced by "_", as PHP does. +func parseURL(s string) phpURL { + var u phpURL + rest := s + if i := strings.IndexByte(rest, ':'); i > 0 && validScheme(rest[:i]) { + after := rest[i+1:] + if !strings.HasPrefix(after, "//") && portOnly(after) { + // "host:port/path" without a scheme. + } else { + u.scheme, u.set = rest[:i], u.set|partScheme + rest = after + } + } + if strings.HasPrefix(rest, "//") { + rest = rest[2:] + end := strings.IndexAny(rest, "/?#") + authority := rest + if end >= 0 { + authority, rest = rest[:end], rest[end:] + } else { + rest = "" + } + u.parseAuthority(authority) + } + if i := strings.IndexByte(rest, '#'); i >= 0 { + u.frag, u.set = rest[i+1:], u.set|partFragment + rest = rest[:i] + } + if i := strings.IndexByte(rest, '?'); i >= 0 { + u.query, u.set = rest[i+1:], u.set|partQuery + rest = rest[:i] + } + if rest != "" { + u.path, u.set = rest, u.set|partPath + } + u.scheme = replaceControl(u.scheme) + u.host = replaceControl(u.host) + u.user = replaceControl(u.user) + u.pass = replaceControl(u.pass) + u.path = replaceControl(u.path) + u.query = replaceControl(u.query) + u.frag = replaceControl(u.frag) + return u +} + +func (u *phpURL) parseAuthority(a string) { + if i := strings.LastIndexByte(a, '@'); i >= 0 { + userinfo := a[:i] + a = a[i+1:] + if j := strings.IndexByte(userinfo, ':'); j >= 0 { + u.user, u.pass = userinfo[:j], userinfo[j+1:] + u.set |= partUser | partPass + } else { + u.user = userinfo + u.set |= partUser + } + } + if i := strings.LastIndexByte(a, ':'); i >= 0 && !strings.HasSuffix(a, "]") { + port := a[i+1:] + if port != "" && allDigits(port) { + u.port, u.set = port, u.set|partPort + } + a = a[:i] + } + if a != "" { + u.host, u.set = a, u.set|partHost + } +} + +func validScheme(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if !(isAlnum(c) || c == '+' || c == '-' || c == '.') { + return false + } + } + return s != "" +} + +// portOnly reports whether s is a port number up to the first "/", the +// case PHP parse_url reads as host:port rather than scheme:path. +func portOnly(s string) bool { + end := strings.IndexByte(s, '/') + if end < 0 { + end = len(s) + } + return end > 0 && end <= 5 && allDigits(s[:end]) +} + +func allDigits(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return s != "" +} + +func isAlnum(c byte) bool { + return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' +} + +func replaceControl(s string) string { + if strings.IndexFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) < 0 { + return s + } + b := []byte(s) + for i, c := range b { + if c < 0x20 || c == 0x7f { + b[i] = '_' + } + } + return string(b) +} + +// phpTrim is PHP trim() with its default character list. +func phpTrim(s string) string { + return strings.Trim(s, " \t\n\r\x00\x0b") +} + +// singleColonSchemes are written "scheme:" instead of "scheme://". +var singleColonSchemes = map[string]bool{"mailto": true, "tel": true, "sms": true} + +// defaultPorts are Winter UrlGenerator::isDefaultPort's known ports. +var defaultPorts = map[string]int{ + "ftp": 21, "ssh": 22, "sftp": 22, "telnet": 23, "smtp": 25, "dns": 53, + "gopher": 70, "http": 80, "pop3": 110, "imap": 143, "ldap": 389, + "https": 443, "smtps": 465, "ldaps": 636, "ftps": 990, "imaps": 993, + "pop3s": 995, +} + +// BuildURL is Winter UrlGenerator::buildUrl($url) with no replacements: the +// query is URL-decoded, the URL parsed, every path segment re-encoded with +// rawurlencode(rawurldecode()) after "." and ".." are resolved, the query +// rebuilt with buildStr and the fragment re-encoded. +func BuildURL(raw string) string { + return buildParts(parseURL(decodeQueryParams(raw))) +} + +// replacePath is buildUrl($url, ['path' => path]). +func replacePath(raw, path string) string { + u := parseURL(decodeQueryParams(raw)) + u.path, u.set = path, u.set|partPath + return buildParts(u) +} + +// decodeQueryParams URL-decodes everything after the first "?". +func decodeQueryParams(s string) string { + if i := strings.IndexByte(s, '?'); i >= 0 { + return s[:i] + "?" + urldecode(s[i+1:]) + } + return s +} + +func buildParts(u phpURL) string { + u.scheme, u.host, u.user, u.pass = phpTrim(u.scheme), phpTrim(u.host), phpTrim(u.user), phpTrim(u.pass) + u.path, u.query, u.frag = phpTrim(u.path), phpTrim(u.query), phpTrim(u.frag) + port := 0 + if u.has(partPort) { + if n, err := strconv.Atoi(phpTrim(u.port)); err == nil && n >= 1 && n <= 65535 { + port = n + } + } + var b strings.Builder + hasScheme := u.has(partScheme) && u.scheme != "" + if hasScheme { + b.WriteString(u.scheme) + if singleColonSchemes[u.scheme] { + b.WriteString(":") + } else { + b.WriteString("://") + } + } + if u.has(partUser) && u.user != "" { + b.WriteString(u.user) + if u.has(partPass) && u.pass != "" { + b.WriteString(":" + u.pass) + } + b.WriteString("@") + } + if u.has(partHost) && u.host != "" { + b.WriteString(u.host) + } + if port != 0 { + if def, ok := defaultPorts[strings.ToLower(u.scheme)]; !ok || def != port { + b.WriteString(":" + strconv.Itoa(port)) + } + } + path := "" + if u.has(partPath) && u.path != "" { + var kept []string + for _, seg := range strings.Split(u.path, "/") { + switch { + case seg == ".": + case seg == ".." && len(kept) > 0: + kept = kept[:len(kept)-1] + default: + kept = append(kept, seg) + } + } + for i, seg := range kept { + kept[i] = rawurlencode(rawurldecode(seg)) + } + path = strings.Join(kept, "/") + } + if hasScheme && !singleColonSchemes[u.scheme] { + if !strings.HasPrefix(path, "/") { + b.WriteString("/") + } + b.WriteString(path) + } else { + b.WriteString(path) + } + if u.has(partQuery) && u.query != "" { + b.WriteString("?" + buildStr(parseQueryPairs(u.query), "")) + } + if u.has(partFragment) && u.frag != "" { + b.WriteString("#" + rawurlencode(rawurldecode(u.frag))) + } + return b.String() +} + +// phpKey is a PHP array key: an int or a string. +type phpKey struct { + isInt bool + i int64 + s string +} + +func (k phpKey) String() string { + if k.isInt { + return strconv.FormatInt(k.i, 10) + } + return k.s +} + +// keyOf is PHP's array key cast of a string: a canonical decimal integer +// becomes an int key. +func keyOf(s string) phpKey { + if n, err := strconv.ParseInt(s, 10, 64); err == nil && strconv.FormatInt(n, 10) == s { + return phpKey{isInt: true, i: n} + } + return phpKey{s: s} +} + +// phpArray is an ordered PHP array of strings, nested arrays or nulls. +type phpArray struct { + keys []phpKey + vals []any + next int64 +} + +func (a *phpArray) index(k phpKey) int { + for i, have := range a.keys { + if have == k { + return i + } + } + return -1 +} + +func (a *phpArray) set(k phpKey, v any) { + if i := a.index(k); i >= 0 { + a.vals[i] = v + return + } + a.keys = append(a.keys, k) + a.vals = append(a.vals, v) + if k.isInt && k.i >= a.next { + a.next = k.i + 1 + } +} + +func (a *phpArray) push(v any) { + a.set(phpKey{isInt: true, i: a.next}, v) +} + +// parseQueryPairs is buildUrl's split of a string query into +// $queryParams. +func parseQueryPairs(query string) *phpArray { + params := &phpArray{} + for _, pair := range strings.Split(query, "&") { + key, value := pair, pair + if i := strings.IndexByte(pair, '='); i >= 0 { + key, value = pair[:i], pair[i+1:] + } + if !strings.Contains(pair, "=") { + params.push(key) + continue + } + if strings.Contains(urldecode(pair), "[") { + k := keyOf(key) + var inner *phpArray + if i := params.index(k); i >= 0 { + inner, _ = params.vals[i].(*phpArray) + } + if inner == nil { + inner = &phpArray{} + } + inner.push(value) + params.set(k, inner) + continue + } + params.set(keyOf(key), value) + } + return params +} + +// buildStr is Winter UrlGenerator::buildStr. +func buildStr(query *phpArray, prefix string) string { + var out []string + for i, k := range query.keys { + v := query.vals[i] + var key string + if k.isInt && k.i == int64(i) { + if prefix != "" { + if strings.HasSuffix(prefix, "]") { + key = prefix + } else { + key = prefix + "[]" + } + } else { + key, _ = v.(string) + v = nil + } + } else if prefix != "" { + key = prefix + "[" + k.String() + "]" + } else { + key = k.String() + } + if nested, ok := v.(*phpArray); ok { + out = append(out, buildStr(nested, key)) + continue + } + part := rawurlencode(rawurldecode(key)) + if s, ok := v.(string); ok { + part += "=" + rawurlencode(rawurldecode(s)) + } + out = append(out, part) + } + return strings.Join(out, "&") +} + +// rawurlencode is PHP rawurlencode: every byte except A-Z a-z 0-9 - _ . ~ +// becomes %XX with upper-case hex. +func rawurlencode(s string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if isAlnum(c) || c == '-' || c == '_' || c == '.' || c == '~' { + b.WriteByte(c) + continue + } + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&15]) + } + return b.String() +} + +// rawurldecode is PHP rawurldecode: %XX becomes its byte, anything else, +// including a malformed escape, stays. +func rawurldecode(s string) string { return percentDecode(s, false) } + +// urldecode is PHP urldecode: rawurldecode that also turns "+" into a +// space. +func urldecode(s string) string { return percentDecode(s, true) } + +func percentDecode(s string, plus bool) string { + if !strings.ContainsAny(s, "%+") { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c == '+' && plus { + b.WriteByte(' ') + continue + } + if c == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]) { + b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2])) + i += 2 + continue + } + b.WriteByte(c) + } + return b.String() +} + +func isHex(c byte) bool { + return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F' +} + +func unhex(c byte) byte { + switch { + case '0' <= c && c <= '9': + return c - '0' + case 'a' <= c && c <= 'f': + return c - 'a' + 10 + default: + return c - 'A' + 10 + } +} + +// URLPath is the path PHP parse_url($url, PHP_URL_PATH) returns, "" when +// the URL has none. +func URLPath(raw string) string { + u := parseURL(raw) + if !u.has(partPath) { + return "" + } + return u.path +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9fe4973 --- /dev/null +++ b/go.mod @@ -0,0 +1,123 @@ +module git.golem15.com/golem15/sm-sitemap-plugin + +go 1.27.0 + +require ( + git.golem15.com/golem15/summercms v0.1.23 + github.com/go-gormigrate/gormigrate/v2 v2.1.7 + github.com/jackc/pgx/v5 v5.10.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + gorm.io/gorm v1.31.2 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect + github.com/knadh/koanf/v2 v2.3.6 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/lib/pq v1.12.3 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/riverqueue/river v0.47.0 // indirect + github.com/riverqueue/river/riverdriver v0.47.0 // indirect + github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect + github.com/riverqueue/river/rivershared v0.47.0 // indirect + github.com/riverqueue/river/rivertype v0.47.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/wneessen/go-mail v0.8.1 // indirect + github.com/yuin/goldmark v1.8.6 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + gocloud.dev v0.46.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/image v0.46.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gorm.io/driver/postgres v1.6.3 // indirect +) + +replace git.golem15.com/golem15/summercms => ../summercms.go diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9af8e9e --- /dev/null +++ b/go.sum @@ -0,0 +1,368 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= +cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= +github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= +github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= +github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc= +github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ= +github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60= +github.com/riverqueue/river/riverdriver v0.47.0/go.mod h1:NOXl0fUiF1AT/TaQOjdx2A/c0Davn+SKbW8nAXWjfC4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 h1:4wckhMlLN08F76/GWxGF6vlrbs5HCMuV01dpUnvOGI4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0/go.mod h1:EME1EGr7RU/vMUf61mpf9YXhsZJ+GlFk4AHrIHT+YdE= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 h1:5N9nvemhQwbUElMxASw4oEaYJ/v6hiS5Y9VcOQfdC5g= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0/go.mod h1:ZboiXXZKC4+fTkxBxGRVmAsCuUu0NPYliqbWYuQAZyw= +github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOtPKQZAmThkjFpLs= +github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418= +github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0= +github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= +gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= +gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/go.work b/go.work new file mode 100644 index 0000000..1b4530f --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.27.0 + +use . + +replace git.golem15.com/golem15/summercms => ../summercms.go + +replace git.golem15.com/golem15/sm-translate-plugin => ../sm-translate-plugin diff --git a/models/definition.go b/models/definition.go new file mode 100644 index 0000000..5a3dc7a --- /dev/null +++ b/models/definition.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +// DefinitionTheme is the fixed theme key of the one sitemap definition of an +// application (D-11). The theme column stays so the table matches PHP; the +// import commands write this key whatever the PHP theme was. +const DefinitionTheme = "default" + +// Definition is a sitemap definition stored on golem15_sitemap_definitions, +// the PHP Golem15\Sitemap\Models\Definition. Data holds the items as JSON +// text (PHP $jsonable data). +type Definition struct { + ID uint `gorm:"column:id;primaryKey"` + Theme *string `gorm:"column:theme"` + Data lagoon.Jsonable[DefinitionItems] `gorm:"column:data"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (Definition) TableName() string { return "golem15_sitemap_definitions" } + +// MorphName is the PHP class string of the model. +func (Definition) MorphName() string { return `Golem15\Sitemap\Models\Definition` } + +// Fillable is exactly theme and data, the PHP $fillable with $guarded = ['*'] +// (MassAssignmentTest). +func (Definition) Fillable() []string { return []string{"theme", "data"} } + +// Items returns the stored items. +func (d Definition) Items() []DefinitionItem { return d.Data.Get() } + +// SetItems stores items as the definition's data. +func (d *Definition) SetItems(items []DefinitionItem) { + d.Data = lagoon.Jsonable[DefinitionItems]{Data: DefinitionItems(items), Valid: true} +} + +func init() { + Register(Definition{}) +} diff --git a/models/definition_item.go b/models/definition_item.go new file mode 100644 index 0000000..24a0d42 --- /dev/null +++ b/models/definition_item.go @@ -0,0 +1,255 @@ +package models + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// ItemTypeURL is the built-in item type of an explicit URL. Every other +// item type is a menu item type of the framework's registry (signpost). +const ItemTypeURL = "url" + +// DefinitionItem is one sitemap definition item, the PHP +// Golem15\Sitemap\Classes\DefinitionItem. Type is "url" or a registry menu +// item type key; URL is the address of a url item; Reference is the record a +// registry item points at; URLPattern overrides the type's URL pattern; +// Nesting asks a registry item for its sub-items; Priority and Changefreq +// are written to the sitemap as they are stored. +// +// Text fields are pointers so a stored JSON null stays null on the next +// save. Decoding is tolerant of what PHP stored: numbers and booleans are +// accepted for text fields, strings and numbers for Nesting, and unknown keys +// (cmsPage, code, exists, fillable and any other) are ignored. Encoding +// writes the PHP toArray key set: changefreq, priority, nesting, type, url, +// reference, cmsPage (always null, CMS pages are not supported) and +// urlPattern. +type DefinitionItem struct { + Type string + URL *string + Reference *string + URLPattern *string + Nesting bool + Priority *string + Changefreq *string +} + +// Text returns the value of a text field, "" for null. +func Text(p *string) string { + if p == nil { + return "" + } + return *p +} + +// UnmarshalJSON decodes one stored item tolerantly. A missing type is the +// PHP default "url"; a null type is "". +func (d *DefinitionItem) UnmarshalJSON(raw []byte) error { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + // PHP initFromArray kept a default item for a null entry. + *d = DefinitionItem{Type: ItemTypeURL} + return nil + } + var fields map[string]json.RawMessage + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&fields); err != nil { + return fmt.Errorf("sitemap definition item: %w", err) + } + item := DefinitionItem{Type: ItemTypeURL} + if v, ok := fields["type"]; ok { + s, err := decodeText(v) + if err != nil { + return fmt.Errorf("sitemap definition item type: %w", err) + } + item.Type = Text(s) + } + for key, dest := range map[string]**string{ + "url": &item.URL, + "reference": &item.Reference, + "urlPattern": &item.URLPattern, + "priority": &item.Priority, + "changefreq": &item.Changefreq, + } { + v, ok := fields[key] + if !ok { + continue + } + s, err := decodeText(v) + if err != nil { + return fmt.Errorf("sitemap definition item %s: %w", key, err) + } + *dest = s + } + if v, ok := fields["nesting"]; ok { + b, err := decodeTruthy(v) + if err != nil { + return fmt.Errorf("sitemap definition item nesting: %w", err) + } + item.Nesting = b + } + *d = item + return nil +} + +// MarshalJSON writes the PHP toArray key set in its order. +func (d DefinitionItem) MarshalJSON() ([]byte, error) { + var b bytes.Buffer + b.WriteByte('{') + write := func(key string, v any, last bool) error { + kb, _ := json.Marshal(key) + b.Write(kb) + b.WriteByte(':') + vb, err := json.Marshal(v) + if err != nil { + return err + } + b.Write(vb) + if !last { + b.WriteByte(',') + } + return nil + } + for _, f := range []struct { + key string + v any + }{ + {"changefreq", d.Changefreq}, + {"priority", d.Priority}, + {"nesting", d.Nesting}, + {"type", d.Type}, + {"url", d.URL}, + {"reference", d.Reference}, + {"cmsPage", nil}, + } { + if err := write(f.key, f.v, false); err != nil { + return nil, err + } + } + if err := write("urlPattern", d.URLPattern, true); err != nil { + return nil, err + } + b.WriteByte('}') + return b.Bytes(), nil +} + +// DefinitionItems is the stored item list of a definition. It decodes a +// JSON array, and a JSON object (a PHP array whose keys were not sequential) +// in key order. +type DefinitionItems []DefinitionItem + +// UnmarshalJSON accepts a JSON array or object of items; null is no items. +func (l *DefinitionItems) UnmarshalJSON(raw []byte) error { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + *l = nil + return nil + } + if raw[0] == '[' { + var items []DefinitionItem + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + *l = items + return nil + } + if raw[0] != '{' { + return fmt.Errorf("sitemap definition data must be a list of items") + } + dec := json.NewDecoder(bytes.NewReader(raw)) + if _, err := dec.Token(); err != nil { + return err + } + var items []DefinitionItem + for dec.More() { + if _, err := dec.Token(); err != nil { + return err + } + var item DefinitionItem + if err := dec.Decode(&item); err != nil { + return err + } + items = append(items, item) + } + *l = items + return nil +} + +// MarshalJSON writes a JSON array; no items is []. +func (l DefinitionItems) MarshalJSON() ([]byte, error) { + if l == nil { + return []byte("[]"), nil + } + return json.Marshal([]DefinitionItem(l)) +} + +// decodeText decodes a stored text value: a string as it is, a number in +// its PHP string form, a boolean as PHP casts it ("1" or ""), null as nil. +func decodeText(raw json.RawMessage) (*string, error) { + var v any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { + return nil, err + } + var s string + switch t := v.(type) { + case nil: + return nil, nil + case string: + s = t + case json.Number: + s = phpNumberString(t) + case bool: + if t { + s = "1" + } + default: + return nil, fmt.Errorf("must be text, got %T", v) + } + return &s, nil +} + +// decodeTruthy decodes a stored flag with PHP truthiness: false, 0, "", +// "0" and null are false. +func decodeTruthy(raw json.RawMessage) (bool, error) { + var v any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { + return false, err + } + switch t := v.(type) { + case nil: + return false, nil + case bool: + return t, nil + case string: + return t != "" && t != "0", nil + case json.Number: + f, err := t.Float64() + if err != nil { + return false, err + } + return f != 0, nil + } + return false, fmt.Errorf("must be a flag, got %T", v) +} + +// phpNumberString is PHP's string cast of a json_decode'd number: an +// integer as written, a float in its shortest round-trip form (1.0 is "1"). +func phpNumberString(n json.Number) string { + text := n.String() + if !strings.ContainsAny(text, ".eE") { + if i, err := strconv.ParseInt(text, 10, 64); err == nil { + return strconv.FormatInt(i, 10) + } + } + f, err := n.Float64() + if err != nil { + return text + } + return strconv.FormatFloat(f, 'f', -1, 64) +} diff --git a/models/registry.go b/models/registry.go new file mode 100644 index 0000000..87885a9 --- /dev/null +++ b/models/registry.go @@ -0,0 +1,16 @@ +// Package models holds the sitemap plugin's GORM models: the sitemap +// definition and its items. +package models + +var all []any + +// Register appends GORM models so later files add models without editing +// plugin.go. +func Register(models ...any) { + all = append(all, models...) +} + +// All returns every model registered by this package. +func All() []any { + return all +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..0b4934e --- /dev/null +++ b/plugin.go @@ -0,0 +1,50 @@ +// Package sitemap is the golem15.sitemap plugin: it serves /sitemap.xml +// from the application's one sitemap definition, byte for byte the way the +// WinterCMS Golem15.Sitemap plugin did, with items that are explicit URLs +// or menu item types of the framework's registry. +package sitemap + +import ( + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/sm-sitemap-plugin/updates" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) +) + +// Plugin is the golem15.sitemap plugin. +type Plugin struct { + app *backpack.App +} + +func (p *Plugin) ID() string { return "golem15.sitemap" } + +// Requires is empty: the sitemap reads content types through the framework's +// menu item type registry, not from a named plugin. +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } + +func (p *Plugin) Models() []any { return models.All() } + +func init() { + party.Register(&Plugin{}) +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..728cd86 --- /dev/null +++ b/routes.go @@ -0,0 +1,137 @@ +package sitemap + +import ( + "errors" + "log/slog" + "net" + "net/http" + "net/netip" + "strings" + + "git.golem15.com/golem15/sm-sitemap-plugin/classes" + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/signpost" + "git.golem15.com/golem15/summercms/modules/surf" + "gorm.io/gorm" +) + +// The response headers of the PHP routes (live responses). +const ( + contentTypeXML = "application/xml" + cacheControl = "no-cache, private" +) + +// Routes mounts GET /sitemap.xml at the application root, outside any +// throttle group and without the backend middleware: search engines fetch it +// anonymously. +func (p *Plugin) Routes(r pact.Router) error { + r.Get("/sitemap.xml", p.sitemapXML) + return nil +} + +// sitemapXML serves the definition with the fixed theme key (D-11): 404 +// when there is none, an empty application/xml body when it has no items, +// else the generated XML. +func (p *Plugin) sitemapXML(w http.ResponseWriter, r *http.Request) { + app := p.app + db, err := database(app) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + var defs []models.Definition + if err := db.WithContext(r.Context()).Where("theme = ?", models.DefinitionTheme).Order("id ASC").Limit(1).Find(&defs).Error; err != nil { + slog.Default().Error("golem15.sitemap: load definition", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if len(defs) == 0 { + slog.Default().Info("golem15.sitemap: No sitemap definition was found. Try creating one first.") + http.NotFound(w, r) + return + } + gen := &classes.Generator{Root: backendURL(app, r)} + if reg, ok := app.Lookup[*signpost.Registry](); ok && reg != nil { + gen.Resolver = reg + } + xml, err := gen.Generate(r.Context(), defs[0].Items()) + if err != nil { + slog.Default().Error("golem15.sitemap: generate", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", contentTypeXML) + w.Header().Set("Cache-Control", cacheControl) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(xml)) +} + +// database returns the app's published *gorm.DB. +func database(app *backpack.App) (*gorm.DB, error) { + if app == nil { + return nil, errors.New("golem15.sitemap: app is not booted") + } + db, ok := app.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, errors.New("golem15.sitemap: database is not configured") + } + return db, nil +} + +// backendURL is the root URL the sitemap's absolute URLs start with, PHP +// url('/') right-trimmed of slashes: app.url when it is set, else the +// request's scheme and Host. The scheme is https for a TLS connection, or +// when the immediate peer is in http.trusted_proxies and its last +// X-Forwarded-Proto value says https; a client that sends the header +// directly is ignored. +func backendURL(app *backpack.App, r *http.Request) string { + if app != nil && app.Config != nil { + if u := strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/"); u != "" { + return u + } + } + scheme := "http" + if isHTTPS(app, r) { + scheme = "https" + } + return strings.TrimRight(scheme+"://"+r.Host, "/") +} + +func isHTTPS(app *backpack.App, r *http.Request) bool { + if r.TLS != nil { + return true + } + if app == nil || app.Config == nil { + return false + } + trusted := surf.TrustedProxies(app.Config) + if len(trusted) == 0 || !peerTrusted(r.RemoteAddr, trusted) { + return false + } + values := r.Header.Values("X-Forwarded-Proto") + if len(values) == 0 { + return false + } + hops := strings.Split(values[len(values)-1], ",") + return strings.EqualFold(strings.TrimSpace(hops[len(hops)-1]), "https") +} + +func peerTrusted(remoteAddr string, trusted []netip.Prefix) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + addr, err := netip.ParseAddr(strings.Trim(host, "[]")) + if err != nil { + return false + } + addr = addr.Unmap() + for _, p := range trusted { + if p.Contains(addr) { + return true + } + } + return false +} diff --git a/sitemap_smoke_test.go b/sitemap_smoke_test.go new file mode 100644 index 0000000..2ea4bb4 --- /dev/null +++ b/sitemap_smoke_test.go @@ -0,0 +1,189 @@ +package sitemap_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + _ "git.golem15.com/golem15/sm-sitemap-plugin" + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + "gorm.io/gorm" +) + +// testTypesPluginID is the fake content plugin that registers menu item +// types for the sitemap smoke tests. The real Journal and sitemap +// combination is proven by the host application's tests. +const testTypesPluginID = "acme.sitemaptypes" + +// acmeTypes is a content plugin with one menu item type, acme-post, whose +// reference "7" resolves to a fixed post with two alternates. +type acmeTypes struct{} + +func (acmeTypes) ID() string { return testTypesPluginID } +func (acmeTypes) Requires() []string { return nil } +func (acmeTypes) Register(*backpack.App) error { return nil } +func (acmeTypes) Boot(*backpack.App) error { return nil } +func (acmeTypes) MenuItemTypes() []pact.MenuItemType { + mtime := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC) + return []pact.MenuItemType{{ + Key: "acme-post", + Label: "Acme post", + DefaultPattern: "/news/:slug", + References: func(context.Context) ([]pact.MenuItemReference, error) { + return []pact.MenuItemReference{{Value: "7", Label: "Hello"}}, nil + }, + Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + if in.Reference != "7" { + return nil, nil + } + return &pact.ResolvedMenuItem{ + Title: "Hello", + URL: "/news/hello", + MTime: &mtime, + Alternates: []pact.MenuItemAlternate{ + {Locale: "pl", URL: "/news/hello"}, + {Locale: "en", URL: "/en/news/hello"}, + }, + }, nil + }, + }} +} + +var _ pact.HasMenuItemTypes = acmeTypes{} + +func init() { + party.Register(acmeTypes{}) +} + +// sitemapEnv is a booted application with the sitemap and the fake types. +type sitemapEnv struct { + h http.Handler + app *backpack.App + gdb *gorm.DB +} + +func bootSitemap(t *testing.T) *sitemapEnv { + t.Helper() + sqlDB := freshDB(t) + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: sitemap-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=sitemap-smoke-test-only-hs256-secret-key"}, + }) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]any{ + "http.body_limits.default_bytes": 1048576, + "http.body_limits.upload_bytes": 8388608, + "mail.driver": "log", + "mail.from": "noreply@example.test", + } { + if err := cfg.Set(key, value); err != nil { + t.Fatal(err) + } + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + plugins, err := party.Activate(app, []string{testTypesPluginID, "golem15.sitemap"}) + if err != nil { + t.Fatal(err) + } + if err := lagoon.Migrate(gdb, plugins); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + return &sitemapEnv{h: h, app: app, gdb: gdb} +} + +func (e *sitemapEnv) get(t *testing.T, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Host = "api.example.test" + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +func (e *sitemapEnv) storeDefinition(t *testing.T, items []models.DefinitionItem) models.Definition { + t.Helper() + theme := models.DefinitionTheme + def := models.Definition{Theme: &theme} + def.SetItems(items) + if err := e.gdb.Create(&def).Error; err != nil { + t.Fatal(err) + } + return def +} + +func text(s string) *string { return &s } + +// TestSitemapTracer stores a definition with a url item and a registry +// item, GETs /sitemap.xml and compares the body with the PHP golden. +func TestSitemapTracer(t *testing.T) { + e := bootSitemap(t) + + if rec := e.get(t, "/sitemap.xml"); rec.Code != http.StatusNotFound { + t.Fatalf("no definition: %d, want 404", rec.Code) + } + + def := e.storeDefinition(t, nil) + rec := e.get(t, "/sitemap.xml") + if rec.Code != http.StatusOK || rec.Body.Len() != 0 || rec.Header().Get("Content-Type") != "application/xml" { + t.Fatalf("empty definition: %d %q %q", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String()) + } + + def.SetItems([]models.DefinitionItem{ + {Type: models.ItemTypeURL, URL: text("/"), Changefreq: text("always"), Priority: text("1.0")}, + {Type: "acme-post", Reference: text("7"), URLPattern: text(""), Nesting: true, Changefreq: text("weekly"), Priority: text("0.5")}, + }) + if err := e.gdb.Save(&def).Error; err != nil { + t.Fatal(err) + } + rec = e.get(t, "/sitemap.xml") + if rec.Code != http.StatusOK { + t.Fatalf("GET /sitemap.xml: %d %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/xml" { + t.Fatalf("Content-Type = %q", ct) + } + want, err := os.ReadFile(filepath.Join("classes", "testdata", "tracer.golden")) + if err != nil { + t.Fatal(err) + } + if got := rec.Body.String(); got != string(want) { + t.Fatalf("/sitemap.xml differs from the PHP golden\n got: %q\nwant: %q", got, want) + } + + // The stored data keeps the PHP item keys. + var raw string + if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, def.ID).Scan(&raw).Error; err != nil { + t.Fatal(err) + } + const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":null},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":null,"reference":"7","cmsPage":null,"urlPattern":""}]` + if raw != wantData { + t.Fatalf("stored data = %s", raw) + } +} diff --git a/testmain_test.go b/testmain_test.go new file mode 100644 index 0000000..8da9ad2 --- /dev/null +++ b/testmain_test.go @@ -0,0 +1,115 @@ +package sitemap_test + +import ( + "context" + "database/sql" + "flag" + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +var ( + pgContainer *postgres.PostgresContainer + pgAdmin *sql.DB + pgDSN string + pgErr error +) + +// TestMain starts one Postgres container for the smoke tests unless the run +// is -short. go test passes -test.short=true, so the flag is parsed and read +// through testing.Short, never by scanning os.Args. +func TestMain(m *testing.M) { + flag.Parse() + if !testing.Short() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + pgErr = startPostgres(ctx) + cancel() + if pgErr != nil { + fmt.Fprintf(os.Stderr, "golem15.sitemap: testcontainers postgres: %v\n", pgErr) + } + } + code := m.Run() + stopPostgres() + os.Exit(code) +} + +func startPostgres(ctx context.Context) error { + ctr, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("golem15_sitemap"), + postgres.WithUsername("golem15_sitemap"), + postgres.WithPassword("golem15_sitemap"), + postgres.BasicWaitStrategies(), + ) + if err != nil { + return err + } + pgContainer = ctr + dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return err + } + db, err := sql.Open("pgx", dsn) + if err != nil { + return err + } + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return err + } + pgAdmin, pgDSN = db, dsn + return nil +} + +func stopPostgres() { + if pgAdmin != nil { + _ = pgAdmin.Close() + } + if pgContainer != nil { + _ = testcontainers.TerminateContainer(pgContainer) + } +} + +// freshDB creates a database of its own for one test and drops it after. +func freshDB(t *testing.T) *sql.DB { + t.Helper() + if testing.Short() { + t.Skip("requires testcontainers postgres") + } + if pgErr != nil { + t.Fatalf("postgres unavailable: %v", pgErr) + } + if pgAdmin == nil { + t.Fatal("postgres unavailable: container was not started") + } + name := fmt.Sprintf("sitemap_%d", time.Now().UnixNano()) + quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"` + if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil { + t.Fatalf("create %s: %v", name, err) + } + u, err := url.Parse(pgDSN) + if err != nil { + t.Fatal(err) + } + u.Path = "/" + name + db, err := sql.Open("pgx", u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = db.Close() + _, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`) + }) + if err := db.PingContext(t.Context()); err != nil { + t.Fatal(err) + } + return db +} diff --git a/tests/php/gen-goldens.php b/tests/php/gen-goldens.php new file mode 100644 index 0000000..67d73b1 --- /dev/null +++ b/tests/php/gen-goldens.php @@ -0,0 +1,465 @@ + reference => + * resolved item {url, mtime, alternates: [[locale, url], ...], items}), + * settings (Sitemap settings values; absent keys take the PHP defaults), + * manifest (the updates manifest object), skip (URLs a beforeAddItem + * listener drops) and extra (URL => text of an element an addItem + * listener appends). + * + * A registry type's resolved item is given in the Go shape (one item per + * record with ordered alternates); this script turns it into what the PHP + * headless resolvers returned: a list type (no url) yields one entry per + * alternate for each child, any other type its children with their default + * URL. + */ + +$vendor = getenv('WINTER_VENDOR'); +if (!$vendor || !is_file($vendor . '/autoload.php')) { + fwrite(STDERR, "set WINTER_VENDOR to the vendor directory of a WinterCMS application\n"); + exit(2); +} +require $vendor . '/autoload.php'; + +use Illuminate\Http\Request; +use Illuminate\Routing\RouteCollection; +use Winter\Storm\Router\UrlGenerator; + +date_default_timezone_set('UTC'); + +if ($argc !== 3) { + fwrite(STDERR, "usage: php gen-goldens.php INPUT.json OUTPUT.golden\n"); + exit(2); +} + +$input = json_decode(file_get_contents($argv[1]), true); +if (!is_array($input)) { + fwrite(STDERR, "invalid JSON input\n"); + exit(1); +} + +function urlGenerator(string $root): UrlGenerator +{ + return new UrlGenerator(new RouteCollection, Request::create(rtrim($root, '/') . '/sitemap.xml')); +} + +if (isset($input['urls'])) { + $url = urlGenerator($input['root']); + $out = ''; + foreach ($input['urls'] as $path) { + $out .= $url->to($path) . "\n"; + } + file_put_contents($argv[2], $out); + exit(0); +} + +if (isset($input['case'])) { + file_put_contents($argv[2], (new GoldenSitemap($input['case']))->render()); + exit(0); +} + +$out = ''; +foreach ($input['cases'] as $case) { + $out .= '=== ' . $case['name'] . "\n" . (new GoldenSitemap($case))->render(); +} +file_put_contents($argv[2], $out); + +/** + * Golem15\Sitemap\Classes\DefinitionItem, unchanged properties. + */ +class GoldenDefinitionItem +{ + public $changefreq; + public $priority; + public $nesting = false; + public $type = 'url'; + public $url = null; + public $code = null; + public $reference = null; + public $cmsPage = null; + public $urlPattern = null; + public $exists = false; + + public static function initFromArray(array $items): array + { + $result = []; + foreach ($items as $itemData) { + $obj = new self; + foreach ($itemData as $name => $value) { + if (property_exists($obj, $name)) { + $obj->$name = $value; + } + } + $result[] = $obj; + } + return $result; + } +} + +/** + * The generation code of Golem15\Sitemap\Models\Definition and routes.php. + */ +class GoldenSitemap +{ + const MAX_URLS = 50000; + + protected $case; + protected $url; + protected $items; + protected $updatesManifest = null; + protected $urlCount = 0; + protected $urlSet; + protected $xmlObject; + + public function __construct(array $case) + { + $this->case = $case; + $this->url = urlGenerator($case['root']); + $this->items = GoldenDefinitionItem::initFromArray($case['items'] ?? []); + } + + protected function setting(string $key, $default) + { + $settings = $this->case['settings'] ?? []; + return array_key_exists($key, $settings) ? $settings[$key] : $default; + } + + /** routes.php */ + public function render(): string + { + $xml = $this->generateSitemap(); + + $frontendUrl = $this->setting('frontend_url', null); + if ($frontendUrl) { + $frontendUrl = rtrim($frontendUrl, '/'); + $backendUrl = rtrim($this->url->to('/'), '/'); + $xml = preg_replace_callback('/(.*?)<\/loc>/', function ($m) use ($backendUrl, $frontendUrl) { + return '' . str_replace($backendUrl, $frontendUrl, $m[1]) . ''; + }, $xml); + $xml = preg_replace_callback('/(]*href=")([^"]*)(")/', function ($m) use ($backendUrl, $frontendUrl) { + return $m[1] . str_replace($backendUrl, $frontendUrl, $m[2]) . $m[3]; + }, $xml); + $xml = str_replace( + 'href="' . $backendUrl . '/sitemap.xsl"', + 'href="' . $frontendUrl . '/sitemap.xsl"', + $xml + ); + } + + return $xml; + } + + /** + * What the PHP headless resolvers returned for a resolved item in the + * Go shape. + */ + protected function phpResolved(array $r, bool $listChildren): array + { + $info = []; + if (!empty($r['url'])) { + $info['url'] = $r['url']; + } + if (array_key_exists('mtime', $r)) { + $info['mtime'] = $r['mtime'] === null ? null : new DateTime($r['mtime']); + } + $links = []; + foreach ($r['alternates'] ?? [] as [$locale, $href]) { + $links[$locale] = $href; + } + $info['alternateLinks'] = $links; + if (isset($r['items'])) { + $info['items'] = []; + foreach ($r['items'] as $child) { + $childInfo = $this->phpResolved($child, false); + if ($listChildren) { + foreach ($childInfo['alternateLinks'] as $locale => $href) { + $perLocale = $childInfo; + $perLocale['url'] = $href; + $info['items'][] = $perLocale; + } + } else { + $info['items'][] = $childInfo; + } + } + } + return $info; + } + + protected function resolveItem(GoldenDefinitionItem $item) + { + $types = $this->case['types'] ?? []; + $ref = (string) $item->reference; + if (!isset($types[$item->type][$ref])) { + return null; + } + $r = $types[$item->type][$ref]; + return $this->phpResolved($r, empty($r['url'])); + } + + public function generateSitemap(): string + { + if (!$this->items) { + return ''; + } + + foreach ($this->items as $item) { + if ($item->type === 'url') { + $this->addItemToSet($item); + } else { + $menuItemInfo = $this->resolveItem($item); + if (!is_array($menuItemInfo)) { + continue; + } + + if (isset($menuItemInfo['url'])) { + $this->addItemToSet($item, $menuItemInfo); + } + + if (isset($menuItemInfo['items'])) { + $menuItemIterator = function ($menuItems) use (&$menuItemIterator, $item) { + foreach ($menuItems as $menuItem) { + if (isset($menuItem['url'])) { + $this->addItemToSet($item, $menuItem); + } + + if (isset($menuItem['items'])) { + $menuItemIterator($menuItem['items']); + } + } + }; + + $menuItemIterator($menuItemInfo['items']); + } + } + } + + $urlSet = $this->getUrlSet(); + $xml = $this->getXmlObject(); + $xml->appendChild($urlSet); + + return $xml->saveXML(); + } + + protected function getXmlObject(): DOMDocument + { + if ($this->xmlObject !== null) { + return $this->xmlObject; + } + + $xml = new DOMDocument; + $xml->encoding = 'UTF-8'; + $xss = $xml->createProcessingInstruction('xml-stylesheet', + 'type="text/xsl" href="' . UrlGenerator::buildUrl($this->url->current(), ['path' => 'sitemap.xsl']) . '"' + ); + $xml->appendChild($xss); + + return $this->xmlObject = $xml; + } + + protected function getUrlSet(): DOMElement + { + if ($this->urlSet !== null) { + return $this->urlSet; + } + + $xml = $this->getXmlObject(); + $urlSet = $xml->createElement('urlset'); + $urlSet->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9'); + $urlSet->setAttribute('xmlns:xhtml', 'http://www.w3.org/1999/xhtml'); + $urlSet->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + $urlSet->setAttribute('xsi:schemaLocation', 'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd'); + + return $this->urlSet = $urlSet; + } + + protected function addItemToSet(GoldenDefinitionItem $item, ?array $itemInfo = null): void + { + $xml = $this->getXmlObject(); + $urlSet = $this->getUrlSet(); + + if ($this->urlCount >= self::MAX_URLS) { + return; + } + + if (!isset($itemInfo['url'])) { + $itemInfo['url'] = $this->url->to($item->url); + } + + $lastModified = $itemInfo['mtime'] ?? null; + + if ($lastModified === null) { + $lastModified = $this->lookupManifestLastmod($itemInfo['url']); + } + + if ($lastModified instanceof \DateTime) { + $lastModified = $lastModified->getTimestamp(); + } + $itemInfo['lastModified'] = $lastModified ? date('c', $lastModified) : null; + + // golem15.sitemap.beforeAddItem: a listener that drops the case's + // skip URLs. + if (in_array($itemInfo['url'], $this->case['skip'] ?? [], true)) { + return; + } + + if (empty($itemInfo['url'])) { + return; + } else { + $itemInfo['url'] = $this->url->to($itemInfo['url']); + } + + $this->urlCount++; + + $tierPriority = $this->getPriorityForUrl($itemInfo['url']); + $effectivePriority = $tierPriority ?? $item->priority; + + $urlElement = $xml->createElement('url'); + + $loc = $xml->createElement('loc'); + $loc->appendChild($xml->createTextNode($itemInfo['url'])); + $urlElement->appendChild($loc); + + if (!empty($itemInfo['lastModified'])) { + $lastmod = $xml->createElement('lastmod'); + $lastmod->appendChild($xml->createTextNode((string) $itemInfo['lastModified'])); + $urlElement->appendChild($lastmod); + } + + $changefreq = $xml->createElement('changefreq'); + $changefreq->appendChild($xml->createTextNode((string) $item->changefreq)); + $urlElement->appendChild($changefreq); + + $priority = $xml->createElement('priority'); + $priority->appendChild($xml->createTextNode((string) $effectivePriority)); + $urlElement->appendChild($priority); + + if (!empty($itemInfo['alternateLinks']) && count($itemInfo['alternateLinks']) > 1) { + foreach ($itemInfo['alternateLinks'] as $hreflang => $href) { + $link = $xml->createElement('xhtml:link'); + $link->setAttribute('rel', 'alternate'); + $link->setAttribute('hreflang', $hreflang); + $link->setAttribute('href', $this->url->to($href)); + $urlElement->appendChild($link); + } + } + + $urlSet->appendChild($urlElement); + + // golem15.sitemap.addItem: a listener that appends the case's extra + // element to the URL. + $extra = $this->case['extra'] ?? []; + if (isset($extra[$itemInfo['url']])) { + $element = $xml->createElement('extra'); + $element->appendChild($xml->createTextNode($extra[$itemInfo['url']])); + $urlElement->appendChild($element); + } + } + + protected function lookupManifestLastmod(string $url): ?int + { + $manifest = $this->getUpdatesManifest(); + if (!$manifest) { + return null; + } + + $path = parse_url($url, PHP_URL_PATH) ?: '/'; + $path = $this->normalizeManifestPath($path); + + if (!isset($manifest[$path])) { + return null; + } + + $timestamp = strtotime((string) $manifest[$path]); + + return $timestamp ?: null; + } + + protected function getUpdatesManifest(): array + { + if ($this->updatesManifest !== null) { + return $this->updatesManifest; + } + + $decoded = $this->case['manifest'] ?? null; + if (!is_array($decoded)) { + return $this->updatesManifest = []; + } + + $normalized = []; + foreach ($decoded as $key => $value) { + $normalized[$this->normalizeManifestPath((string) $key)] = $value; + } + + return $this->updatesManifest = $normalized; + } + + protected function normalizeManifestPath(string $path): string + { + $path = '/' . ltrim($path, '/'); + + return $path === '/' ? '/' : rtrim($path, '/'); + } + + protected function getPriorityForUrl(string $url): ?string + { + if (!$this->setting('enable_priority_tiers', true)) { + return null; + } + + $path = parse_url($url, PHP_URL_PATH) ?: '/'; + $path = rtrim($path, '/'); + + if ($path === '' || $path === '/en') { + return $this->setting('priority_homepage', '1.0'); + } + + if (preg_match('#^/(uslugi|en/services|realizacje|en/work)/[^/]+$#', $path)) { + return $this->setting('priority_services', '0.8'); + } + + if (preg_match('#^/(blog|en/blog)/[^/]+/[^/]+$#', $path)) { + return $this->setting('priority_blog', '0.6'); + } + + if (preg_match('#^/(uslugi|en/services|realizacje|en/work|blog|en/blog)(/[^/]+)?$#', $path)) { + return $this->setting('priority_categories', '0.4'); + } + + $legalPaths = [ + '/polityka-prywatnosci', '/en/privacy-policy', + '/regulamin', '/en/terms', + '/o-nas', '/en/about', + '/kontakt', '/en/contact', + '/laboratorium-ai', '/en/ai-lab', + '/laboratorium-ai/zabawa-animacja', '/en/ai-lab/blob-playground', + ]; + if (in_array($path, $legalPaths)) { + return $this->setting('priority_legal', '0.2'); + } + + return null; + } +} diff --git a/updates/202610110001_create_golem15_sitemap_definitions.go b/updates/202610110001_create_golem15_sitemap_definitions.go new file mode 100644 index 0000000..57285ae --- /dev/null +++ b/updates/202610110001_create_golem15_sitemap_definitions.go @@ -0,0 +1,39 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// createDefinitions creates golem15_sitemap_definitions with the PHP columns +// (v1.0.2 create_definitions_table, renamed in v2.0.0 and v2.0.1): a +// nullable indexed theme and the data text of the items. +var createDefinitions = []*gormigrate.Migration{ + { + ID: "202610110001_create_golem15_sitemap_definitions", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_sitemap_definitions ( + id SERIAL PRIMARY KEY, + theme VARCHAR(255) NULL, + data TEXT NULL, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL +)`, + `CREATE INDEX golem15_sitemap_definitions_theme_index ON golem15_sitemap_definitions (theme)`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_sitemap_definitions`).Error + }, + }, +} + +func init() { + Register(createDefinitions...) +} diff --git a/updates/registry.go b/updates/registry.go new file mode 100644 index 0000000..3238991 --- /dev/null +++ b/updates/registry.go @@ -0,0 +1,17 @@ +// Package updates holds the sitemap plugin's ordered gormigrate steps. +package updates + +import "github.com/go-gormigrate/gormigrate/v2" + +var all []*gormigrate.Migration + +// Register appends gormigrate steps so later files add migrations without +// editing plugin.go. +func Register(ms ...*gormigrate.Migration) { + all = append(all, ms...) +} + +// All returns every migration registered by this package. +func All() []*gormigrate.Migration { + return all +}