diff --git a/README.md b/README.md index 2d423c5..4fee762 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/controllers/menuentries.go b/controllers/menuentries.go index 74eb363..9865fef 100644 --- a/controllers/menuentries.go +++ b/controllers/menuentries.go @@ -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)) { diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 771d4ef..bc62c61 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 35b8f56..fdbfc11 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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. diff --git a/menu_smoke_test.go b/menu_smoke_test.go index af9e286..61893a7 100644 --- a/menu_smoke_test.go +++ b/menu_smoke_test.go @@ -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", "")