package controllers import ( "context" "errors" "strings" "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" errReference = "golem15.menu::lang.errors.reference_required" errMenuRequired = "golem15.menu::lang.errors.menu_required" errParentCycle = "golem15.menu::lang.errors.parent_cycle" errParentMenu = "golem15.menu::lang.errors.parent_menu" 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 registry type that takes a // reference saved without one (it would resolve to nothing and vanish from // the public tree), 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) { t, known := c.registry().Type(entry.Type) switch { case !known: details["type"] = []string{errTypeUnknown} case t.References != nil && strings.TrimSpace(models.Text(entry.Reference)) == "": details["reference"] = []string{errReference} } } 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, then moves the entry's descendants into its menu, so a // subtree follows its root to another menu instead of staying behind with // parents in a menu it is not in. func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error { if err := checkPlacement(ctx, model); err != nil { return err } entry, ok := model.(*models.MenuEntry) if !ok || entry == nil || entry.ID == 0 { return nil } tx, ok := cabana.TxFromContext(ctx) if !ok { return errors.New("golem15.menu: the entry check needs the save's transaction") } return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Exec(`WITH RECURSIVE sub(id) AS ( SELECT id FROM golem15_menu_menu_entries WHERE parent_id = ? AND id <> ? UNION SELECT e.id FROM golem15_menu_menu_entries e JOIN sub ON e.parent_id = sub.id ) UPDATE golem15_menu_menu_entries SET menu_id = ? WHERE id IN (SELECT id FROM sub) AND id <> ? AND menu_id <> ?`, entry.ID, entry.ID, entry.MenuID, entry.ID, entry.MenuID).Error } // checkPlacement refuses an entry in no menu, a parent from another menu // than the one the entry names (BeforeSave would otherwise move the entry to // the parent's menu without a word) and a parent cycle. 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.RequestedMenuID() != 0 { return &cabana.ValidationError{Details: map[string]any{"parent": []string{errParentMenu}}} } 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{} )