commit fb9590f0993850c5b9fe9fb0b7d978218c8e7984 Author: Jakub Zych Date: Tue Oct 27 10:27:46 2026 +0100 feat: plugin scaffold, entries and the SEO tab tracer 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/entries.go b/classes/entries.go new file mode 100644 index 0000000..e5fe17e --- /dev/null +++ b/classes/entries.go @@ -0,0 +1,213 @@ +package classes + +import ( + "context" + "fmt" + "strings" + + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// The entry columns the SEO tab edits, in form order. Each one is a field +// named FieldPrefix+column in the SEO tab. +var EntryColumns = []string{ + "title", "description", "keyphrase", "canonical", "robots", + "og_title", "og_description", "og_image", +} + +// FieldPrefix is the prefix of the SEO tab's field names (seo_title, ...), +// so they never collide with an owner's fields. +const FieldPrefix = "seo_" + +// entryValue reads one editable column of e. +func entryValue(e models.Entry, column string) string { + switch column { + case "title": + return e.Title + case "description": + return e.Description + case "keyphrase": + return e.Keyphrase + case "canonical": + return e.Canonical + case "robots": + return e.Robots + case "og_title": + return e.OGTitle + case "og_description": + return e.OGDescription + case "og_image": + return e.OGImage + } + return "" +} + +// setEntryValue writes one editable column of e; an unknown column is +// ignored. +func setEntryValue(e *models.Entry, column, value string) { + switch column { + case "title": + e.Title = value + case "description": + e.Description = value + case "keyphrase": + e.Keyphrase = value + case "canonical": + e.Canonical = value + case "robots": + e.Robots = value + case "og_title": + e.OGTitle = value + case "og_description": + e.OGDescription = value + case "og_image": + e.OGImage = value + } +} + +// EntryValue is the exported reader of one editable column. +func EntryValue(e models.Entry, column string) string { return entryValue(e, column) } + +// LoadEntries returns a record's entries keyed by locale. +func LoadEntries(db *gorm.DB, modelType, recordID string) (map[string]models.Entry, error) { + var rows []models.Entry + if err := db.Where("model_type = ? AND record_id = ?", modelType, recordID).Order("locale ASC").Find(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]models.Entry, len(rows)) + for _, row := range rows { + out[row.Locale] = row + } + return out, nil +} + +// LoadEntry returns a record's entry in locale; found is false when there is +// none. +func LoadEntry(db *gorm.DB, modelType, recordID, locale string) (models.Entry, bool, error) { + var rows []models.Entry + if err := db.Where("model_type = ? AND record_id = ? AND locale = ?", modelType, recordID, locale).Limit(1).Find(&rows).Error; err != nil { + return models.Entry{}, false, err + } + if len(rows) == 0 { + return models.Entry{}, false, nil + } + return rows[0], true, nil +} + +// RecordValues is a set of SEO tab values for one record: column -> +// locale -> value. A column or locale that is absent is left as stored. +type RecordValues map[string]map[string]string + +// RecordWrite is one write of a record's SEO values through the service. +type RecordWrite struct { + // Type is the record's content type (it carries the descriptor). + Type pact.MenuItemType + // ID is the record's primary key as text. + ID string + // Values are the submitted values; nil writes none and only refreshes + // the URL snapshot of existing entries. + Values RecordValues +} + +// SaveRecord writes a record's SEO values inside db (the caller's +// transaction): each locale that carries a value gets its entry created or +// updated, and every written entry takes the record's current URL in that +// locale (signpost Describe, read through the same transaction). It returns +// the entries before and after the write, keyed by locale, for history. +func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (before, after map[string]models.Entry, err error) { + if in.Type.Content == nil { + return nil, nil, fmt.Errorf("golem15.seo: type %q has no content descriptor", in.Type.Key) + } + modelType := in.Type.Content.Model + existing, err := LoadEntries(db, modelType, in.ID) + if err != nil { + return nil, nil, err + } + before = make(map[string]models.Entry, len(existing)) + for locale, e := range existing { + before[locale] = e + } + urls := s.recordURLs(ctx, in.Type.Key, in.ID) + touched := map[string]models.Entry{} + for column, perLocale := range in.Values { + for locale, value := range perLocale { + e, ok := touched[locale] + if !ok { + if e, ok = existing[locale]; !ok { + e = models.Entry{ModelType: modelType, RecordID: in.ID, Locale: locale} + } + } + setEntryValue(&e, column, strings.TrimSpace(value)) + touched[locale] = e + } + } + after = make(map[string]models.Entry, len(existing)) + for locale, e := range existing { + after[locale] = e + } + for locale, e := range touched { + _, stored := existing[locale] + if !stored && entryEmpty(e) { + continue + } + e.TypeKey = in.Type.Key + if u, ok := urls[locale]; ok { + e.URL = u + } + if e.ID == 0 { + if err := db.Create(&e).Error; err != nil { + return nil, nil, err + } + } else if err := db.Save(&e).Error; err != nil { + return nil, nil, err + } + after[locale] = e + } + return before, after, nil +} + +// entryEmpty reports whether no editable column of e carries a value. +func entryEmpty(e models.Entry) bool { + for _, column := range EntryColumns { + if entryValue(e, column) != "" { + return false + } + } + return len(e.Data) == 0 +} + +// recordURLs returns the record's public path per locale through its +// content descriptor; an error or a missing registry answers none. +func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]string { + out := map[string]string{} + reg := s.Registry() + if reg == nil { + return out + } + rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale()) + if err != nil || rec == nil { + return out + } + for _, l := range rec.Locales { + out[l.Locale] = l.URL + } + return out +} + +// RecordValuesOf returns the stored values of a record for the SEO tab: +// column -> locale -> value. +func RecordValuesOf(entries map[string]models.Entry) RecordValues { + out := RecordValues{} + for _, column := range EntryColumns { + perLocale := map[string]string{} + for locale, e := range entries { + if v := entryValue(e, column); v != "" { + perLocale[locale] = v + } + } + out[column] = perLocale + } + return out +} diff --git a/classes/resolver.go b/classes/resolver.go new file mode 100644 index 0000000..78d7bbb --- /dev/null +++ b/classes/resolver.go @@ -0,0 +1,222 @@ +package classes + +import ( + "context" + "errors" + "net/url" + "strings" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Config keys of the resolver. +const ( + // SiteURLConfigKey is the absolute origin of the public site (for + // example https://example.com). Canonical and other absolute URLs are + // built from it, falling back to app.url; never from a request's Host. + SiteURLConfigKey = "golem15.seo.site_url" + // UnprefixedLocaleConfigKey names the locale whose public URLs carry no + // locale prefix; empty leaves the choice to each content type. + UnprefixedLocaleConfigKey = "golem15.seo.unprefixed_locale" +) + +// MaxURLBytes caps the URL the resolver accepts. +const MaxURLBytes = 2048 + +// ErrBadURL is returned for an input that is not a path or an http(s) URL. +var ErrBadURL = errors.New("golem15.seo: url must be a path or an absolute http(s) URL") + +// The actions of a resolve answer. +const ( + ActionOK = "ok" + ActionNotFound = "not_found" +) + +// The sources of a resolved value (Result.Sources). +const ( + SourceRule = "rule" + SourceRecord = "record" + SourceTemplate = "template" + SourceGlobal = "global" + SourceContent = "content" + SourceDefault = "default" +) + +// Result is the effective SEO of one URL. Action is ok or not_found; Status +// is the HTTP status the frontend should answer with. URL is the absolute +// URL of the page (path and kept query on the site URL). Sources maps each +// value name (title, description, canonical, robots, og_title, +// og_description, og_image) to where it came from. +type Result struct { + Action string `json:"action"` + Status int `json:"status"` + URL string `json:"url"` + Locale string `json:"locale"` + Staging bool `json:"staging"` + SEO *SEO `json:"seo"` + Sources map[string]string `json:"-"` + // TypeKey and RecordID name the record behind the URL, "" when none. + TypeKey string `json:"-"` + RecordID string `json:"-"` +} + +// SEO is the head metadata of a resolved page. +type SEO struct { + Title string `json:"title"` + Description string `json:"description"` + Canonical string `json:"canonical"` + Robots string `json:"robots"` + XRobotsTag string `json:"x_robots_tag"` + OG OpenGraph `json:"og"` + Twitter TwitterTag `json:"twitter"` + Verification []MetaTag `json:"verification"` +} + +// OpenGraph holds the og: values. +type OpenGraph struct { + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image"` + URL string `json:"url"` +} + +// TwitterTag holds the twitter: values. +type TwitterTag struct { + Card string `json:"card"` + Site string `json:"site"` +} + +// MetaTag is one verification meta tag. +type MetaTag struct { + Name string `json:"name"` + Content string `json:"content"` +} + +// Resolver answers the effective SEO of a URL. +type Resolver struct { + Service *Service +} + +// Resolver returns the service's resolver. +func (s *Service) Resolver() *Resolver { return &Resolver{Service: s} } + +// SiteURL is the configured public origin without a trailing slash: +// golem15.seo.site_url, else app.url, else "". +func (s *Service) SiteURL() string { + cfg := s.Config() + if cfg == nil { + return "" + } + for _, key := range []string{SiteURLConfigKey, "app.url"} { + if u := strings.TrimRight(strings.TrimSpace(cfg.String(key)), "/"); u != "" { + return u + } + } + return "" +} + +// UnprefixedLocale is the configured unprefixed locale, "" when unset. +func (s *Service) UnprefixedLocale() string { + cfg := s.Config() + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.String(UnprefixedLocaleConfigKey)) +} + +// parseTarget turns the resolver input into a path and its query. A path +// must start with "/"; an absolute URL must be http or https. Fragments are +// dropped. +func parseTarget(raw string) (string, url.Values, error) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > MaxURLBytes { + return "", nil, ErrBadURL + } + u, err := url.Parse(raw) + if err != nil { + return "", nil, ErrBadURL + } + if u.IsAbs() { + if u.Scheme != "http" && u.Scheme != "https" { + return "", nil, ErrBadURL + } + } else if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") { + return "", nil, ErrBadURL + } + path := u.Path + if path == "" { + path = "/" + } + return path, u.Query(), nil +} + +// lookup finds the published record behind path; key is "" when none. +func (r *Resolver) lookup(ctx context.Context, path string) (key string, match *pact.ContentMatchResult, rec *pact.ContentRecord, err error) { + reg := r.Service.Registry() + if reg == nil { + return "", nil, nil, nil + } + key, match, err = reg.MatchPath(ctx, path, r.Service.UnprefixedLocale()) + if err != nil || match == nil { + return "", nil, nil, err + } + rec, err = reg.Describe(ctx, key, match.ID, r.Service.UnprefixedLocale()) + if err != nil { + return "", nil, nil, err + } + if rec == nil || !rec.Published { + return "", nil, nil, nil + } + return key, match, rec, nil +} + +// contentLocale returns the record's description in locale, else its first +// locale. +func contentLocale(rec *pact.ContentRecord, locale string) pact.ContentLocale { + if rec == nil || len(rec.Locales) == 0 { + return pact.ContentLocale{Locale: locale} + } + for _, l := range rec.Locales { + if l.Locale == locale { + return l + } + } + return rec.Locales[0] +} + +// Resolve answers the effective SEO of rawURL, a path or an absolute URL of +// the site. A path no published record owns answers not_found. +func (r *Resolver) Resolve(ctx context.Context, rawURL string) (Result, error) { + path, _, err := parseTarget(rawURL) + if err != nil { + return Result{}, err + } + site := r.Service.SiteURL() + res := Result{Action: ActionNotFound, Status: 404, URL: site + path, Sources: map[string]string{}} + key, match, rec, err := r.lookup(ctx, path) + if err != nil { + return Result{}, err + } + if key == "" { + return res, nil + } + typ, _ := r.Service.Registry().ContentType(key) + db, err := r.Service.DB(ctx) + if err != nil { + return Result{}, err + } + entry, _, err := LoadEntry(db, typ.Content.Model, match.ID, match.Locale) + if err != nil { + return Result{}, err + } + content := contentLocale(rec, match.Locale) + seo := &SEO{Canonical: site + path, Verification: []MetaTag{}} + if entry.Title != "" { + seo.Title, res.Sources["title"] = entry.Title, SourceRecord + } else { + seo.Title, res.Sources["title"] = content.Title, SourceContent + } + res.Action, res.Status, res.Locale, res.SEO = ActionOK, 200, match.Locale, seo + res.TypeKey, res.RecordID = key, match.ID + return res, nil +} diff --git a/classes/service.go b/classes/service.go new file mode 100644 index 0000000..20b8981 --- /dev/null +++ b/classes/service.go @@ -0,0 +1,79 @@ +// Package classes holds the SEO plugin's logic: the service every writer +// goes through, the entry store and the effective-SEO resolver. +package classes + +import ( + "context" + "errors" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/signpost" + "gorm.io/gorm" +) + +// ErrNotBooted is returned when the service has no booted application. +var ErrNotBooted = errors.New("golem15.seo: app is not booted") + +// Service is the plugin's single entry point for reads and writes of SEO +// data. The admin extension, the admin controllers, the listeners, the job +// and the API all call it, so every write goes through the same checks and +// history. +type Service struct { + app *backpack.App +} + +// NewService returns the service of a booted application. It needs the +// application's database; the signpost registry is optional (without it no +// content type takes part). +func NewService(app *backpack.App) (*Service, error) { + if app == nil { + return nil, ErrNotBooted + } + if db, ok := app.Lookup[*gorm.DB](); !ok || db == nil { + return nil, errors.New("database is not configured") + } + return &Service{app: app}, nil +} + +// App returns the booted application. +func (s *Service) App() *backpack.App { + if s == nil { + return nil + } + return s.app +} + +// DB returns the write transaction carried by ctx (cabana.TxFromContext), +// else the application's database bound to ctx. +func (s *Service) DB(ctx context.Context) (*gorm.DB, error) { + if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil { + return tx, nil + } + if s == nil || s.app == nil { + return nil, ErrNotBooted + } + db, ok := s.app.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, errors.New("golem15.seo: database is not configured") + } + return db.WithContext(ctx), nil +} + +// Registry returns the menu item type registry, nil when none is published. +func (s *Service) Registry() *signpost.Registry { + if s == nil || s.app == nil { + return nil + } + reg, _ := s.app.Lookup[*signpost.Registry]() + return reg +} + +// Config returns the application config, nil when there is none. +func (s *Service) Config() *compass.Config { + if s == nil || s.app == nil { + return nil + } + return s.app.Config +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..8b13b87 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,12 @@ +# golem15.seo defaults, read as golem15.seo.. +# +# site_url is the public origin of the site (for example +# https://example.com). Canonical, og:url and every other absolute URL the +# resolver answers start with it; when it is empty, app.url is used. The +# request's Host header is never used. +site_url: "" +# +# unprefixed_locale names the locale whose public URLs carry no locale +# prefix (for example pl on a site whose English pages start with /en). +# Empty leaves the choice to each content type. +unprefixed_locale: "" diff --git a/controllers/api/resolve.go b/controllers/api/resolve.go new file mode 100644 index 0000000..30e6f46 --- /dev/null +++ b/controllers/api/resolve.go @@ -0,0 +1,41 @@ +// Package api holds the SEO plugin's JSON endpoints under /_seo/api/v1. +package api + +import ( + "errors" + "net/http" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/summercms/modules/diary" + "git.golem15.com/golem15/summercms/modules/wire" +) + +var log = diary.For("seo") + +// Resolve serves GET /_seo/api/v1/resolve?url=: the +// effective SEO of a public URL for the frontend's . It answers 200 +// with the resolve answer (action ok or not_found, the status the page +// should answer with) and 422 when url is missing or not a path or an +// http(s) URL. Absolute URLs come from the configured site URL, never from +// the request's Host. +func Resolve(service func() *classes.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + svc := service() + if svc == nil { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"}) + return + } + res, err := svc.Resolver().Resolve(r.Context(), r.URL.Query().Get("url")) + if errors.Is(err, classes.ErrBadURL) { + wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + if err != nil { + log.Error("golem15.seo: resolve", "error", err) + wire.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal Server Error"}) + return + } + w.Header().Set("X-Robots-Tag", "noindex") + wire.WriteJSON(w, http.StatusOK, res) + } +} diff --git a/extensions.go b/extensions.go new file mode 100644 index 0000000..39bc33b --- /dev/null +++ b/extensions.go @@ -0,0 +1,147 @@ +package seo + +import ( + "context" + "fmt" + "strings" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/signpost" +) + +// TabSEO is the key of the SEO tab added to every content type's form. +const TabSEO = "golem15.seo::lang.tab.seo" + +// AdminActionExtensions adds the SEO tab (D-01, D-91) to the admin form of +// every content type registered in signpost with a content descriptor that +// names a controller. The content plugins import nothing from this plugin. +func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { + reg := p.registry() + if reg == nil { + return nil + } + var out []pact.AdminActionExtension + for _, typ := range reg.ContentTypes() { + if typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" { + continue + } + out = append(out, pact.AdminActionExtension{ + Controller: typ.Content.Controller, + Form: p.seoTab(typ.Key), + }) + } + return out +} + +func (p *Plugin) registry() *signpost.Registry { + if p == nil || p.app == nil { + return nil + } + reg, _ := p.app.Lookup[*signpost.Registry]() + return reg +} + +// seoTabYAML is the SEO tab's fields document: the tab placed after the +// owner's tabs (no anchor) and its fields. +const seoTabYAML = `tabs: + - name: golem15.seo::lang.tab.seo +fields: + seo_title: + label: golem15.seo::lang.field.title + type: mltext + tab: golem15.seo::lang.tab.seo +` + +// seoTab is the form extension of content type key. +func (p *Plugin) seoTab(key string) *pact.AdminFormExtension { + return &pact.AdminFormExtension{ + FieldsYAML: []byte(seoTabYAML), + Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) { + return p.loadSEOTab(ctx, key, in) + }, + Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + return p.saveSEOTab(ctx, key, in) + }, + } +} + +// contentType returns the registered content type key with a descriptor. +func (p *Plugin) contentType(key string) (pact.MenuItemType, error) { + reg := p.registry() + if reg == nil { + return pact.MenuItemType{}, fmt.Errorf("golem15.seo: menu item type registry is not published") + } + typ, ok := reg.ContentType(key) + if !ok { + return pact.MenuItemType{}, fmt.Errorf("golem15.seo: content type %q is not registered", key) + } + return typ, nil +} + +// loadSEOTab returns the stored SEO tab values of a record, every field a +// map of locale to text. +func (p *Plugin) loadSEOTab(ctx context.Context, key string, in pact.AdminFormLoad) (map[string]any, error) { + svc := p.Service() + if svc == nil { + return nil, classes.ErrNotBooted + } + typ, err := p.contentType(key) + if err != nil { + return nil, err + } + entries, err := classes.LoadEntries(in.DB.WithContext(ctx), typ.Content.Model, in.ID) + if err != nil { + return nil, err + } + out := map[string]any{} + for column, perLocale := range classes.RecordValuesOf(entries) { + out[classes.FieldPrefix+column] = perLocale + } + return out, nil +} + +// saveSEOTab stores the submitted SEO tab values of a record inside the +// record's save transaction. +func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + svc := p.Service() + if svc == nil { + return pact.AdminFormSaveResult{}, classes.ErrNotBooted + } + typ, err := p.contentType(key) + if err != nil { + return pact.AdminFormSaveResult{}, err + } + values := classes.RecordValues{} + for _, column := range classes.EntryColumns { + raw, ok := in.Values[classes.FieldPrefix+column] + if !ok { + continue + } + values[column] = localeMap(raw) + } + if _, _, err := svc.SaveRecord(ctx, in.DB, classes.RecordWrite{Type: typ, ID: in.ID, Values: values}); err != nil { + return pact.AdminFormSaveResult{}, err + } + return pact.AdminFormSaveResult{}, nil +} + +// localeMap reads a submitted value as locale -> text: a translatable +// field's map, or a plain string under the empty locale key. +func localeMap(raw any) map[string]string { + switch v := raw.(type) { + case map[string]string: + return v + case map[string]any: + out := make(map[string]string, len(v)) + for locale, value := range v { + if s, ok := value.(string); ok { + out[locale] = s + } + } + return out + case string: + return map[string]string{"": v} + } + return map[string]string{} +} diff --git a/fixture_test.go b/fixture_test.go new file mode 100644 index 0000000..d0ab29c --- /dev/null +++ b/fixture_test.go @@ -0,0 +1,454 @@ +package seo_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/fstest" + "time" + + seo "git.golem15.com/golem15/sm-seo-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "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/signpost" + "git.golem15.com/golem15/summercms/modules/surf" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// The fake content plugin acme.pages: pages (a tabbed admin form) behind +// the menu item type acme-page (/pages/:slug), with a content descriptor +// naming the admin controller. It knows nothing about the SEO plugin. + +const ( + pagesControllerID = "acme.pages.pages" + pagesPermission = "acme.pages.access" + pagesTypeKey = "acme-page" + pagesModel = "acme_pages_pages" + adminJWTSecret = "seo-admin-test-only-hs256-secret-key" + siteURL = "https://www.example.test" +) + +// page is the fake content model. +type page struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Slug string `gorm:"column:slug"` + Excerpt string `gorm:"column:excerpt"` + Published bool `gorm:"column:published"` +} + +func (page) TableName() string { return pagesModel } +func (*page) Fillable() []string { return []string{"title", "slug", "excerpt", "published"} } +func (*page) Rules() map[string]string { return map[string]string{} } +func (pagesController) ID() string { return pagesControllerID } +func (pagesController) ModelName() string { return "Page" } +func (pagesController) ConfigDir() string { return "controllers/pages" } +func (pagesController) NewRecord() any { return &page{} } +func (pagesController) RequiredPermissions() []string { + return []string{pagesPermission} +} + +type pagesController struct{} + +// pagesPlugin is acme.pages. +type pagesPlugin struct { + app *backpack.App +} + +var pagesFields = `fields: + title: + label: Title + type: text + slug: + label: Slug + type: text + excerpt: + label: Excerpt + type: textarea + tab: Content + published: + label: Published + type: switch + tab: Manage +` + +func (*pagesPlugin) ID() string { return "acme.pages" } +func (*pagesPlugin) Requires() []string { return nil } +func (p *pagesPlugin) Register(app *backpack.App) error { + p.app = app + // The fixture stands in for the translate plugin: en is the default + // locale and pl is enabled, so translatable admin values are locale + // maps as on a real site. + return app.Publish[cabana.TranslationWriter](&fakeTranslations{values: map[string]string{}}) +} +func (*pagesPlugin) Boot(*backpack.App) error { return nil } +func (*pagesPlugin) AdminFS() fs.FS { + return fstest.MapFS{ + "controllers/pages/config_form.yaml": &fstest.MapFile{Data: []byte("name: Page\nform: ~/plugins/acme/pages/models/page/fields.yaml\nmodelClass: Page\n")}, + "controllers/pages/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")}, + "models/page/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")}, + "models/page/fields.yaml": &fstest.MapFile{Data: []byte(pagesFields)}, + } +} +func (*pagesPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{pagesController{}} +} +func (*pagesPlugin) Permissions() []pact.Permission { + return []pact.Permission{{Code: pagesPermission, Tab: "Pages", Label: "Manage pages"}} +} +func (*pagesPlugin) Migrations() []*gormigrate.Migration { + return []*gormigrate.Migration{{ + ID: "acme_pages_create", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE acme_pages_pages ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + excerpt TEXT NOT NULL DEFAULT '', + published BOOLEAN NOT NULL DEFAULT FALSE +)`).Error + }, + }} +} +func (*pagesPlugin) Models() []any { return []any{page{}} } + +// db is the write transaction in ctx, else the application database. +func (p *pagesPlugin) db(ctx context.Context) *gorm.DB { + if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil { + return tx + } + db, _ := p.app.Lookup[*gorm.DB]() + return db.WithContext(ctx) +} + +// describe answers a page in en (/pages/) and pl (/pl/pages/). +func (p *pagesPlugin) describe(row page) pact.ContentRecord { + return pact.ContentRecord{ + ID: strconv.FormatUint(uint64(row.ID), 10), + Published: row.Published, + Locales: []pact.ContentLocale{ + {Locale: "en", URL: "/pages/" + row.Slug, Title: row.Title, Description: row.Excerpt}, + {Locale: "pl", URL: "/pl/pages/" + row.Slug, Title: row.Title, Description: row.Excerpt}, + }, + } +} + +func (p *pagesPlugin) MenuItemTypes() []pact.MenuItemType { + return []pact.MenuItemType{{ + Key: pagesTypeKey, + Label: "Page", + DefaultPattern: "/pages/:slug", + Placeholders: []string{"slug"}, + Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + return nil, nil + }, + Content: &pact.ContentDescriptor{ + Controller: pagesControllerID, + Model: pagesModel, + Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) { + var rows []page + if err := p.db(ctx).Where("id = ?", in.ID).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 { + return nil, err + } + rec := p.describe(rows[0]) + return &rec, nil + }, + Records: func(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) { + after, _ := strconv.ParseUint(in.After, 10, 64) + var rows []page + if err := p.db(ctx).Where("id > ?", after).Order("id ASC").Limit(in.Limit).Find(&rows).Error; err != nil { + return pact.ContentPage{}, err + } + out := pact.ContentPage{} + for _, row := range rows { + out.Records = append(out.Records, p.describe(row)) + } + if len(rows) == in.Limit && len(rows) > 0 { + out.Next = out.Records[len(out.Records)-1].ID + } + return out, nil + }, + Match: func(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) { + path, locale := in.Path, "en" + if rest, ok := strings.CutPrefix(path, "/pl/"); ok { + path, locale = "/"+rest, "pl" + } + values, ok := signpost.MatchPattern(in.Pattern, path) + if !ok { + return nil, nil + } + var rows []page + if err := p.db(ctx).Where("slug = ? AND published", values["slug"]).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 { + return nil, err + } + return &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(rows[0].ID), 10), Locale: locale, Exact: true}, nil + }, + }, + }} +} + +var ( + _ party.Plugin = (*pagesPlugin)(nil) + _ pact.HasAdminControllers = (*pagesPlugin)(nil) + _ pact.HasMenuItemTypes = (*pagesPlugin)(nil) + _ pact.HasMigrations = (*pagesPlugin)(nil) + _ pact.HasPermissions = (*pagesPlugin)(nil) +) + +func init() { + party.Register(&pagesPlugin{}) +} + +// testEnv is a booted application with acme.pages and golem15.seo. +type testEnv struct { + h http.Handler + app *backpack.App + db *gorm.DB + sql *sql.DB +} + +var seq atomic.Uint64 + +// adminPrefix is the admin JSON API prefix. +func adminAPI(rel string) string { return cabana.DefaultAdminPrefix + "/api/v1" + rel } + +// bootEnv boots acme.pages and golem15.seo on a fresh database. +func bootEnv(t *testing.T, extraEnv ...string) *testEnv { + 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: seo-test\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: append([]string{ + "SUMMER_ENV=production", + "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret, + "SUMMER_GOLEM15__SEO__SITE_URL=" + siteURL, + }, extraEnv...), + }) + 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{"acme.pages", "golem15.seo"}) + 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 &testEnv{h: h, app: app, db: gdb, sql: sqlDB} +} + +// admin creates a backend user with the given grants (superuser when +// super is true) and returns its access token. +func (e *testEnv) admin(t *testing.T, super bool, permissions ...string) string { + t.Helper() + stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, seq.Add(1)) + login := "admin-" + stamp + password := "admin-secret-" + stamp + hash, err := bouncer.HashPassword(10, password) + if err != nil { + t.Fatal(err) + } + grants := map[string]int{} + for _, code := range permissions { + grants[code] = 1 + } + raw, err := json.Marshal(grants) + if err != nil { + t.Fatal(err) + } + var roleID uint + if err := e.db.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, + "Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("backend role: id=%d err=%v", roleID, err) + } + if err := e.db.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, ?, ?, NOW(), NOW())`, + login, login+"@example.test", hash, super, roleID).Error; err != nil { + t.Fatal(err) + } + rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password}) + if rec.Code != http.StatusOK { + t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" { + t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err) + } + return body.Data.AccessToken +} + +// call runs one request against the application. +func (e *testEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } + req := httptest.NewRequest(method, path, reader) + req.Host = "attacker.example" + req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1) + req.Header.Set("Accept-Language", "en") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +// expect runs a request and fails unless it answers status. +func (e *testEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + rec := e.call(t, method, path, token, body) + if rec.Code != status { + t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String()) + } + return rec +} + +// createPage creates a page through the admin and returns its id. +func (e *testEnv) createPage(t *testing.T, token string, body map[string]any) string { + t.Helper() + rec := e.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/acme/pages/pages"), token, body) + var out struct { + Data struct { + ID json.Number `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Data.ID == "" { + t.Fatalf("create body %s: %v", rec.Body.String(), err) + } + return out.Data.ID.String() +} + +// resolve calls the public resolve endpoint. +func (e *testEnv) resolve(t *testing.T, target string) map[string]any { + t.Helper() + rec := e.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/resolve?url="+urlQueryEscape(target), "", nil) + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out +} + +// schemaField is one field of a form schema. +type schemaField struct { + Name string `json:"name"` + Tab string `json:"tab"` +} + +// formSchema returns the form schema fields of controller (vendor/plugin/ctl). +func (e *testEnv) formSchema(t *testing.T, token, controller string) []schemaField { + t.Helper() + rec := e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/"+controller+"/schema/form"), token, nil) + var out struct { + Data struct { + Fields []schemaField `json:"fields"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out.Data.Fields +} + +// seoPlugin returns the activated SEO plugin. +func (e *testEnv) seoPlugin(t *testing.T) *seo.Plugin { + t.Helper() + plugins, ok := e.app.Lookup[party.Activated]() + if !ok { + t.Fatal("no activated plugins") + } + for _, p := range plugins { + if sp, ok := p.(*seo.Plugin); ok { + return sp + } + } + t.Fatal("golem15.seo is not activated") + return nil +} + +// jsonContains reports whether body contains the compact JSON fragment. +func jsonContains(body, fragment string) bool { + return bytes.Contains([]byte(body), []byte(fragment)) +} + +// fakeTranslations is an in-memory cabana.TranslationWriter: en default, pl +// enabled. +type fakeTranslations struct { + mu sync.Mutex + values map[string]string +} + +func (*fakeTranslations) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil } +func (*fakeTranslations) EnabledLocales(context.Context, *gorm.DB) ([]string, error) { + return []string{"en", "pl"}, nil +} +func (f *fakeTranslations) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.values[fmt.Sprintf("%T|%s|%s", model, field, locale)] = value + return nil +} +func (f *fakeTranslations) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.values[fmt.Sprintf("%T|%s|%s", model, field, locale)] + return v, ok, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0ef1f5 --- /dev/null +++ b/go.mod @@ -0,0 +1,183 @@ +module git.golem15.com/golem15/sm-seo-plugin + +go 1.27.0 + +require ( + git.golem15.com/golem15/summercms v0.5.0 + 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 ( + charm.land/bubbles/v2 v2.2.1 // indirect + charm.land/bubbletea/v2 v2.1.0 // indirect + charm.land/fang/v2 v2.0.1 // indirect + charm.land/huh/v2 v2.0.3 // indirect + charm.land/lipgloss/v2 v2.0.6 // indirect + 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/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect + github.com/aws/smithy-go v1.26.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect + github.com/charmbracelet/x/ansi v0.11.9 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.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/dustin/go-humanize v1.0.1 // 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/jsonschema-go v0.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/google/wire v0.7.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/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/maypok86/otter/v2 v2.3.0 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // 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/modelcontextprotocol/go-sdk v1.8.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // 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/redis/go-redis/v9 v9.23.0 // 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/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // 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/xo/terminfo v1.0.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // 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.uber.org/atomic v1.12.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/oauth2 v0.36.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/time v0.15.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..abcd5a0 --- /dev/null +++ b/go.sum @@ -0,0 +1,478 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= +charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc= +github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +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/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio= +github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= +github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= +github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= +github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= +github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +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/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +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/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +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/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= +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/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +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/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +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/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s= +github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +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/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +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/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +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/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= +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/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +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/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +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..9f57e58 --- /dev/null +++ b/go.work @@ -0,0 +1,5 @@ +go 1.27.0 + +use . + +replace git.golem15.com/golem15/summercms => ../summercms.go diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml new file mode 100644 index 0000000..66acba3 --- /dev/null +++ b/lang/en/lang.yaml @@ -0,0 +1,8 @@ +plugin: + name: SEO + description: Search engine metadata for every content type, URL rules and global SEO settings. +tab: + seo: SEO +field: + title: SEO title + title_comment: Leave empty to use the title template from the SEO settings. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml new file mode 100644 index 0000000..3204ce6 --- /dev/null +++ b/lang/pl/lang.yaml @@ -0,0 +1,8 @@ +plugin: + name: SEO + description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO. +tab: + seo: SEO +field: + title: Tytuł SEO + title_comment: Zostaw puste, aby użyć szablonu tytułu z ustawień SEO. diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..df54bd4 --- /dev/null +++ b/logger.go @@ -0,0 +1,7 @@ +package seo + +import "git.golem15.com/golem15/summercms/modules/diary" + +// pluginLog is the plugin's logger: its records carry component=seo, so +// config/log.yaml can raise or lower them (log.components.seo). +var pluginLog = diary.For("seo") diff --git a/models/entry.go b/models/entry.go new file mode 100644 index 0000000..4f08fe3 --- /dev/null +++ b/models/entry.go @@ -0,0 +1,85 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" +) + +// Entry is the SEO of one record in one locale on golem15_seo_entries +// (D-01): the values an editor set in the record's SEO tab, keyed by the +// record's morph type (ModelType, the content descriptor's Model), its id as +// text and the locale. TypeKey is the record's content type (signpost key) +// and URL the record's public path in that locale when the entry was last +// written. An empty value means "inherit": the resolver falls back to the +// type template, the global settings and the content. EffectiveTitle and +// EffectiveDescription cache the resolved values for duplicate checks. Data +// holds values of later fields (schema type, custom JSON-LD) without a +// migration per field. +type Entry struct { + ID uint `gorm:"column:id;primaryKey"` + ModelType string `gorm:"column:model_type"` + RecordID string `gorm:"column:record_id"` + Locale string `gorm:"column:locale"` + TypeKey string `gorm:"column:type_key"` + URL string `gorm:"column:url"` + Title string `gorm:"column:title"` + Description string `gorm:"column:description"` + Keyphrase string `gorm:"column:keyphrase"` + Canonical string `gorm:"column:canonical"` + Robots string `gorm:"column:robots"` + OGTitle string `gorm:"column:og_title"` + OGDescription string `gorm:"column:og_description"` + OGImage string `gorm:"column:og_image"` + EffectiveTitle string `gorm:"column:effective_title"` + EffectiveDescription string `gorm:"column:effective_description"` + Data JSONMap `gorm:"column:data"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (Entry) TableName() string { return "golem15_seo_entries" } + +// JSONMap is a jsonb object column. A nil map is stored as {}. +type JSONMap map[string]any + +// Value encodes the map as JSON. +func (m JSONMap) Value() (driver.Value, error) { + if m == nil { + return "{}", nil + } + raw, err := json.Marshal(map[string]any(m)) + if err != nil { + return nil, err + } + return string(raw), nil +} + +// Scan decodes a jsonb object; NULL reads as an empty map. +func (m *JSONMap) Scan(src any) error { + var raw []byte + switch v := src.(type) { + case nil: + *m = JSONMap{} + return nil + case []byte: + raw = v + case string: + raw = []byte(v) + default: + return errors.New("models: unsupported jsonb value") + } + out := map[string]any{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &out); err != nil { + return err + } + } + *m = out + return nil +} + +func init() { + Register(Entry{}) +} diff --git a/models/registry.go b/models/registry.go new file mode 100644 index 0000000..5140b5f --- /dev/null +++ b/models/registry.go @@ -0,0 +1,17 @@ +// Package models holds the SEO plugin's GORM models: the per-record SEO +// entries, their history, the settings, the per-type settings and the URL +// rules. +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..1470e20 --- /dev/null +++ b/plugin.go @@ -0,0 +1,90 @@ +// Package seo is the golem15.seo plugin: per-record and per-URL SEO for +// every content type registered in signpost, global SEO settings, history, +// and the effective SEO of a URL for the frontend. Content plugins never +// import it: they register a content descriptor (pact.ContentDescriptor) on +// their menu item type, and this plugin adds an SEO tab to their admin form +// through a form extension and stores the values in its own tables. +package seo + +import ( + "embed" + "fmt" + "io/fs" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/sm-seo-plugin/updates" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ pact.HasAdminActionExtensions = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) +) + +//go:embed config +var configFS embed.FS + +//go:embed lang +var langFS embed.FS + +// Plugin is the golem15.seo plugin. +type Plugin struct { + app *backpack.App + service *classes.Service +} + +func (p *Plugin) ID() string { return "golem15.seo" } + +// Requires is empty: content types are found through signpost, never by +// plugin ID. +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(app *backpack.App) error { + p.app = app + return nil +} + +// Boot builds the service on the application's database, signpost registry +// and config. +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + svc, err := classes.NewService(app) + if err != nil { + return fmt.Errorf("golem15.seo: %w", err) + } + p.service = svc + return nil +} + +// Service returns the booted service, nil before Boot. +func (p *Plugin) Service() *classes.Service { + if p == nil { + return nil + } + return p.service +} + +// ConfigFS ships config/config.yaml. +func (p *Plugin) ConfigFS() fs.FS { return configFS } + +// LangFS is the embedded English and Polish phrasebooks. +func (p *Plugin) LangFS() fs.FS { return langFS } + +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..5f00357 --- /dev/null +++ b/routes.go @@ -0,0 +1,42 @@ +package seo + +import ( + "net/http" + "time" + + apicontrollers "git.golem15.com/golem15/sm-seo-plugin/controllers/api" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// BucketResolve is the throttle bucket of the public resolve endpoint. +const BucketResolve = "seo-resolve" + +// Routes mounts the public /_seo/api/v1 endpoints. They carry no backend +// middleware: the frontend server calls them anonymously. +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_seo/api/v1", surf.Use("throttle:"+BucketResolve), func(g pact.Router) { + g.Get("/resolve", apicontrollers.Resolve(p.Service)) + }) + return nil +} + +// Buckets registers seo-resolve: 600 requests a minute per client IP behind +// the configured trusted proxies (the frontend server resolves every page +// it renders). +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + return map[string]surf.Bucket{ + BucketResolve: { + Name: BucketResolve, + Max: 600, + Decay: time.Minute, + Key: func(r *http.Request) string { + return BucketResolve + "|" + surf.ClientIP(r, trusted) + }, + }, + } +} diff --git a/seo_tab_test.go b/seo_tab_test.go new file mode 100644 index 0000000..8547261 --- /dev/null +++ b/seo_tab_test.go @@ -0,0 +1,91 @@ +package seo_test + +import ( + "net/http" + "net/url" + "testing" + + seo "git.golem15.com/golem15/sm-seo-plugin" +) + +// seoTabLabel is the English label of seo.TabSEO. +const seoTabLabel = "SEO" + +func urlQueryEscape(s string) string { return url.QueryEscape(s) } + +// seoField returns the schema field name, or fails. +func findField(t *testing.T, fields []schemaField, name string) schemaField { + t.Helper() + for _, f := range fields { + if f.Name == name { + return f + } + } + t.Fatalf("schema has no field %q: %+v", name, fields) + return schemaField{} +} + +// TestSEOTabTracer is the plan's tracer: an editor sets a record's SEO +// title in the SEO tab the plugin adds to a content type it does not know, +// the value lands in the plugin's own table, and the public resolve answer +// returns it. +func TestSEOTabTracer(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + + fields := env.formSchema(t, token, "acme/pages/pages") + // The schema serves tab labels translated: golem15.seo::lang.tab.seo + // is "SEO" in English. + if f := findField(t, fields, "seo_title"); f.Tab != seoTabLabel { + t.Fatalf("seo_title tab = %q, want %q (%s)", f.Tab, seoTabLabel, seo.TabSEO) + } + if last := fields[len(fields)-1]; last.Tab != seoTabLabel { + t.Fatalf("last field %+v, want the SEO tab last", last) + } + + id := env.createPage(t, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true}) + + got := env.resolve(t, "/pages/about") + seoBody, _ := got["seo"].(map[string]any) + if got["action"] != "ok" || seoBody["title"] != "About us" { + t.Fatalf("resolve before the SEO title = %v, want the content title", got) + } + + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{ + "title": "About us", "slug": "about", "excerpt": "Who we are", "published": true, + "seo_title": map[string]any{"en": "Custom"}, + }) + + got = env.resolve(t, "/pages/about") + seoBody, _ = got["seo"].(map[string]any) + if got["action"] != "ok" || seoBody["title"] != "Custom" { + t.Fatalf("resolve = %v, want seo.title Custom", got) + } + if got["url"] != siteURL+"/pages/about" || seoBody["canonical"] != siteURL+"/pages/about" { + t.Fatalf("url/canonical = %v / %v, want the configured site URL, never the request Host", got["url"], seoBody["canonical"]) + } + + var stored string + if err := env.db.Raw(`SELECT title FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'en'`, pagesModel, id).Scan(&stored).Error; err != nil || stored != "Custom" { + t.Fatalf("stored entry title = %q err=%v", stored, err) + } + var seoColumns int64 + if err := env.db.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name LIKE 'seo%'`, pagesModel).Scan(&seoColumns).Error; err != nil || seoColumns != 0 { + t.Fatalf("owner table SEO columns = %d err=%v, want none", seoColumns, err) + } + + show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/"+id), token, nil) + if !jsonContains(show.Body.String(), `"seo_title":{"en":"Custom"}`) { + t.Fatalf("show body lacks the stored SEO title: %s", show.Body.String()) + } + + got = env.resolve(t, "/pl/pages/about") + seoBody, _ = got["seo"].(map[string]any) + if got["locale"] != "pl" || seoBody["title"] != "About us" { + t.Fatalf("pl resolve = %v, want the content title (no pl SEO title)", got) + } + + if got := env.resolve(t, "/pages/missing"); got["action"] != "not_found" { + t.Fatalf("unknown path = %v, want not_found", got) + } +} diff --git a/testmain_test.go b/testmain_test.go new file mode 100644 index 0000000..61d960f --- /dev/null +++ b/testmain_test.go @@ -0,0 +1,115 @@ +package seo_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.seo: 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_seo"), + postgres.WithUsername("golem15_seo"), + postgres.WithPassword("golem15_seo"), + 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("seo_%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/updates/202610270001_create_golem15_seo_entries.go b/updates/202610270001_create_golem15_seo_entries.go new file mode 100644 index 0000000..a69e081 --- /dev/null +++ b/updates/202610270001_create_golem15_seo_entries.go @@ -0,0 +1,56 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// createEntries creates golem15_seo_entries: one row per record and locale +// (D-01), keyed by the record's morph type, its id as text and the locale. +// Empty text means "inherit". data holds later fields (Phase 33 schema +// types, custom JSON-LD) without a migration each. +var createEntries = []*gormigrate.Migration{ + { + ID: "202610270001_create_golem15_seo_entries", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_seo_entries ( + id BIGSERIAL PRIMARY KEY, + model_type TEXT NOT NULL, + record_id TEXT NOT NULL, + locale TEXT NOT NULL DEFAULT '', + type_key TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + keyphrase TEXT NOT NULL DEFAULT '', + canonical TEXT NOT NULL DEFAULT '', + robots TEXT NOT NULL DEFAULT '', + og_title TEXT NOT NULL DEFAULT '', + og_description TEXT NOT NULL DEFAULT '', + og_image TEXT NOT NULL DEFAULT '', + effective_title TEXT NOT NULL DEFAULT '', + effective_description TEXT NOT NULL DEFAULT '', + data jsonb NOT NULL DEFAULT '{}', + created_at TIMESTAMP(0) NULL, + updated_at TIMESTAMP(0) NULL, + CONSTRAINT golem15_seo_entries_record_locale UNIQUE (model_type, record_id, locale) +)`, + `CREATE INDEX golem15_seo_entries_url ON golem15_seo_entries (url)`, + `CREATE INDEX golem15_seo_entries_type_key ON golem15_seo_entries (type_key)`, + } { + 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_seo_entries`).Error + }, + }, +} + +func init() { + Register(createEntries...) +} diff --git a/updates/registry.go b/updates/registry.go new file mode 100644 index 0000000..0c878ee --- /dev/null +++ b/updates/registry.go @@ -0,0 +1,18 @@ +// Package updates holds the SEO 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, in ID order (the +// files register themselves in file-name order). +func All() []*gormigrate.Migration { + return all +}