fix(17): WR-03 require a reference for a menu entry whose type takes one
An entry of a registry type with References saved without a reference passed the dependent recheck (null is always among the options), then resolved to nothing and vanished from the public tree with its children. The admin save now refuses it (errors.reference_required).
This commit is contained in:
@@ -18,7 +18,7 @@ It requires no other plugin. Content types (blog posts, categories, pages) come
|
||||
|
||||
- **Tables.** `golem15_menu_menus` (id, name, code unique, is_active default true, css_class, timestamps) and `golem15_menu_menu_entries` (id, menu_id, parent_id, nest_left, nest_right, nest_depth, sort_order, type, title, url, reference, code, icon, css_class, target, is_published default true, timestamps) keep the PHP columns. One additive nullable column, `url_pattern`, holds the per-entry URL pattern override of a registry entry. Three gormigrate steps create them; AutoMigrate is not used.
|
||||
- **Save rules.** `models.MenuEntry.BeforeSave` ports the PHP `beforeSave`: the nest columns become 0 when unset, an entry without `sort_order` goes after the menu's last entry, an entry with a parent always takes the parent's menu (`models.MenuEntry.RequestedMenuID` then reports the other menu the save named, if any), a missing title is stored empty and a missing published flag is true. The nest columns are kept for the PHP schema; the tree is `parent_id` plus `sort_order`.
|
||||
- **Entry types.** The built-ins `url` (Link, `models.TypeURL`) and `divider` (Divider, `models.TypeDivider`), then every registry type with its owning plugin's label. The reference dropdown depends on the type through `signpost.ReferenceOptions`; built-ins take no reference. The admin save refuses an unknown type, a reference the type does not offer, a URL outside the scheme allowlist and a target other than `_self`, `_blank` or empty.
|
||||
- **Entry types.** The built-ins `url` (Link, `models.TypeURL`) and `divider` (Divider, `models.TypeDivider`), then every registry type with its owning plugin's label. The reference dropdown depends on the type through `signpost.ReferenceOptions`; built-ins take no reference. The admin save refuses an unknown type, a reference the type does not offer, a missing reference for a type that takes one, a URL outside the scheme allowlist and a target other than `_self`, `_blank` or empty.
|
||||
- **URL allowlist.** `classes.AllowedURL` accepts an empty URL, a path starting with `/`, a fragment starting with `#`, and `http:`, `https:`, `mailto:` and `tel:` URLs. Anything else (`javascript:`, `data:`, a URL with surrounding whitespace or control characters) is a 422 `errors.url_scheme` on save and a `null` URL in the API.
|
||||
- **Admin.** A top-level Menus item (icon `list`, order 510) with the side items Menus and Menu entries, both behind the permission `golem15.menu.manage_menu` (developer and publisher roles). Menus: name, code (preset from name), css_class, is_active. Menu entries: a list of every entry with a menu filter and the toolbar buttons reorder, create and delete; a form with menu, parent, type, title, url, reference, url_pattern, code, icon, css_class, target and is_published; a tree reorder page scoped to one menu (`config_reorder.yaml`, partition `menu_id`), which refuses a payload naming an entry of another menu with 409.
|
||||
- **Public API.** `GET /_menu/api/v1/menus/{code}` serves an active menu with its published entries as a nested tree. See [Usage](#usage).
|
||||
@@ -188,7 +188,7 @@ cors:
|
||||
- Menu entries list: PHP listed the top-level entries of one menu chosen in a toolbar select, with a Children tab and partial in the form. Here the list shows every entry, a menu filter picks the menu, and the reorder page edits the tree per menu; the Children tab is not ported.
|
||||
- Entry form: the menu field is a relation field (also the reorder partition); `url_pattern` (per-entry pattern override) and `target` (`_self`, `_blank`) are new fields. The labels "Menuses" and "Is_published" read Menus and Published.
|
||||
- Type keys: PHP `link` is `url`; PHP `blog_categories` is the registry type `all-journal-categories` when a blog plugin registers it; `page` is not supported.
|
||||
- Saves: an entry with a parent always takes the parent's menu (PHP only when the menu was empty); the admin form refuses with 422 a parent from another menu than the one the form names (`errors.parent_menu`), and an entry moved to another menu takes its whole subtree along, so no descendant is left with a parent in another menu. An unknown type, a parent cycle, a disallowed URL or target and an entry without a menu are refused with 422; a menu code another menu uses is a 422.
|
||||
- Saves: an entry with a parent always takes the parent's menu (PHP only when the menu was empty); the admin form refuses with 422 a parent from another menu than the one the form names (`errors.parent_menu`), and an entry moved to another menu takes its whole subtree along, so no descendant is left with a parent in another menu. An unknown type, a registry type that takes a reference saved without one (`errors.reference_required`; it would resolve to nothing and vanish from the public tree), a parent cycle, a disallowed URL or target and an entry without a menu are refused with 422; a menu code another menu uses is a 422.
|
||||
- API: the public JSON API replaces the PHP component and filters unpublished entries.
|
||||
- Timestamps are `TIMESTAMPTZ`.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package controllers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
@@ -22,6 +23,7 @@ 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"
|
||||
@@ -150,8 +152,10 @@ func (c menuEntriesAdminController) FormBeforeUpdate(_ context.Context, model an
|
||||
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.
|
||||
// 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 {
|
||||
@@ -159,8 +163,12 @@ func (c menuEntriesAdminController) checkEntry(model any) error {
|
||||
}
|
||||
details := map[string]any{}
|
||||
if entry.Type != "" && !models.Builtin(entry.Type) {
|
||||
if _, known := c.registry().Type(entry.Type); !known {
|
||||
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)) {
|
||||
|
||||
@@ -43,6 +43,7 @@ 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.
|
||||
reference_required: Choose a reference for this type.
|
||||
menu_required: Choose a menu or a parent entry.
|
||||
parent_cycle: An entry cannot be placed under itself or one of its own entries.
|
||||
parent_menu: The parent entry belongs to another menu. Choose a parent from the selected menu, or change the menu.
|
||||
|
||||
@@ -43,6 +43,7 @@ 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.
|
||||
reference_required: Wybierz odnośnik dla tego typu.
|
||||
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.
|
||||
parent_menu: Pozycja nadrzędna należy do innego menu. Wybierz pozycję nadrzędną z wybranego menu albo zmień menu.
|
||||
|
||||
@@ -330,6 +330,7 @@ func TestMenuAdminSmoke(t *testing.T) {
|
||||
// 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": "acme-page", "title": "Unset"}, "reference", "golem15.menu::lang.errors.reference_required")
|
||||
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", "")
|
||||
|
||||
Reference in New Issue
Block a user