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
This commit is contained in:
2026-10-12 17:30:50 +02:00
commit 08f99b8aad
34 changed files with 2197 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
go.work.sum

45
admin.go Normal file
View File

@@ -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)
)

38
admin_navigation.go Normal file
View File

@@ -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)

19
admin_permissions.go Normal file
View File

@@ -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)

46
classes/links.go Normal file
View File

@@ -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
}

View File

@@ -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},
}
}

5
controllers/helpers.go Normal file
View File

@@ -0,0 +1,5 @@
package controllers
import "strconv"
func uintText(id uint) string { return strconv.FormatUint(uint64(id), 10) }

238
controllers/menuentries.go Normal file
View File

@@ -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{}
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

31
controllers/menus.go Normal file
View File

@@ -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{}
)

View File

@@ -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

View File

@@ -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

80
go.mod Normal file
View File

@@ -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

346
go.sum Normal file
View File

@@ -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=

5
go.work Normal file
View File

@@ -0,0 +1,5 @@
go 1.27.0
use .
replace git.golem15.com/golem15/summercms => ../summercms.go

47
lang/en/lang.yaml Normal file
View File

@@ -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.

47
lang/pl/lang.yaml Normal file
View File

@@ -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.

374
menu_smoke_test.go Normal file
View File

@@ -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)
}
}
}

38
models/menu.go Normal file
View File

@@ -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{})
}

26
models/menu/columns.yaml Normal file
View File

@@ -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

25
models/menu/fields.yaml Normal file
View File

@@ -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

170
models/menu_entry.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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

15
models/registry.go Normal file
View File

@@ -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
}

49
plugin.go Normal file
View File

@@ -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{})
}

115
testmain_test.go Normal file
View File

@@ -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
}

View File

@@ -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...)
}

View File

@@ -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...)
}

View File

@@ -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...)
}

17
updates/registry.go Normal file
View File

@@ -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
}