commit 08f99b8aade2dfa09c20ca7bd029971256685631 Author: Jakub Zych Date: Mon Oct 12 17:30:50 2026 +0200 feat(17-04): menu plugin admin with registry types, URL checks and tree reorder - golem15.menu plugin, tables golem15_menu_menus and golem15_menu_menu_entries with the PHP columns plus a nullable url_pattern (D-02) - MenuEntry BeforeSave ports PHP beforeSave: nest columns 0, menu from the parent, sort_order after the menu's last - entry type dropdown: url, divider and the signpost registry types; reference depends on type through signpost.ReferenceOptions - URL scheme allowlist (errors.url_scheme), target _self or _blank, menu and parent cycle checks - menu filter, toolbar reorder/create/delete and config_reorder.yaml partitioned by menu - navigation Menus with Menus and Menu entries, permission golem15.menu.manage_menu, en and pl phrasebooks - TestMenuAdminSmoke 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/admin.go b/admin.go new file mode 100644 index 0000000..987ca1c --- /dev/null +++ b/admin.go @@ -0,0 +1,45 @@ +package menu + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/sm-menu-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// adminFS holds the admin YAML file by file: the controllers directory also +// holds Go sources. +// +//go:embed controllers/menus/config_form.yaml controllers/menus/config_list.yaml models/menu/fields.yaml models/menu/columns.yaml +//go:embed controllers/menuentries/config_form.yaml controllers/menuentries/config_list.yaml controllers/menuentries/config_filter.yaml controllers/menuentries/config_reorder.yaml +//go:embed models/menuentry/fields.yaml models/menuentry/columns.yaml +var adminFS embed.FS + +//go:embed lang +var langFS embed.FS + +// AdminFS serves the admin YAML. +func (p *Plugin) AdminFS() fs.FS { return adminFS } + +// LangFS is the embedded English and Polish phrasebooks. +func (p *Plugin) LangFS() fs.FS { return langFS } + +// AdminControllers hands the controllers a lazy accessor of the booted app, +// so they read the database and the menu item type registry at request +// time. +func (p *Plugin) AdminControllers() []pact.AdminController { + return controllers.AdminControllers(func() *backpack.App { + if p == nil { + return nil + } + return p.app + }) +} + +var ( + _ pact.AdminAssets = (*Plugin)(nil) + _ pact.HasAdminControllers = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) +) diff --git a/admin_navigation.go b/admin_navigation.go new file mode 100644 index 0000000..74c9114 --- /dev/null +++ b/admin_navigation.go @@ -0,0 +1,38 @@ +package menu + +import ( + "git.golem15.com/golem15/sm-menu-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Navigation is a top-level Menus item with the side items Menus and Menu +// entries. PHP hung both screens on another plugin's side menu +// (SiteManager), which is not ported; the port registers its own. +func (*Plugin) Navigation() []pact.NavigationItem { + return []pact.NavigationItem{{ + Code: "menu", + Label: "golem15.menu::lang.plugin.name", + Icon: "list", + Permissions: []string{"golem15.menu.*"}, + Order: 510, + Controller: controllers.MenusControllerID, + SideMenu: []pact.NavigationItem{ + { + Code: "menus", + Label: "golem15.menu::lang.models.menu.label_plural", + Icon: "list", + Permissions: []string{controllers.PermissionManageMenu}, + Controller: controllers.MenusControllerID, + }, + { + Code: "menuentries", + Label: "golem15.menu::lang.models.menuentry.label_plural", + Icon: "list-ordered", + Permissions: []string{controllers.PermissionManageMenu}, + Controller: controllers.MenuEntriesControllerID, + }, + }, + }} +} + +var _ pact.HasNavigation = (*Plugin)(nil) diff --git a/admin_permissions.go b/admin_permissions.go new file mode 100644 index 0000000..bae8322 --- /dev/null +++ b/admin_permissions.go @@ -0,0 +1,19 @@ +package menu + +import ( + "git.golem15.com/golem15/sm-menu-plugin/controllers" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Permissions is the plugin's Winter registerPermissions() catalog: the PHP +// code golem15.menu.manage_menu on the developer and publisher roles. +func (*Plugin) Permissions() []pact.Permission { + return []pact.Permission{{ + Code: controllers.PermissionManageMenu, + Tab: "golem15.menu::lang.plugin.name", + Label: "golem15.menu::lang.permissions.manage_menu", + Roles: []string{"developer", "publisher"}, + }} +} + +var _ pact.HasPermissions = (*Plugin)(nil) diff --git a/classes/links.go b/classes/links.go new file mode 100644 index 0000000..00d419e --- /dev/null +++ b/classes/links.go @@ -0,0 +1,46 @@ +// Package classes holds the menu plugin's link rules and the tree the public +// API serves. +package classes + +import "strings" + +// AllowedURL reports whether an entry URL may be stored and served: empty, a +// path starting with "/", a fragment starting with "#", or an absolute +// http, https, mailto or tel URL (case-insensitive). Anything else, such as +// javascript: or data:, is refused, and so is a URL with surrounding +// whitespace or control characters, which browsers strip before reading the +// scheme (RESEARCH Pitfall 15). +func AllowedURL(url string) bool { + if url == "" { + return true + } + for _, r := range url { + if r < 0x20 || r == 0x7f { + return false + } + } + if strings.TrimSpace(url) != url { + return false + } + if url[0] == '/' || url[0] == '#' { + return true + } + lower := strings.ToLower(url) + for _, prefix := range []string{"http://", "https://", "mailto:", "tel:"} { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +// The link targets an entry may open in. +const ( + TargetSelf = "_self" + TargetBlank = "_blank" +) + +// AllowedTarget reports whether target is _self, _blank or empty. +func AllowedTarget(target string) bool { + return target == "" || target == TargetSelf || target == TargetBlank +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go new file mode 100644 index 0000000..215cd26 --- /dev/null +++ b/controllers/admin_registry.go @@ -0,0 +1,21 @@ +// Package controllers holds the menu plugin's admin controllers. +package controllers + +import ( + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// PermissionManageMenu is the PHP permission golem15.menu.manage_menu; both +// screens require it. +const PermissionManageMenu = "golem15.menu.manage_menu" + +// AdminControllers is the plugin's admin controller list. app returns the +// booted application; it is asked on every call, so a controller never keeps +// a database handle or registry of its own. +func AdminControllers(app func() *backpack.App) []pact.AdminController { + return []pact.AdminController{ + menusAdminController{}, + menuEntriesAdminController{app: app}, + } +} diff --git a/controllers/helpers.go b/controllers/helpers.go new file mode 100644 index 0000000..46c3a02 --- /dev/null +++ b/controllers/helpers.go @@ -0,0 +1,5 @@ +package controllers + +import "strconv" + +func uintText(id uint) string { return strconv.FormatUint(uint64(id), 10) } diff --git a/controllers/menuentries.go b/controllers/menuentries.go new file mode 100644 index 0000000..301eb10 --- /dev/null +++ b/controllers/menuentries.go @@ -0,0 +1,238 @@ +package controllers + +import ( + "context" + "errors" + + "git.golem15.com/golem15/sm-menu-plugin/classes" + "git.golem15.com/golem15/sm-menu-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/signpost" + "gorm.io/gorm" +) + +// MenuEntriesControllerID is the cabana controller id of the Menu entries +// screen. +const MenuEntriesControllerID = "golem15.menu.menuentries" + +// The phrase keys of the entry form's refusals and built-in type labels. +const ( + errURLScheme = "golem15.menu::lang.errors.url_scheme" + errTarget = "golem15.menu::lang.errors.target" + errTypeUnknown = "golem15.menu::lang.errors.type_unknown" + errMenuRequired = "golem15.menu::lang.errors.menu_required" + errParentCycle = "golem15.menu::lang.errors.parent_cycle" + labelTypeURL = "golem15.menu::lang.models.menuentry.type_url" + labelTypeDivide = "golem15.menu::lang.models.menuentry.type_divider" +) + +// menuEntriesAdminController lists and edits menu entries. Its type dropdown +// offers the built-ins url and divider and every menu item type of the +// registry; its reference dropdown depends on the type through +// signpost.ReferenceOptions; config_reorder.yaml gives the tree reorder page +// scoped to one menu. +type menuEntriesAdminController struct { + app func() *backpack.App +} + +func (menuEntriesAdminController) ID() string { return MenuEntriesControllerID } + +func (menuEntriesAdminController) ModelName() string { return `Golem15\Menu\Models\MenuEntry` } + +func (menuEntriesAdminController) ConfigDir() string { return "controllers/menuentries" } + +func (menuEntriesAdminController) RequiredPermissions() []string { + return []string{PermissionManageMenu} +} + +func (menuEntriesAdminController) NewRecord() any { return &models.MenuEntry{} } + +// AdminFieldRelations binds the menu field (also the reorder partition) and +// the parent field. +func (menuEntriesAdminController) AdminFieldRelations() []cabana.FieldRelationContract { + return []cabana.FieldRelationContract{ + {Field: "menu", Kind: "belongsTo", NewRelated: func() any { return &models.Menu{} }, ForeignKey: "menu_id"}, + {Field: "parent", Kind: "belongsTo", NewRelated: func() any { return &models.MenuEntry{} }, ForeignKey: "parent_id"}, + } +} + +// RelationExtendOptionsQuery orders the menu choices (and the reorder +// partitions) by name and the parent choices by title. +func (menuEntriesAdminController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB { + switch field { + case "menu": + return db.Order("name ASC").Order("id ASC") + case "parent": + return db.Order("title ASC").Order("id ASC") + } + return db +} + +func (c menuEntriesAdminController) registry() *signpost.Registry { + if c.app == nil { + return nil + } + app := c.app() + if app == nil { + return nil + } + reg, _ := app.Lookup[*signpost.Registry]() + return reg +} + +func (c menuEntriesAdminController) pool() *gorm.DB { + if c.app == nil { + return nil + } + app := c.app() + if app == nil { + return nil + } + db, _ := app.Lookup[*gorm.DB]() + return db +} + +// DropdownOptions serves the type dropdown: url (Link) and divider +// (Divider), then every registry type in registry order with its owning +// plugin's label (D-05). +func (c menuEntriesAdminController) DropdownOptions(string) []pact.Option { + out := []pact.Option{ + {Value: models.TypeURL, Label: labelTypeURL}, + {Value: models.TypeDivider, Label: labelTypeDivide}, + } + for _, t := range c.registry().Types() { + out = append(out, pact.Option{Value: t.Key, Label: t.Label}) + } + return out +} + +// DependentOptions serves the reference dropdown: url, divider and an +// unknown type take no reference (Applies false); a registry type offers its +// references through signpost.ReferenceOptions (D-08). +func (c menuEntriesAdminController) DependentOptions(ctx context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) { + typ, _ := values["type"].(string) + if typ == "" || models.Builtin(typ) { + return pact.DependentOptionSet{}, nil + } + return signpost.ReferenceOptions(ctx, c.registry(), typ) +} + +// FilterOptions serves the menu filter's choices: every menu by name. +func (c menuEntriesAdminController) FilterOptions(scope string) []pact.Option { + if scope != models.FilterMenu { + return nil + } + db := c.pool() + if db == nil { + return nil + } + var menus []models.Menu + if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&menus).Error; err != nil { + return nil + } + out := make([]pact.Option, 0, len(menus)) + for _, m := range menus { + out = append(out, pact.Option{Value: uintText(m.ID), Label: m.Name}) + } + return out +} + +// FormBeforeCreate checks the type, the URL scheme and the target. +func (c menuEntriesAdminController) FormBeforeCreate(_ context.Context, model any) error { + return c.checkEntry(model) +} + +// FormBeforeUpdate applies the create checks to an update. +func (c menuEntriesAdminController) FormBeforeUpdate(_ context.Context, model any) error { + return c.checkEntry(model) +} + +// checkEntry refuses an unknown type, a URL outside the scheme allowlist +// (errors.url_scheme, Pitfall 15) and a target other than _self or _blank. +func (c menuEntriesAdminController) checkEntry(model any) error { + entry, ok := model.(*models.MenuEntry) + if !ok || entry == nil { + return nil + } + details := map[string]any{} + if entry.Type != "" && !models.Builtin(entry.Type) { + if _, known := c.registry().Type(entry.Type); !known { + details["type"] = []string{errTypeUnknown} + } + } + if !classes.AllowedURL(models.Text(entry.URL)) { + details["url"] = []string{errURLScheme} + } + if !classes.AllowedTarget(models.Text(entry.Target)) { + details["target"] = []string{errTarget} + } + if len(details) > 0 { + return &cabana.ValidationError{Details: details} + } + return nil +} + +// FormAfterCreate refuses an entry that ends up in no menu (no menu chosen +// and no parent to take it from). cabana writes the menu and parent from the +// relation fields after the model rules ran, so the check runs once the row +// is written, inside the save's transaction. +func (menuEntriesAdminController) FormAfterCreate(ctx context.Context, model any) error { + return checkPlacement(ctx, model) +} + +// FormAfterUpdate also refuses a parent that is the entry itself or one of +// its descendants. +func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error { + return checkPlacement(ctx, model) +} + +func checkPlacement(ctx context.Context, model any) error { + entry, ok := model.(*models.MenuEntry) + if !ok || entry == nil { + return nil + } + if entry.MenuID == 0 { + return &cabana.ValidationError{Details: map[string]any{"menu": []string{errMenuRequired}}} + } + if entry.ParentID == nil { + return nil + } + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return errors.New("golem15.menu: the entry check needs the save's transaction") + } + db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx) + seen := map[uint]bool{} + for id := *entry.ParentID; id != 0; { + if id == entry.ID || seen[id] { + return &cabana.ValidationError{Details: map[string]any{"parent": []string{errParentCycle}}} + } + seen[id] = true + var rows []models.MenuEntry + if err := db.Select("id", "parent_id").Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil { + return err + } + if len(rows) == 0 || rows[0].ParentID == nil { + break + } + id = *rows[0].ParentID + } + return nil +} + +var ( + _ pact.AdminController = menuEntriesAdminController{} + _ pact.AdminPermissioned = menuEntriesAdminController{} + _ pact.AdminRecordSource = menuEntriesAdminController{} + _ pact.DropdownOptionsProvider = menuEntriesAdminController{} + _ pact.DependentOptionsProvider = menuEntriesAdminController{} + _ pact.FilterOptions = menuEntriesAdminController{} + _ pact.FormBeforeCreate = menuEntriesAdminController{} + _ pact.FormBeforeUpdate = menuEntriesAdminController{} + _ pact.FormAfterCreate = menuEntriesAdminController{} + _ pact.FormAfterUpdate = menuEntriesAdminController{} + _ pact.RelationExtendOptionsQuery = menuEntriesAdminController{} + _ cabana.FieldRelationProvider = menuEntriesAdminController{} +) diff --git a/controllers/menuentries/config_filter.yaml b/controllers/menuentries/config_filter.yaml new file mode 100644 index 0000000..0fbf125 --- /dev/null +++ b/controllers/menuentries/config_filter.yaml @@ -0,0 +1,14 @@ +# =================================== +# Filter Scope Definitions +# =================================== +# +# The menu filter replaces the PHP toolbar's menu select. Its name is the +# reorder partition column, so the list's Reorder button opens the filtered +# menu. + +scopes: + menu_id: + label: golem15.menu::lang.models.menuentry.menu + modelClass: Golem15\Menu\Models\Menu + nameFrom: name + scope: FilterMenu diff --git a/controllers/menuentries/config_form.yaml b/controllers/menuentries/config_form.yaml new file mode 100644 index 0000000..227f78a --- /dev/null +++ b/controllers/menuentries/config_form.yaml @@ -0,0 +1,25 @@ +# =================================== +# Form Behavior Config +# =================================== + +# Record name +name: golem15.menu::lang.models.menuentry.label + +# Model Form Field configuration +form: ~/plugins/golem15/menu/models/menuentry/fields.yaml + +# Model Class name +modelClass: Golem15\Menu\Models\MenuEntry + +# Default redirect location +defaultRedirect: golem15/menu/menuentries + +# Create page +create: + redirect: golem15/menu/menuentries/update/:id + redirectClose: golem15/menu/menuentries + +# Update page +update: + redirect: golem15/menu/menuentries + redirectClose: golem15/menu/menuentries diff --git a/controllers/menuentries/config_list.yaml b/controllers/menuentries/config_list.yaml new file mode 100644 index 0000000..e0f00cc --- /dev/null +++ b/controllers/menuentries/config_list.yaml @@ -0,0 +1,46 @@ +# =================================== +# List Behavior Config +# =================================== +# +# The PHP list showed the top-level entries of one menu picked in a toolbar +# select; here every entry is listed, the menu filter picks the menu, and +# the reorder page edits the tree. + +# Model List Column configuration +list: ~/plugins/golem15/menu/models/menuentry/columns.yaml + +# Model Class name +modelClass: Golem15\Menu\Models\MenuEntry + +# List Title +title: golem15.menu::lang.models.menuentry.label_plural + +# Link URL for each record +recordUrl: golem15/menu/menuentries/update/:id + +# Message to display if the list is empty +noRecordsMessage: backend::lang.list.no_records + +# Records to display per page +recordsPerPage: 20 + +# Options to provide the user when selecting how many records to display per page +perPageOptions: [20, 40, 80, 100, 120] + +# Displays the list column set up button +showSetup: true + +# Displays the sorting link on each column +showSorting: true + +# Display checkboxes next to each record +showCheckboxes: true + +# The menu filter (config_filter.yaml) +filter: config_filter.yaml + +# Toolbar: reorder (config_reorder.yaml), create and delete selected +toolbar: + buttons: [reorder, create, delete] + search: + prompt: backend::lang.list.search_prompt diff --git a/controllers/menuentries/config_reorder.yaml b/controllers/menuentries/config_reorder.yaml new file mode 100644 index 0000000..93005e4 --- /dev/null +++ b/controllers/menuentries/config_reorder.yaml @@ -0,0 +1,27 @@ +# =================================== +# Reorder Behavior Config +# =================================== +# +# The PHP reorder_config.yaml, scoped to one menu through the partition: the +# page lists the entries of one menu as a tree and saves parent_id and +# sort_order. The PHP toolbar partial is not supported. + +# Reorder Title +title: golem15.menu::lang.models.menuentry.reorder_title + +# Attribute name +nameFrom: title + +# Model Class name +modelClass: Golem15\Menu\Models\MenuEntry + +# Tree columns +parentColumn: parent_id +sortColumn: sort_order + +# One menu at a time +partition: + column: menu_id + relation: menu + nameFrom: name + label: golem15.menu::lang.models.menuentry.menu diff --git a/controllers/menus.go b/controllers/menus.go new file mode 100644 index 0000000..66714a5 --- /dev/null +++ b/controllers/menus.go @@ -0,0 +1,31 @@ +package controllers + +import ( + "git.golem15.com/golem15/sm-menu-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// MenusControllerID is the cabana controller id of the Menus screen. +const MenusControllerID = "golem15.menu.menus" + +// menusAdminController lists and edits menus: name, code, CSS class and the +// active switch. +type menusAdminController struct{} + +func (menusAdminController) ID() string { return MenusControllerID } + +func (menusAdminController) ModelName() string { return `Golem15\Menu\Models\Menu` } + +func (menusAdminController) ConfigDir() string { return "controllers/menus" } + +func (menusAdminController) RequiredPermissions() []string { + return []string{PermissionManageMenu} +} + +func (menusAdminController) NewRecord() any { return &models.Menu{} } + +var ( + _ pact.AdminController = menusAdminController{} + _ pact.AdminPermissioned = menusAdminController{} + _ pact.AdminRecordSource = menusAdminController{} +) diff --git a/controllers/menus/config_form.yaml b/controllers/menus/config_form.yaml new file mode 100644 index 0000000..846e3eb --- /dev/null +++ b/controllers/menus/config_form.yaml @@ -0,0 +1,25 @@ +# =================================== +# Form Behavior Config +# =================================== + +# Record name +name: golem15.menu::lang.models.menu.label + +# Model Form Field configuration +form: ~/plugins/golem15/menu/models/menu/fields.yaml + +# Model Class name +modelClass: Golem15\Menu\Models\Menu + +# Default redirect location +defaultRedirect: golem15/menu/menus + +# Create page +create: + redirect: golem15/menu/menus/update/:id + redirectClose: golem15/menu/menus + +# Update page +update: + redirect: golem15/menu/menus + redirectClose: golem15/menu/menus diff --git a/controllers/menus/config_list.yaml b/controllers/menus/config_list.yaml new file mode 100644 index 0000000..7dd3b17 --- /dev/null +++ b/controllers/menus/config_list.yaml @@ -0,0 +1,39 @@ +# =================================== +# List Behavior Config +# =================================== + +# Model List Column configuration +list: ~/plugins/golem15/menu/models/menu/columns.yaml + +# Model Class name +modelClass: Golem15\Menu\Models\Menu + +# List Title +title: golem15.menu::lang.models.menu.label_plural + +# Link URL for each record +recordUrl: golem15/menu/menus/update/:id + +# Message to display if the list is empty +noRecordsMessage: backend::lang.list.no_records + +# Records to display per page +recordsPerPage: 20 + +# Options to provide the user when selecting how many records to display per page +perPageOptions: [20, 40, 80, 100, 120] + +# Displays the list column set up button +showSetup: true + +# Displays the sorting link on each column +showSorting: true + +# Display checkboxes next to each record +showCheckboxes: true + +# Toolbar: the PHP list_toolbar partial held create and delete selected +toolbar: + buttons: [create, delete] + search: + prompt: backend::lang.list.search_prompt diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a867945 --- /dev/null +++ b/go.mod @@ -0,0 +1,80 @@ +module git.golem15.com/golem15/sm-menu-plugin + +go 1.27.0 + +require ( + git.golem15.com/golem15/summercms v0.1.23 + github.com/go-gormigrate/gormigrate/v2 v2.1.7 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/disintegration/imaging v1.6.2 // 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-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // 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/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/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // 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/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/wneessen/go-mail v0.8.1 // indirect + github.com/yuin/goldmark v1.8.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + gocloud.dev v0.46.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/image v0.46.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gorm.io/driver/postgres v1.6.3 // indirect +) + +replace git.golem15.com/golem15/summercms => ../summercms.go diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..02a9894 --- /dev/null +++ b/go.sum @@ -0,0 +1,346 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= +cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3 h1:w5OoDiMN6x53ROmiIImGzmVcxXv2q1GXY+aKV4WAJYM= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.3/go.mod h1:dAhgYp776bX3LuWvnSCFwQEjNs6fuFg7YXIy5PXcP3Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= +github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg= +github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= +github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= +github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/riverqueue/river v0.47.0 h1:j8HOEyiOE8gRRhVS2wllKams372TH1WWiH6xCakXHqc= +github.com/riverqueue/river v0.47.0/go.mod h1:Wgmwx475ZBd8lQnNrJgyG2DWH7BfyNiSAtv0rC9bJBQ= +github.com/riverqueue/river/riverdriver v0.47.0 h1:qU8VkjdMl9plqeRg57SxsDUM/i/eECaSYejZ7HynC60= +github.com/riverqueue/river/riverdriver v0.47.0/go.mod h1:NOXl0fUiF1AT/TaQOjdx2A/c0Davn+SKbW8nAXWjfC4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 h1:4wckhMlLN08F76/GWxGF6vlrbs5HCMuV01dpUnvOGI4= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0/go.mod h1:EME1EGr7RU/vMUf61mpf9YXhsZJ+GlFk4AHrIHT+YdE= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 h1:5N9nvemhQwbUElMxASw4oEaYJ/v6hiS5Y9VcOQfdC5g= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0/go.mod h1:ZboiXXZKC4+fTkxBxGRVmAsCuUu0NPYliqbWYuQAZyw= +github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOtPKQZAmThkjFpLs= +github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418= +github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0= +github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/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.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +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.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= +gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= +gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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..e11301f --- /dev/null +++ b/lang/en/lang.yaml @@ -0,0 +1,47 @@ +plugin: + name: Menus + description: Site menus with links, dividers and content entries. +permissions: + manage_menu: Manage site menus +models: + general: + id: ID + created_at: Created At + updated_at: Updated At + menu: + label: Menu + label_plural: Menus + name: Name + code: Code + css_class: CSS class + is_active: Active + menuentry: + label: Menu entry + label_plural: Menu entries + menu: Menu + menu_comment: Entries with a parent belong to the parent's menu. + parent: Parent + type: Type + type_url: Link + type_divider: Divider + title: Title + url: URL + url_comment: Used by Link entries. Start with http://, https://, mailto:, tel:, / or #. + reference: Reference + reference_comment: The record this entry points to, for types that need one. + url_pattern: URL pattern + url_pattern_comment: Use :placeholder syntax, e.g. /blog/:slug + code: Code + icon: Icon + css_class: CSS class + target: Opens in + target_self: Same tab + target_blank: New tab + is_published: Published + reorder_title: Reorder menu entries +errors: + url_scheme: Use a link that starts with http://, https://, mailto:, tel:, / or #. + target: Choose Same tab or New tab. + type_unknown: Choose one of the listed types. + menu_required: Choose a menu or a parent entry. + parent_cycle: An entry cannot be placed under itself or one of its own entries. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml new file mode 100644 index 0000000..a9ce5cf --- /dev/null +++ b/lang/pl/lang.yaml @@ -0,0 +1,47 @@ +plugin: + name: Menu + description: Menu strony z linkami, separatorami i pozycjami treści. +permissions: + manage_menu: Zarządzanie menu strony +models: + general: + id: ID + created_at: Utworzono + updated_at: Zaktualizowano + menu: + label: Menu + label_plural: Menu + name: Nazwa + code: Kod + css_class: Klasa CSS + is_active: Aktywne + menuentry: + label: Pozycja menu + label_plural: Pozycje menu + menu: Menu + menu_comment: Pozycje z pozycją nadrzędną należą do jej menu. + parent: Pozycja nadrzędna + type: Typ + type_url: Link + type_divider: Separator + title: Tytuł + url: URL + url_comment: Dla pozycji typu Link. Zacznij od http://, https://, mailto:, tel:, / lub #. + reference: Odnośnik + reference_comment: Rekord, do którego prowadzi pozycja, dla typów, które go wymagają. + url_pattern: Wzorzec URL + url_pattern_comment: Użyj składni :placeholder, np. /blog/:slug + code: Kod + icon: Ikona + css_class: Klasa CSS + target: Otwieraj w + target_self: Tej samej karcie + target_blank: Nowej karcie + is_published: Opublikowane + reorder_title: Zmiana kolejności pozycji menu +errors: + url_scheme: Podaj link zaczynający się od http://, https://, mailto:, tel:, / lub #. + target: Wybierz Tej samej karcie lub Nowej karcie. + type_unknown: Wybierz jeden z typów z listy. + menu_required: Wybierz menu albo pozycję nadrzędną. + parent_cycle: Pozycji nie można umieścić pod nią samą ani pod jej własnymi pozycjami. diff --git a/menu_smoke_test.go b/menu_smoke_test.go new file mode 100644 index 0000000..5772d38 --- /dev/null +++ b/menu_smoke_test.go @@ -0,0 +1,374 @@ +package menu_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + _ "git.golem15.com/golem15/sm-menu-plugin" + "git.golem15.com/golem15/sm-menu-plugin/models" + "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/surf" + "gorm.io/gorm" +) + +// testTypesPluginID is the fake content plugin that registers menu item +// types for the smoke tests, so the menu plugin needs no content plugin. +const testTypesPluginID = "acme.menutypes" + +const permManage = "golem15.menu.manage_menu" + +// acmeTypes registers acme-page (references 1 Home with 2 About under it) +// and acme-list (a list type without references). +type acmeTypes struct{} + +func (acmeTypes) ID() string { return testTypesPluginID } +func (acmeTypes) Requires() []string { return nil } +func (acmeTypes) Register(*backpack.App) error { return nil } +func (acmeTypes) Boot(*backpack.App) error { return nil } +func (acmeTypes) MenuItemTypes() []pact.MenuItemType { + return []pact.MenuItemType{ + { + Key: "acme-page", + Label: "Acme page", + DefaultPattern: "/pages/:slug", + References: func(context.Context) ([]pact.MenuItemReference, error) { + return []pact.MenuItemReference{{Value: "1", Label: "Home", Items: []pact.MenuItemReference{{Value: "2", Label: "About"}}}}, nil + }, + Resolve: resolvePage, + }, + { + Key: "acme-list", + Label: "Acme pages", + DefaultPattern: "/pages/:slug", + PatternFrom: "acme-page", + Nesting: true, + DynamicItems: true, + Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + var items []pact.ResolvedMenuItem + for _, slug := range []string{"home", "about"} { + url := strings.ReplaceAll(in.Pattern, ":slug", slug) + items = append(items, pact.ResolvedMenuItem{ + Title: slug, URL: url, + Alternates: []pact.MenuItemAlternate{{Locale: "pl", URL: url, Title: slug}, {Locale: "en", URL: "/en" + url, Title: slug + " (en)"}}, + }) + } + return &pact.ResolvedMenuItem{Items: items}, nil + }, + }, + } +} + +// resolvePage resolves references 1 (home) and 2 (about) with the layered +// pattern; any other reference no longer exists. +func resolvePage(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + slugs := map[string]string{"1": "home", "2": "about"} + slug, ok := slugs[in.Reference] + if !ok { + return nil, nil + } + url := strings.ReplaceAll(in.Pattern, ":slug", slug) + return &pact.ResolvedMenuItem{ + Title: slug, + URL: url, + Alternates: []pact.MenuItemAlternate{ + {Locale: "pl", URL: url, Title: slug}, + {Locale: "en", URL: "/en" + url, Title: slug + " (en)"}, + }, + }, nil +} + +var _ pact.HasMenuItemTypes = acmeTypes{} + +func init() { + party.Register(acmeTypes{}) +} + +// menuEnv is a booted application with the menu plugin and the fake types. +type menuEnv struct { + h http.Handler + app *backpack.App + gdb *gorm.DB +} + +func testConfig(t *testing.T) *compass.Config { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: menu-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=menu-smoke-test-only-hs256-secret-key-0001"}, + }) + 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) + } + } + return cfg +} + +func bootMenu(t *testing.T) *menuEnv { + t.Helper() + sqlDB := freshDB(t) + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + app := backpack.New(testConfig(t)) + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + t.Fatal(err) + } + plugins, err := party.Activate(app, []string{testTypesPluginID, "golem15.menu"}) + 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 &menuEnv{h: h, app: app, gdb: gdb} +} + +var adminSeq atomic.Uint64 + +// call sends an admin API request with an optional bearer token and JSON +// body. +func (e *menuEnv) 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, cabana.DefaultAdminPrefix+"/api/v1"+path, reader) + req.Host = "api.example.test" + req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.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 +} + +// admin signs in a new administrator whose role grants permissions. +func (e *menuEnv) admin(t *testing.T, permissions ...string) string { + t.Helper() + stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) + login, password := "admin-"+stamp, "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, _ := json.Marshal(grants) + var roleID uint + if err := e.gdb.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.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, + login, login+"@example.test", hash, roleID).Error; err != nil { + t.Fatal(err) + } + rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password}) + var body struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" { + t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String()) + } + return body.Data.AccessToken +} + +// create posts a record and returns its id; it fails the test unless the +// answer is 201. +func (e *menuEnv) create(t *testing.T, token, path string, body map[string]any) uint { + t.Helper() + rec := e.call(t, http.MethodPost, path, token, body) + var out struct { + Data struct { + ID uint `json:"id"` + } `json:"data"` + } + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Data.ID == 0 { + t.Fatalf("POST %s %v: %d %s", path, body, rec.Code, rec.Body.String()) + } + return out.Data.ID +} + +func (e *menuEnv) entry(t *testing.T, id uint) models.MenuEntry { + t.Helper() + var entry models.MenuEntry + if err := e.gdb.First(&entry, id).Error; err != nil { + t.Fatal(err) + } + return entry +} + +func intOf(p *int) int { + if p == nil { + return -1 + } + return *p +} + +// expect422 posts or puts body and checks for a 422 naming field and, +// when key is set, the phrase key. +func (e *menuEnv) expect422(t *testing.T, token, method, path string, body map[string]any, field, key string) { + t.Helper() + rec := e.call(t, method, path, token, body) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`":`) || (key != "" && !strings.Contains(rec.Body.String(), key)) { + t.Fatalf("%s %s %v: %d %s, want 422 on %s %s", method, path, body, rec.Code, rec.Body.String(), field, key) + } +} + +const ( + menusPath = "/golem15/menu/menus" + entriesPath = "/golem15/menu/menuentries" +) + +// TestMenuAdminSmoke drives the menus and menu entries screens: the PHP save +// rules, the D-05 type set, the reference recheck, the URL scheme and target +// refusals, the parent cycle refusal and the tree reorder page scoped to one +// menu. +func TestMenuAdminSmoke(t *testing.T) { + e := bootMenu(t) + token := e.admin(t, permManage) + + mainID := e.create(t, token, menusPath, map[string]any{"name": "Main", "code": "main", "is_active": true}) + e.expect422(t, token, http.MethodPost, menusPath, map[string]any{"name": "Again", "code": "main", "is_active": true}, "code", "") + + // The form offers the built-ins and the registry types; the reference + // depends on the type. + rec := e.call(t, http.MethodGet, entriesPath+"/schema/form", token, nil) + for _, want := range []string{`"value":"url"`, `"value":"divider"`, `"value":"acme-page"`, `"value":"acme-list"`, `"dependsOn":"type"`} { + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) { + t.Fatalf("form schema lacks %s: %d %s", want, rec.Code, rec.Body.String()) + } + } + rec = e.call(t, http.MethodGet, entriesPath+"/fields/reference/dependent-options?values%5Btype%5D=acme-page", token, nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"2"`) || !strings.Contains(rec.Body.String(), `"applies":true`) { + t.Fatalf("dependent options acme-page: %d %s", rec.Code, rec.Body.String()) + } + rec = e.call(t, http.MethodGet, entriesPath+"/fields/reference/dependent-options?values%5Btype%5D=url", token, nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"applies":false`) { + t.Fatalf("dependent options url: %d %s", rec.Code, rec.Body.String()) + } + + // The first entry; the second has no sort_order and goes after it with + // zero nest columns. + home := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Home", "url": "/", "target": "_self"}) + divider := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "divider"}) + d := e.entry(t, divider) + if intOf(d.SortOrder) != intOf(e.entry(t, home).SortOrder)+1 || intOf(d.NestLeft) != 0 || intOf(d.NestRight) != 0 || intOf(d.NestDepth) != 0 || models.Text(d.Title) != "" || d.IsPublished == nil || !*d.IsPublished { + t.Fatalf("divider = %+v", d) + } + page := e.create(t, token, entriesPath, map[string]any{"menu": mainID, "type": "acme-page", "title": "About", "reference": "2", "url_pattern": "/p/:slug"}) + + // A child without a menu takes its parent's menu. + child := e.create(t, token, entriesPath, map[string]any{"parent": home, "type": "url", "title": "Contact", "url": "mailto:hello@example.test", "target": "_blank"}) + if c := e.entry(t, child); c.MenuID != mainID || c.ParentID == nil || *c.ParentID != home { + t.Fatalf("child = %+v", c) + } + + // Refusals: a reference the type does not offer, a javascript: URL, an + // unknown target, an unknown type, no menu at all, a parent cycle. + e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "acme-page", "title": "Gone", "reference": "99"}, "reference", "") + e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Evil", "url": "javascript:alert(1)"}, "url", "golem15.menu::lang.errors.url_scheme") + e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "url", "title": "Top", "url": "/", "target": "_top"}, "target", "golem15.menu::lang.errors.target") + e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"menu": mainID, "type": "page", "title": "Old"}, "type", "") + e.expect422(t, token, http.MethodPost, entriesPath, map[string]any{"type": "url", "title": "Nowhere", "url": "/"}, "menu", "golem15.menu::lang.errors.menu_required") + e.expect422(t, token, http.MethodPut, fmt.Sprintf("%s/%d", entriesPath, home), map[string]any{"parent": child}, "parent", "golem15.menu::lang.errors.parent_cycle") + var n int64 + e.gdb.Model(&models.MenuEntry{}).Count(&n) + if n != 4 { + t.Fatalf("refusals left %d entries, want 4", n) + } + + // The list filtered on the menu, and the reorder tree of that menu. + footerID := e.create(t, token, menusPath, map[string]any{"name": "Footer", "code": "footer", "is_active": true}) + foreign := e.create(t, token, entriesPath, map[string]any{"menu": footerID, "type": "url", "title": "Imprint", "url": "/imprint"}) + rec = e.call(t, http.MethodGet, fmt.Sprintf("%s?filter[menu_id]=%d", entriesPath, mainID), token, nil) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "Imprint") || !strings.Contains(rec.Body.String(), "Contact") { + t.Fatalf("filtered list: %d %s", rec.Code, rec.Body.String()) + } + rec = e.call(t, http.MethodGet, fmt.Sprintf("%s/reorder?partition=%d", entriesPath, mainID), token, nil) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "Imprint") || !strings.Contains(rec.Body.String(), `"title":"Reorder menu entries"`) { + t.Fatalf("reorder tree: %d %s", rec.Code, rec.Body.String()) + } + + // Nest the acme page under Home after Contact; the divider moves up. + nodes := []map[string]any{ + {"id": home, "parent_id": nil, "sort_order": 1}, + {"id": child, "parent_id": home, "sort_order": 1}, + {"id": page, "parent_id": home, "sort_order": 2}, + {"id": divider, "parent_id": nil, "sort_order": 2}, + } + rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes}) + if rec.Code != http.StatusOK { + t.Fatalf("reorder: %d %s", rec.Code, rec.Body.String()) + } + if p := e.entry(t, page); p.ParentID == nil || *p.ParentID != home || intOf(p.SortOrder) != 2 { + t.Fatalf("reordered page = %+v", p) + } + if dv := e.entry(t, divider); dv.ParentID != nil || intOf(dv.SortOrder) != 2 { + t.Fatalf("reordered divider = %+v", dv) + } + + // A reorder naming an entry of another menu changes nothing. + nodes = append(nodes, map[string]any{"id": foreign, "parent_id": nil, "sort_order": 3}) + rec = e.call(t, http.MethodPost, entriesPath+"/reorder", token, map[string]any{"partition": mainID, "nodes": nodes}) + if rec.Code != http.StatusConflict { + t.Fatalf("reorder with a foreign entry: %d %s", rec.Code, rec.Body.String()) + } + + // Without the permission the screens answer 403. + other := e.admin(t) + for _, path := range []string{menusPath, entriesPath} { + if rec := e.call(t, http.MethodGet, path, other, nil); rec.Code != http.StatusForbidden { + t.Errorf("GET %s without permission: %d", path, rec.Code) + } + } +} diff --git a/models/menu.go b/models/menu.go new file mode 100644 index 0000000..ade57ba --- /dev/null +++ b/models/menu.go @@ -0,0 +1,38 @@ +package models + +import "time" + +// Menu is a named menu stored on golem15_menu_menus, the PHP +// Golem15\Menu\Models\Menu. Sites address a menu by its unique code. +type Menu struct { + ID uint `gorm:"column:id;primaryKey"` + Name string `gorm:"column:name"` + Code string `gorm:"column:code"` + IsActive bool `gorm:"column:is_active"` + CSSClass *string `gorm:"column:css_class"` + Entries []MenuEntry `gorm:"foreignKey:MenuID" json:"-"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (Menu) TableName() string { return "golem15_menu_menus" } + +// MorphName is the PHP class string of the model. +func (Menu) MorphName() string { return `Golem15\Menu\Models\Menu` } + +// Fillable is the list an admin save may set. +func (Menu) Fillable() []string { + return []string{"name", "code", "is_active", "css_class"} +} + +// Rules require a name and a code that no other menu uses. +func (Menu) Rules() map[string]string { + return map[string]string{ + "name": "required", + "code": "required|unique:golem15_menu_menus", + } +} + +func init() { + Register(Menu{}) +} diff --git a/models/menu/columns.yaml b/models/menu/columns.yaml new file mode 100644 index 0000000..b1232f7 --- /dev/null +++ b/models/menu/columns.yaml @@ -0,0 +1,26 @@ +# =================================== +# List Column Definitions +# =================================== + +columns: + id: + label: golem15.menu::lang.models.general.id + searchable: true + name: + label: golem15.menu::lang.models.menu.name + searchable: true + code: + label: golem15.menu::lang.models.menu.code + searchable: true + is_active: + label: golem15.menu::lang.models.menu.is_active + type: switch + created_at: + label: golem15.menu::lang.models.general.created_at + type: datetime + sortable: true + invisible: true + updated_at: + label: golem15.menu::lang.models.general.updated_at + type: datetime + sortable: true diff --git a/models/menu/fields.yaml b/models/menu/fields.yaml new file mode 100644 index 0000000..7ade066 --- /dev/null +++ b/models/menu/fields.yaml @@ -0,0 +1,25 @@ +# =================================== +# Form Field Definitions +# =================================== + +fields: + name: + label: golem15.menu::lang.models.menu.name + type: text + span: left + required: true + code: + label: golem15.menu::lang.models.menu.code + type: text + span: right + required: true + preset: name + css_class: + label: golem15.menu::lang.models.menu.css_class + type: text + span: left + is_active: + label: golem15.menu::lang.models.menu.is_active + type: switch + span: right + default: true diff --git a/models/menu_entry.go b/models/menu_entry.go new file mode 100644 index 0000000..4312f83 --- /dev/null +++ b/models/menu_entry.go @@ -0,0 +1,170 @@ +package models + +import ( + "fmt" + "strconv" + "strings" + "time" + + "gorm.io/gorm" +) + +// The built-in entry types (D-05). Every other type is a menu item type of +// the framework's registry (signpost). PHP stored the link type as "link"; +// the Winter import maps it to TypeURL. +const ( + // TypeURL is a link to the entry's stored URL. + TypeURL = "url" + // TypeDivider is a separator with no URL. + TypeDivider = "divider" +) + +// Builtin reports whether typ is a built-in entry type. +func Builtin(typ string) bool { return typ == TypeURL || typ == TypeDivider } + +// MenuEntry is one entry of a menu, stored on golem15_menu_menu_entries: the +// PHP Golem15\Menu\Models\MenuEntry. Entries form a tree per menu through +// parent_id and are ordered by sort_order within their parent. The nest +// columns are kept for the PHP schema; nothing reads them. +type MenuEntry struct { + ID uint `gorm:"column:id;primaryKey"` + MenuID uint `gorm:"column:menu_id"` + Menu *Menu `gorm:"foreignKey:MenuID" json:"-"` + ParentID *uint `gorm:"column:parent_id"` + Parent *MenuEntry `gorm:"foreignKey:ParentID" json:"-"` + NestLeft *int `gorm:"column:nest_left"` + NestRight *int `gorm:"column:nest_right"` + NestDepth *int `gorm:"column:nest_depth"` + SortOrder *int `gorm:"column:sort_order"` + Type string `gorm:"column:type"` + Title *string `gorm:"column:title"` + URL *string `gorm:"column:url"` + URLPattern *string `gorm:"column:url_pattern"` + Reference *string `gorm:"column:reference"` + Code *string `gorm:"column:code"` + Icon *string `gorm:"column:icon"` + CSSClass *string `gorm:"column:css_class"` + Target *string `gorm:"column:target"` + IsPublished *bool `gorm:"column:is_published"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +func (MenuEntry) TableName() string { return "golem15_menu_menu_entries" } + +// MorphName is the PHP class string of the model. +func (MenuEntry) MorphName() string { return `Golem15\Menu\Models\MenuEntry` } + +// Fillable is the list an admin save may set. The nest columns and +// sort_order are not: the save hook and the reorder page set them. +func (MenuEntry) Fillable() []string { + return []string{"menu_id", "parent_id", "type", "title", "url", "url_pattern", "reference", "code", "icon", "css_class", "target", "is_published"} +} + +// Rules require a type; the admin controller checks the type, URL, target, +// menu and parent beyond that. +func (MenuEntry) Rules() map[string]string { + return map[string]string{"type": "required"} +} + +// BeforeSave ports the PHP beforeSave: the nest columns become 0 when unset, +// an entry with a parent takes the parent's menu, and an entry without a +// sort_order goes after the menu's last one. A missing title is stored empty +// (dividers have none) and a missing published flag is the PHP default true. +func (e *MenuEntry) BeforeSave(tx *gorm.DB) error { + zero := func(p **int) { + if *p == nil { + v := 0 + *p = &v + } + } + zero(&e.NestLeft) + zero(&e.NestRight) + zero(&e.NestDepth) + if e.Title == nil { + empty := "" + e.Title = &empty + } + if e.IsPublished == nil { + on := true + e.IsPublished = &on + } + db := tx.Session(&gorm.Session{NewDB: true}) + if e.ParentID != nil && *e.ParentID != 0 { + var menuIDs []uint + if err := db.Model(&MenuEntry{}).Where("id = ?", *e.ParentID).Limit(1).Pluck("menu_id", &menuIDs).Error; err != nil { + return err + } + if len(menuIDs) > 0 && menuIDs[0] != 0 { + e.MenuID = menuIDs[0] + } + } + if e.SortOrder == nil { + var max *int + if err := db.Model(&MenuEntry{}).Where("menu_id = ?", e.MenuID).Select("MAX(sort_order)").Scan(&max).Error; err != nil { + return err + } + next := 1 + if max != nil { + next = *max + 1 + } + e.SortOrder = &next + } + return nil +} + +// Text is the value of an optional text column, "" when null. +func Text(s *string) string { + if s == nil { + return "" + } + return *s +} + +func init() { + Register(MenuEntry{}) +} + +// FilterMenu is the config_filter.yaml scope of the menu filter. +const FilterMenu = "FilterMenu" + +// FilterScopes lists the scopes the list filters may name. +func (MenuEntry) FilterScopes() []string { return []string{FilterMenu} } + +// FilterScope narrows the list to the entries of the chosen menus. +func (MenuEntry) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { + if db == nil || name != FilterMenu { + return db + } + ids := filterIDs(value) + if len(ids) == 0 { + return db + } + return db.Where("menu_id IN ?", ids) +} + +// filterIDs reads one id or a list of ids from a filter value. +func filterIDs(value any) []uint64 { + var raw []any + switch v := value.(type) { + case []any: + raw = v + case []string: + for _, s := range v { + raw = append(raw, s) + } + default: + raw = []any{v} + } + var out []uint64 + for _, item := range raw { + text := strings.TrimSpace(fmt.Sprint(item)) + if item == nil || text == "" { + continue + } + if id, err := strconv.ParseUint(text, 10, 32); err == nil && id > 0 { + out = append(out, id) + } + } + return out +} diff --git a/models/menuentry/columns.yaml b/models/menuentry/columns.yaml new file mode 100644 index 0000000..376b052 --- /dev/null +++ b/models/menuentry/columns.yaml @@ -0,0 +1,34 @@ +# =================================== +# List Column Definitions +# =================================== + +columns: + id: + label: golem15.menu::lang.models.general.id + searchable: true + menu: + label: golem15.menu::lang.models.menuentry.menu + searchable: true + relation: menu + select: name + title: + label: golem15.menu::lang.models.menuentry.title + searchable: true + type: + label: golem15.menu::lang.models.menuentry.type + searchable: true + url: + label: golem15.menu::lang.models.menuentry.url + searchable: true + is_published: + label: golem15.menu::lang.models.menuentry.is_published + type: switch + created_at: + label: golem15.menu::lang.models.general.created_at + type: datetime + sortable: true + invisible: true + updated_at: + label: golem15.menu::lang.models.general.updated_at + type: datetime + sortable: true diff --git a/models/menuentry/fields.yaml b/models/menuentry/fields.yaml new file mode 100644 index 0000000..1900fb5 --- /dev/null +++ b/models/menuentry/fields.yaml @@ -0,0 +1,77 @@ +# =================================== +# Form Field Definitions +# =================================== +# +# Adapted from the WinterCMS form: the "Menu Entry Options" tab is flattened, +# the "Children" tab and partial are gone (the reorder page replaces them), +# menu is a relation field (it is also the reorder partition), the type +# options are the built-ins plus the registry types, and reference, +# url_pattern and target are new. + +fields: + menu: + label: golem15.menu::lang.models.menuentry.menu + comment: golem15.menu::lang.models.menuentry.menu_comment + type: relation + nameFrom: name + span: full + parent: + label: golem15.menu::lang.models.menuentry.parent + type: relation + nameFrom: title + emptyOption: backend::lang.form.none + span: left + type: + label: golem15.menu::lang.models.menuentry.type + type: dropdown + options: getTypeOptions + default: url + required: true + span: right + title: + label: golem15.menu::lang.models.menuentry.title + type: text + span: left + url: + label: golem15.menu::lang.models.menuentry.url + comment: golem15.menu::lang.models.menuentry.url_comment + type: text + span: right + reference: + label: golem15.menu::lang.models.menuentry.reference + comment: golem15.menu::lang.models.menuentry.reference_comment + type: dropdown + dependsOn: type + emptyOption: backend::lang.form.none + span: left + url_pattern: + label: golem15.menu::lang.models.menuentry.url_pattern + comment: golem15.menu::lang.models.menuentry.url_pattern_comment + type: text + span: right + code: + label: golem15.menu::lang.models.menuentry.code + type: text + span: left + icon: + label: golem15.menu::lang.models.menuentry.icon + type: text + span: right + css_class: + label: golem15.menu::lang.models.menuentry.css_class + type: text + span: left + target: + label: golem15.menu::lang.models.menuentry.target + type: dropdown + default: _self + span: right + options: + _self: golem15.menu::lang.models.menuentry.target_self + _blank: golem15.menu::lang.models.menuentry.target_blank + is_published: + label: golem15.menu::lang.models.menuentry.is_published + type: switch + default: true + context: update + span: left diff --git a/models/registry.go b/models/registry.go new file mode 100644 index 0000000..fe6929f --- /dev/null +++ b/models/registry.go @@ -0,0 +1,15 @@ +// Package models holds the menu plugin's GORM models. +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..0faa4bf --- /dev/null +++ b/plugin.go @@ -0,0 +1,49 @@ +// Package menu is the golem15.menu plugin: site menus whose entries are +// links, dividers and items of the framework's menu item type registry, +// edited in the admin as a tree per menu. A port of the WinterCMS +// Golem15.Menu plugin. +package menu + +import ( + "git.golem15.com/golem15/sm-menu-plugin/models" + "git.golem15.com/golem15/sm-menu-plugin/updates" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) +) + +// Plugin is the golem15.menu plugin. +type Plugin struct { + app *backpack.App +} + +func (p *Plugin) ID() string { return "golem15.menu" } + +// Requires is empty: content types come through the framework's menu item +// type registry, not from a named plugin. +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } + +func (p *Plugin) Models() []any { return models.All() } + +func init() { + party.Register(&Plugin{}) +} diff --git a/testmain_test.go b/testmain_test.go new file mode 100644 index 0000000..c97df14 --- /dev/null +++ b/testmain_test.go @@ -0,0 +1,115 @@ +package menu_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.menu: 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_menu"), + postgres.WithUsername("golem15_menu"), + postgres.WithPassword("golem15_menu"), + 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("menu_%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/202610110001_create_golem15_menu_menus.go b/updates/202610110001_create_golem15_menu_menus.go new file mode 100644 index 0000000..6def3da --- /dev/null +++ b/updates/202610110001_create_golem15_menu_menus.go @@ -0,0 +1,33 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// createMenus creates golem15_menu_menus with the PHP columns (v1.0.2 +// create_menus_table): a unique code, the active flag (default true) and an +// optional CSS class. +var createMenus = []*gormigrate.Migration{ + { + ID: "202610110001_create_golem15_menu_menus", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE golem15_menu_menus ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + code VARCHAR(255) NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + css_class VARCHAR(255) NULL, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL +)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_menu_menus`).Error + }, + }, +} + +func init() { + Register(createMenus...) +} diff --git a/updates/202610110002_create_golem15_menu_menu_entries.go b/updates/202610110002_create_golem15_menu_menu_entries.go new file mode 100644 index 0000000..58abf2f --- /dev/null +++ b/updates/202610110002_create_golem15_menu_menu_entries.go @@ -0,0 +1,53 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// createMenuEntries creates golem15_menu_menu_entries with the PHP columns +// (v1.0.1 create_menu_entries_table): the menu, the tree columns (parent_id, +// the NestedTree nest columns and sort_order), the entry's type, title, URL +// and reference, and its display attributes. +var createMenuEntries = []*gormigrate.Migration{ + { + ID: "202610110002_create_golem15_menu_menu_entries", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_menu_menu_entries ( + id SERIAL PRIMARY KEY, + menu_id INTEGER NOT NULL, + parent_id INTEGER NULL, + nest_left INTEGER NOT NULL, + nest_right INTEGER NOT NULL, + nest_depth INTEGER NOT NULL, + sort_order INTEGER NULL, + type VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + url VARCHAR(255) NULL, + reference VARCHAR(255) NULL, + code VARCHAR(255) NULL, + icon VARCHAR(255) NULL, + css_class VARCHAR(255) NULL, + target VARCHAR(255) NULL, + is_published BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NULL +)`, + `CREATE INDEX golem15_menu_menu_entries_menu_parent_sort_index ON golem15_menu_menu_entries (menu_id, parent_id, sort_order)`, + } { + 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_menu_menu_entries`).Error + }, + }, +} + +func init() { + Register(createMenuEntries...) +} diff --git a/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go b/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go new file mode 100644 index 0000000..1b5af71 --- /dev/null +++ b/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go @@ -0,0 +1,25 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// addURLPattern adds the one column the PHP table lacks: a nullable +// url_pattern, the entry's own URL pattern override for registry types +// (D-02). Rows copied from PHP keep NULL there. +var addURLPattern = []*gormigrate.Migration{ + { + ID: "202610110003_add_url_pattern_to_golem15_menu_menu_entries", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE golem15_menu_menu_entries ADD COLUMN url_pattern VARCHAR(255) NULL`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE golem15_menu_menu_entries DROP COLUMN IF EXISTS url_pattern`).Error + }, + }, +} + +func init() { + Register(addURLPattern...) +} diff --git a/updates/registry.go b/updates/registry.go new file mode 100644 index 0000000..c171d4b --- /dev/null +++ b/updates/registry.go @@ -0,0 +1,17 @@ +// Package updates holds the menu plugin's migrations. +package updates + +import "github.com/go-gormigrate/gormigrate/v2" + +var all []*gormigrate.Migration + +// Register appends gormigrate steps so later files add migrations without +// editing plugin.go. +func Register(ms ...*gormigrate.Migration) { + all = append(all, ms...) +} + +// All returns every migration registered by this package. +func All() []*gormigrate.Migration { + return all +}