From 33a65ad8027dd5f149ffe2f0d7e3c3b369008d64 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 12 Oct 2026 17:51:27 +0200 Subject: [PATCH] feat(17-04): public menu API with registry resolution, throttle bucket and README - GET /_menu/api/v1/menus/{code}?locale=xx, anonymous, menu-public-api bucket (120/min per client IP) - classes.BuildTree: published entries nested by parent, sort_order then id, scheme-checked URLs - registry entries resolved through signpost with url_pattern as the item override and the locale's alternate - list types keep stored URL and title, generated children follow stored ones; unresolvable entries left out - TestMenuAPISmoke; README with API, CORS paths, import workflow and Winter adaptations - go mod tidy records the test dependencies --- README.md | 208 +++++++++++++++++++++++++++++++++++ classes/tree.go | 227 +++++++++++++++++++++++++++++++++++++++ controllers/api/menus.go | 101 +++++++++++++++++ go.mod | 45 +++++++- go.sum | 22 ++++ menu_smoke_test.go | 216 +++++++++++++++++++++++++++++++++++++ plugin.go | 34 +++++- routes.go | 21 ++++ 8 files changed, 869 insertions(+), 5 deletions(-) create mode 100644 README.md create mode 100644 classes/tree.go create mode 100644 controllers/api/menus.go create mode 100644 routes.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cbae82 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# golem15.menu + +The compiled menu plugin for SummerCMS applications: 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 and served to frontends as a published JSON tree. + +```go +import _ "git.golem15.com/golem15/sm-menu-plugin" +``` + +Module `git.golem15.com/golem15/sm-menu-plugin`, package `menu`, plugin ID `golem15.menu`. + +## Overview + +The plugin is a compiled SummerCMS plugin, a port of the WinterCMS Golem15.Menu plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. + +It requires no other plugin. Content types (blog posts, categories, pages) come from the framework's menu item type registry (`signpost`): any plugin that registers menu item types offers them in the entry form and resolves them in the API, without the menu plugin importing it. `MorphName` is the PHP class string (`Golem15\Menu\Models\Menu`, `Golem15\Menu\Models\MenuEntry`). + +## Features + +- **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, 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. +- **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). +- **Import.** `menu:import-winter` copies a WinterCMS site's menus and entries from the JSON Lines the export SQL files produce. See [CLI commands](#cli-commands). +- **Phrasebooks.** `lang/en/lang.yaml` and `lang/pl/lang.yaml` keep the PHP key names. + +## Usage + +### Public menu API + +``` +GET /_menu/api/v1/menus/{code}?locale=en +``` + +Anonymous; the route group never attaches the backend middleware. `code` is the menu code (letters, digits, `-` and `_`). `locale` is optional and picks the matching alternate of resolved registry items. + +```json +{ + "data": { + "code": "main", + "name": "Main", + "css_class": "navbar", + "items": [ + { + "id": 1, + "title": "Home", + "type": "url", + "url": "/", + "target": "_self", + "css_class": null, + "icon": null, + "code": "home", + "items": [] + }, + { + "id": 4, + "title": "Topics", + "type": "acme-all-categories", + "url": "/topics", + "target": null, + "css_class": "dropdown", + "icon": null, + "code": null, + "items": [ + { + "id": 5, + "title": "Archive", + "type": "url", + "url": "https://archive.example.com", + "target": "_blank", + "css_class": null, + "icon": null, + "code": null, + "items": [] + }, + { + "id": null, + "title": "News", + "type": "acme-all-categories", + "url": "/en/news", + "target": null, + "css_class": null, + "icon": null, + "code": null, + "items": [] + } + ] + }, + { + "id": 6, + "title": "", + "type": "divider", + "url": null, + "target": null, + "css_class": null, + "icon": null, + "code": null, + "items": [] + } + ] + } +} +``` + +Rules: + +- Only an active menu is served; an unknown, inactive or malformed code is `404 {"error":"Menu not found"}`. +- Only published entries are served. An unpublished entry hides its whole subtree. (The PHP component did not filter `is_published`; the API does, as a deliberate change.) +- Within each parent, entries are ordered by `sort_order`, then `id`. +- `id` is the entry id; `null` for a child a registry type generated. `type` is the stored type; a generated child carries its parent's registry type. +- `url` entries serve their stored URL when `classes.AllowedURL` accepts it, else `null`. Dividers serve `null`. +- Registry entries are resolved at request time through the registry with the entry's reference, its `url_pattern` as the item override (else the host's pattern, else the type's default) and the type's `Nesting`: + - a **single** type (the resolved item has a URL) serves the URL of the alternate whose locale is `?locale`, else the resolved URL; the title is the stored title when not empty, else the resolved title for that locale; + - a **list** type (the resolved item has no URL) keeps the entry's stored URL (scheme-checked) and title; + - resolved sub-items follow the entry's stored children in `items`, each with the alternate's title and URL for `?locale` when there is one; their `target`, `css_class`, `icon` and `code` are `null`. +- An entry whose type no plugin registers, or whose reference no longer resolves, is left out with its subtree and logged. +- `target` is `_self`, `_blank` or `null`; `css_class`, `icon` and `code` are `null` when empty. `items` is always an array. + +The tree is built by `classes.BuildTree` from the entries `api.Show` loads. + +### Throttling and CORS + +The API is throttled by the surf bucket `menu-public-api` (`menu.BucketPublicAPI`): 120 requests a minute per client IP, read behind the host's trusted proxies. The 429 body is surf's `{"message":"Too Many Attempts."}`. + +The plugin sends no CORS headers. A host whose frontend runs on another origin adds the path to `http.cors.paths` in `config/http.yaml`: + +```yaml +cors: + paths: ["_menu/api/*"] + allowed_origins: ["https://www.example.com"] + allowed_methods: ["GET"] +``` + +### Winter import + +1. On the WinterCMS database, check the table names and entry types named in the headers of `scripts/winter-export-menus.sql` and `scripts/winter-export-entries.sql`, then run both read-only exports: + + ```sh + mysql -N -B --raw < scripts/winter-export-menus.sql > menus.jsonl + mysql -N -B --raw < scripts/winter-export-entries.sql > menu-entries.jsonl + ``` + +2. In the application, after the plugins that register the target types are installed: + + ```sh + ./bin/acme menu:import-winter --menus menus.jsonl --entries menu-entries.jsonl --dry-run + ./bin/acme menu:import-winter --menus menus.jsonl --entries menu-entries.jsonl + ``` + +## API reference + +| Identifier | Description | +|------------|-------------| +| `menu.Plugin` | The `golem15.menu` plugin: migrations, models, phrasebooks, admin controllers, permissions, navigation, the `menu:import-winter` command, the public route and its throttle bucket. | +| `menu.BucketPublicAPI` | `menu-public-api`, the throttle bucket of the public API. | +| `models.Menu` | A menu on `golem15_menu_menus`; rules require a name and a unique code. | +| `models.MenuEntry` | An entry on `golem15_menu_menu_entries` with the PHP save rules (`BeforeSave`) and the menu filter scope. | +| `models.TypeURL`, `models.TypeDivider` | The built-in entry types `url` and `divider`. | +| `models.Builtin` | Reports whether a type is a built-in. | +| `models.Text` | The value of an optional text column, `""` when null. | +| `classes.AllowedURL` | The URL scheme allowlist used on save and in the API. | +| `classes.AllowedTarget` | Accepts `_self`, `_blank` or empty (`classes.TargetSelf`, `classes.TargetBlank`). | +| `classes.BuildTree` | Builds the public tree from a menu's published entries (`classes.TreeOptions`: registry, locale, logger). | +| `classes.Item` | One item of the public tree. | +| `classes.Resolver` | The registry methods `classes.BuildTree` reads; `*signpost.Registry` implements it. | +| `api.Show` | The handler of `GET /_menu/api/v1/menus/{code}`. | +| `api.Menu` | The data object of the public API. | +| `console.ImportWinter` | The `menu:import-winter` command. | +| `console.ImportWinterFiles` | The import itself, for tests and other callers (`console.WinterOptions`, `console.WinterReport`). | +| `console.DefaultTypeMap` | The Winter type map: `link` to `url`, `blog_categories` to `all-journal-categories`. | +| `controllers.MenusControllerID`, `controllers.MenuEntriesControllerID` | The admin controller ids `golem15.menu.menus` and `golem15.menu.menuentries`. | +| `controllers.PermissionManageMenu` | The permission `golem15.menu.manage_menu`. | + +## CLI commands + +`menu:import-winter --menus FILE --entries FILE [--map old=new ...] [--force] [--dry-run]` + +- Reads one JSON object per line from each file. Ids, `menu_id`, `parent_id`, the nest columns and `sort_order` are kept; both id sequences are moved past the copied ids with `setval`. +- Types are mapped by `console.DefaultTypeMap` (`link` to `url`, `blog_categories` to `all-journal-categories`); repeatable `--map old=new` adds or overrides a mapping, and `--map old=` drops the type. +- Dropped with their subtree, one report line each: `page` entries (Winter.Pages is not ported), entries whose type is neither built-in, registered nor mapped, and registry entries whose reference is not among the type's current references. Every remapped entry is reported too. +- Tables that already hold rows are refused unless `--force`, which replaces them in the same transaction. `--dry-run` prints the report and writes nothing. An empty file writes nothing and reports zero. + +## Winter adaptations + +- Navigation: PHP hung Menus and Menu entries on another plugin's SiteManager side menu, which is not ported; the plugin registers its own top-level Menus item. +- 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); 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. +- API: the public JSON API replaces the PHP component and filters unpublished entries. +- Timestamps are `TIMESTAMPTZ`. + +## Dependencies + +- SummerCMS modules: backpack, bonfire, cabana, lagoon, pact, party, signpost, surf, wire. +- Compiled plugins: none. +- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`. Test-only: `github.com/testcontainers/testcontainers-go` with its Postgres module, `github.com/jackc/pgx/v5`. + +## Testing + +```sh +go vet ./... +go test -short ./... # no Docker +go test ./... # TestMenuAdminSmoke, TestMenuImportSmoke, TestMenuAPISmoke on a Postgres container +``` + +The smoke tests register a fake content plugin with the types `acme-page` (with references), `acme-list` (a list type) and `all-journal-categories`, so the plugin needs no content plugin to test. diff --git a/classes/tree.go b/classes/tree.go new file mode 100644 index 0000000..1a4bfcd --- /dev/null +++ b/classes/tree.go @@ -0,0 +1,227 @@ +package classes + +import ( + "context" + "log/slog" + + "git.golem15.com/golem15/sm-menu-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Item is one entry of the public menu tree (GET /_menu/api/v1/menus/{code}). +// ID is the entry id, nil for a child a registry type generated. Type is the +// stored entry type; a generated child carries its parent's registry type. +// URL is nil for dividers and for links that fail AllowedURL. Target is +// _self, _blank or nil. CSSClass, Icon and Code are nil when empty. Items is +// never nil, so it encodes as []. +type Item struct { + ID *uint `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + URL *string `json:"url"` + Target *string `json:"target"` + CSSClass *string `json:"css_class"` + Icon *string `json:"icon"` + Code *string `json:"code"` + Items []Item `json:"items"` +} + +// Resolver is the part of the framework's menu item type registry the tree +// reads; *signpost.Registry implements it. +type Resolver interface { + Type(key string) (pact.MenuItemType, bool) + Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) +} + +// TreeOptions are the request inputs of BuildTree. +type TreeOptions struct { + // Registry resolves registry entries; nil leaves every registry entry + // out. + Registry Resolver + // Locale picks the alternate of a resolved item whose locale matches; + // "" or a locale without an alternate keeps the resolved URL and title. + Locale string + // Logger records entries left out; nil uses slog.Default. + Logger *slog.Logger +} + +// BuildTree nests a menu's entries under their parents. entries must be the +// published entries of one menu ordered by sort_order then id; that order +// is kept within each parent. An entry whose parent is not among entries +// (unpublished or missing) is unreachable and left out with its subtree. +// +// Built-in types: a url entry serves its stored URL when AllowedURL accepts +// it (else nil), a divider a nil URL. A registry entry is resolved through +// opts.Registry with its reference, its url_pattern as the item override and +// its type's Nesting. When the resolved item has a URL (a single type), the +// entry serves the URL of the alternate for opts.Locale, else the resolved +// URL, and its stored title when not empty, else the resolved title for the +// locale. When the resolved item has no URL (a list type), the entry keeps +// its stored URL (scheme-checked) and title. The resolved sub-items follow +// the stored children in Items as generated children. An entry whose type is +// not registered or whose reference no longer resolves is left out with its +// subtree and logged. +func BuildTree(ctx context.Context, entries []models.MenuEntry, opts TreeOptions) []Item { + b := treeBuilder{ctx: ctx, opts: opts, children: map[uint][]*models.MenuEntry{}, seen: map[uint]bool{}} + if b.ctx == nil { + b.ctx = context.Background() + } + if b.opts.Logger == nil { + b.opts.Logger = slog.Default() + } + var roots []*models.MenuEntry + for i := range entries { + e := &entries[i] + if e.ParentID == nil || *e.ParentID == 0 { + roots = append(roots, e) + continue + } + b.children[*e.ParentID] = append(b.children[*e.ParentID], e) + } + return b.items(roots) +} + +type treeBuilder struct { + ctx context.Context + opts TreeOptions + children map[uint][]*models.MenuEntry + seen map[uint]bool +} + +func (b *treeBuilder) items(entries []*models.MenuEntry) []Item { + out := []Item{} + for _, e := range entries { + if b.seen[e.ID] { + continue + } + b.seen[e.ID] = true + if item, ok := b.item(e); ok { + out = append(out, item) + } + } + return out +} + +func (b *treeBuilder) item(e *models.MenuEntry) (Item, bool) { + id := e.ID + item := Item{ + ID: &id, + Title: models.Text(e.Title), + Type: e.Type, + Target: target(e.Target), + CSSClass: optional(e.CSSClass), + Icon: optional(e.Icon), + Code: optional(e.Code), + } + var generated []Item + switch e.Type { + case models.TypeURL: + item.URL = allowed(models.Text(e.URL)) + case models.TypeDivider: + default: + resolved, ok := b.resolve(e) + if !ok { + return Item{}, false + } + if resolved.URL == "" { + item.URL = allowed(models.Text(e.URL)) + } else { + url, title := pick(resolved, b.opts.Locale) + item.URL = allowed(url) + if item.Title == "" { + item.Title = title + } + } + generated = b.generated(e.Type, resolved.Items) + } + item.Items = append(b.items(b.children[e.ID]), generated...) + return item, true +} + +// resolve resolves a registry entry; false leaves it out. +func (b *treeBuilder) resolve(e *models.MenuEntry) (*pact.ResolvedMenuItem, bool) { + reg := b.opts.Registry + if reg == nil { + b.opts.Logger.Warn("menu: entry left out: no menu item type registry", "entry", e.ID, "type", e.Type) + return nil, false + } + t, ok := reg.Type(e.Type) + if !ok { + b.opts.Logger.Warn("menu: entry left out: menu item type is not registered", "entry", e.ID, "type", e.Type) + return nil, false + } + resolved, err := reg.Resolve(b.ctx, e.Type, models.Text(e.Reference), models.Text(e.URLPattern), t.Nesting, "") + if err != nil { + b.opts.Logger.Warn("menu: entry left out: resolve failed", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference), "error", err) + return nil, false + } + if resolved == nil { + b.opts.Logger.Warn("menu: entry left out: reference does not resolve", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference)) + return nil, false + } + return resolved, true +} + +// generated turns resolved sub-items into children without an id, carrying +// the parent's registry type. +func (b *treeBuilder) generated(typ string, resolved []pact.ResolvedMenuItem) []Item { + out := make([]Item, 0, len(resolved)) + for i := range resolved { + url, title := pick(&resolved[i], b.opts.Locale) + out = append(out, Item{ + Title: title, + Type: typ, + URL: allowed(url), + Items: b.generated(typ, resolved[i].Items), + }) + } + return out +} + +// pick returns the URL and title of the alternate for locale, falling back +// to the resolved URL and title for a missing locale or empty values. +func pick(r *pact.ResolvedMenuItem, locale string) (url, title string) { + url, title = r.URL, r.Title + if locale == "" { + return url, title + } + for _, alt := range r.Alternates { + if alt.Locale != locale { + continue + } + if alt.URL != "" { + url = alt.URL + } + if alt.Title != "" { + title = alt.Title + } + break + } + return url, title +} + +// allowed is url when it is not empty and AllowedURL accepts it, else nil. +func allowed(url string) *string { + if url == "" || !AllowedURL(url) { + return nil + } + return &url +} + +// target is the stored target when it is _self or _blank, else nil. +func target(s *string) *string { + v := models.Text(s) + if v == "" || !AllowedTarget(v) { + return nil + } + return &v +} + +// optional is the stored value, nil when null or empty. +func optional(s *string) *string { + if s == nil || *s == "" { + return nil + } + v := *s + return &v +} diff --git a/controllers/api/menus.go b/controllers/api/menus.go new file mode 100644 index 0000000..3b82644 --- /dev/null +++ b/controllers/api/menus.go @@ -0,0 +1,101 @@ +// Package api holds the menu plugin's public read API. +package api + +import ( + "errors" + "log/slog" + "net/http" + "regexp" + + "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/signpost" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// codePattern is the shape of a menu code the API serves: letters, digits, +// dashes and underscores. Anything else is the JSON 404, so every miss has +// the same body. +var codePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Menu is the data object of GET /_menu/api/v1/menus/{code}. +type Menu struct { + Code string `json:"code"` + Name string `json:"name"` + CSSClass *string `json:"css_class"` + Items []classes.Item `json:"items"` +} + +// Show is GET /_menu/api/v1/menus/{code}?locale=xx, anonymous. It serves the +// active menu with code and its published entries as a tree +// (classes.BuildTree), resolving registry entries through the framework's +// menu item type registry for the requested locale. An unknown or inactive +// menu is 404 {"error":"Menu not found"}. +func Show(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if !codePattern.MatchString(code) { + writeError(w, http.StatusNotFound, "Menu not found") + return + } + db, err := gdb(app) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal server error") + return + } + db = db.WithContext(r.Context()) + var menus []models.Menu + if err := db.Where("code = ? AND is_active = ?", code, true).Order("id ASC").Limit(1).Find(&menus).Error; err != nil { + slog.Default().Error("menu api: load menu", "code", code, "error", err) + writeError(w, http.StatusInternalServerError, "Internal server error") + return + } + if len(menus) == 0 { + writeError(w, http.StatusNotFound, "Menu not found") + return + } + menu := menus[0] + var entries []models.MenuEntry + if err := db.Where("menu_id = ? AND is_published = ?", menu.ID, true). + Order("sort_order ASC NULLS LAST, id ASC").Find(&entries).Error; err != nil { + slog.Default().Error("menu api: load entries", "code", code, "error", err) + writeError(w, http.StatusInternalServerError, "Internal server error") + return + } + var registry classes.Resolver + if reg, ok := app.Lookup[*signpost.Registry](); ok && reg != nil { + registry = reg + } + items := classes.BuildTree(r.Context(), entries, classes.TreeOptions{ + Registry: registry, + Locale: r.URL.Query().Get("locale"), + }) + var css *string + if menu.CSSClass != nil && *menu.CSSClass != "" { + css = menu.CSSClass + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": Menu{ + Code: menu.Code, + Name: menu.Name, + CSSClass: css, + Items: items, + }}) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + wire.WriteJSON(w, status, map[string]any{"error": message}) +} + +func gdb(app *backpack.App) (*gorm.DB, error) { + if app == nil { + return nil, errors.New("menu api: application is not configured") + } + db, ok := app.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, errors.New("menu api: database is not configured") + } + return db, nil +} diff --git a/go.mod b/go.mod index a867945..d09283b 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,35 @@ go 1.27.0 require ( git.golem15.com/golem15/summercms v0.1.23 github.com/go-gormigrate/gormigrate/v2 v2.1.7 + github.com/jackc/pgx/v5 v5.10.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 gorm.io/gorm v1.31.2 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // 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-ole/go-ole v1.3.0 // 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 @@ -28,10 +46,10 @@ require ( 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/klauspost/compress v1.18.6 // 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 @@ -40,21 +58,46 @@ require ( 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/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // 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/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/wneessen/go-mail v0.8.1 // indirect github.com/yuin/goldmark v1.8.6 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // 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 diff --git a/go.sum b/go.sum index 02a9894..9af8e9e 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KM 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/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 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= @@ -85,6 +87,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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= @@ -116,6 +120,7 @@ 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.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 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= @@ -188,6 +193,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S 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/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= 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= @@ -235,6 +242,8 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt 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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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= @@ -247,6 +256,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An 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/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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= @@ -255,10 +266,13 @@ github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGyc 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.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 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.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 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= @@ -311,6 +325,10 @@ 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.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= @@ -344,3 +362,7 @@ 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= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/menu_smoke_test.go b/menu_smoke_test.go index 012f956..3876b9b 100644 --- a/menu_smoke_test.go +++ b/menu_smoke_test.go @@ -544,3 +544,219 @@ func TestMenuImportSmoke(t *testing.T) { t.Fatalf("empty files changed the tables: %d menus, %d entries", m, n) } } + +// publicGet sends an anonymous GET to a public path from remote, or from a +// fresh client address when remote is "". +func (e *menuEnv) publicGet(t *testing.T, path, remote string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Host = "api.example.test" + if remote == "" { + remote = fmt.Sprintf("203.0.113.%d:5000", adminSeq.Add(1)%250+1) + } + req.RemoteAddr = remote + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +// rawEntry is an entry written with raw SQL, past the admin save gate. +type rawEntry struct { + menu uint + parent *uint + sort int + typ string + title string + url *string + reference *string + pattern *string + target *string + css *string + code *string + published bool +} + +func (e *menuEnv) insertMenu(t *testing.T, name, code string, active bool) uint { + t.Helper() + var id uint + if err := e.gdb.Raw(`INSERT INTO golem15_menu_menus (name, code, is_active, css_class, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW()) RETURNING id`, + name, code, active, "nav-"+code).Scan(&id).Error; err != nil || id == 0 { + t.Fatalf("insert menu %s: id=%d err=%v", code, id, err) + } + return id +} + +func (e *menuEnv) insertEntry(t *testing.T, r rawEntry) uint { + t.Helper() + var id uint + if err := e.gdb.Raw(`INSERT INTO golem15_menu_menu_entries (menu_id, parent_id, nest_left, nest_right, nest_depth, sort_order, type, title, url, reference, url_pattern, target, css_class, code, is_published, created_at, updated_at) +VALUES (?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) RETURNING id`, + r.menu, r.parent, r.sort, r.typ, r.title, r.url, r.reference, r.pattern, r.target, r.css, r.code, r.published).Scan(&id).Error; err != nil || id == 0 { + t.Fatalf("insert entry %s: id=%d err=%v", r.title, id, err) + } + return id +} + +func str(s string) *string { return &s } + +// apiItem is one item of the public menu tree. +type apiItem struct { + ID *uint `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + URL *string `json:"url"` + Target *string `json:"target"` + CSSClass *string `json:"css_class"` + Icon *string `json:"icon"` + Code *string `json:"code"` + Items []apiItem `json:"items"` +} + +type menuBody struct { + Data struct { + Code string `json:"code"` + Name string `json:"name"` + CSSClass *string `json:"css_class"` + Items []apiItem `json:"items"` + } `json:"data"` +} + +func (e *menuEnv) getMenu(t *testing.T, path string) menuBody { + t.Helper() + rec := e.publicGet(t, path, "") + var body menuBody + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil { + t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body.String()) + } + return body +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// TestMenuAPISmoke reads a menu through the anonymous public API: 404 JSON +// for unknown and inactive codes, unpublished entries and their subtrees +// absent, order by sort_order then id, a javascript: URL stored with raw SQL +// served as null, a list type keeping its stored title and URL and carrying +// generated children after the stored ones, a url_pattern override, the +// requested locale's alternate, unresolvable entries left out and the +// per-client throttle. +func TestMenuAPISmoke(t *testing.T) { + e := bootMenu(t) + mainID := e.insertMenu(t, "Main", "main", true) + e.insertMenu(t, "Hidden", "hidden", false) + + home := e.insertEntry(t, rawEntry{menu: mainID, sort: 1, typ: "url", title: "Home", url: str("/"), target: str("_blank"), css: str("nav-home"), code: str("home"), published: true}) + bad := e.insertEntry(t, rawEntry{menu: mainID, sort: 2, typ: "url", title: "Bad", url: str("javascript:alert(1)"), target: str("_top"), published: true}) + divider := e.insertEntry(t, rawEntry{menu: mainID, sort: 2, typ: "divider", title: "", published: true}) + about := e.insertEntry(t, rawEntry{menu: mainID, sort: 0, typ: "acme-page", title: "", reference: str("2"), published: true}) + start := e.insertEntry(t, rawEntry{menu: mainID, sort: 5, typ: "acme-page", title: "Start", reference: str("1"), pattern: str("/p/:slug"), published: true}) + list := e.insertEntry(t, rawEntry{menu: mainID, sort: 6, typ: "acme-list", title: "Pages", url: str("/pages"), published: true}) + stored := e.insertEntry(t, rawEntry{menu: mainID, parent: &list, sort: 1, typ: "url", title: "Child", url: str("https://example.test/x"), published: true}) + hidden := e.insertEntry(t, rawEntry{menu: mainID, sort: 7, typ: "url", title: "Hidden", url: str("/hidden"), published: false}) + e.insertEntry(t, rawEntry{menu: mainID, parent: &hidden, sort: 1, typ: "url", title: "Hidden child", url: str("/hidden/child"), published: true}) + e.insertEntry(t, rawEntry{menu: mainID, sort: 8, typ: "acme-page", title: "Gone", reference: str("99"), published: true}) + e.insertEntry(t, rawEntry{menu: mainID, sort: 9, typ: "gone-type", title: "Unregistered", published: true}) + e.insertEntry(t, rawEntry{menu: mainID, parent: &home, sort: 1, typ: "gone-type", title: "Unregistered child", published: true}) + + body := e.getMenu(t, "/_menu/api/v1/menus/main") + if body.Data.Code != "main" || body.Data.Name != "Main" || deref(body.Data.CSSClass) != "nav-main" { + t.Fatalf("menu = %+v", body.Data) + } + items := body.Data.Items + var order []uint + for _, it := range items { + if it.ID == nil { + t.Fatalf("top-level item without id: %+v", it) + } + order = append(order, *it.ID) + } + if want := []uint{about, home, bad, divider, start, list}; fmt.Sprint(order) != fmt.Sprint(want) { + t.Fatalf("order = %v, want %v (%+v)", order, want, items) + } + + // The single type without a stored title takes the resolved title and + // URL of the default pattern. + if it := items[0]; it.Title != "about" || deref(it.URL) != "/pages/about" || it.Type != "acme-page" || len(it.Items) != 0 { + t.Fatalf("about = %+v", it) + } + // A url entry keeps its fields; its unregistered child is left out. + if it := items[1]; it.Title != "Home" || deref(it.URL) != "/" || deref(it.Target) != "_blank" || deref(it.CSSClass) != "nav-home" || deref(it.Code) != "home" || it.Icon != nil || it.Items == nil || len(it.Items) != 0 { + t.Fatalf("home = %+v", it) + } + // A javascript: URL and an unknown target come back null. + if it := items[2]; it.URL != nil || it.Target != nil { + t.Fatalf("bad = %+v", it) + } + if it := items[3]; it.Type != "divider" || it.URL != nil { + t.Fatalf("divider = %+v", it) + } + // The url_pattern override and the stored title. + if it := items[4]; it.Title != "Start" || deref(it.URL) != "/p/home" { + t.Fatalf("start = %+v", it) + } + // The list keeps its stored title and URL; the stored child comes first, + // then the generated children without an id. + it := items[5] + if it.Title != "Pages" || deref(it.URL) != "/pages" || len(it.Items) != 3 { + t.Fatalf("list = %+v", it) + } + if c := it.Items[0]; c.ID == nil || *c.ID != stored || deref(c.URL) != "https://example.test/x" { + t.Fatalf("stored child = %+v", c) + } + for i, slug := range []string{"home", "about"} { + c := it.Items[i+1] + if c.ID != nil || c.Type != "acme-list" || c.Title != slug || deref(c.URL) != "/pages/"+slug || c.Target != nil || c.CSSClass != nil || c.Items == nil { + t.Fatalf("generated child %d = %+v", i, c) + } + } + raw := e.publicGet(t, "/_menu/api/v1/menus/main", "").Body.String() + for _, absent := range []string{"Hidden", "Gone", "Unregistered", "javascript"} { + if strings.Contains(raw, absent) { + t.Fatalf("body holds %q: %s", absent, raw) + } + } + if !strings.Contains(raw, `"id":null`) || !strings.Contains(raw, `"items":[]`) { + t.Fatalf("nulls and empty lists not encoded: %s", raw) + } + + // ?locale=en picks the en alternates; stored titles stay. + items = e.getMenu(t, "/_menu/api/v1/menus/main?locale=en").Data.Items + if it := items[0]; it.Title != "about (en)" || deref(it.URL) != "/en/pages/about" { + t.Fatalf("about en = %+v", it) + } + if it := items[4]; it.Title != "Start" || deref(it.URL) != "/en/p/home" { + t.Fatalf("start en = %+v", it) + } + if c := items[5].Items[1]; c.Title != "home (en)" || deref(c.URL) != "/en/pages/home" { + t.Fatalf("generated child en = %+v", c) + } + // A locale without alternates keeps the resolved values. + if it := e.getMenu(t, "/_menu/api/v1/menus/main?locale=de").Data.Items[0]; it.Title != "about" || deref(it.URL) != "/pages/about" { + t.Fatalf("about de = %+v", it) + } + + // Misses are 404 JSON: an unknown code, an inactive menu, a malformed code. + for _, path := range []string{"/_menu/api/v1/menus/nope", "/_menu/api/v1/menus/hidden", "/_menu/api/v1/menus/main.json"} { + rec := e.publicGet(t, path, "") + if rec.Code != http.StatusNotFound || rec.Body.String() != `{"error":"Menu not found"}` { + t.Errorf("GET %s: %d %s", path, rec.Code, rec.Body.String()) + } + } + + // One client gets 120 requests a minute, then surf's 429 body. + const client = "192.0.2.77:6000" + for i := 0; i < 120; i++ { + if rec := e.publicGet(t, "/_menu/api/v1/menus/nope", client); rec.Code != http.StatusNotFound { + t.Fatalf("request %d: %d %s", i+1, rec.Code, rec.Body.String()) + } + } + rec := e.publicGet(t, "/_menu/api/v1/menus/main", client) + if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "Too Many Attempts.") { + t.Fatalf("121st request: %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/plugin.go b/plugin.go index 9720b49..bc0a2f5 100644 --- a/plugin.go +++ b/plugin.go @@ -5,6 +5,9 @@ package menu import ( + "net/http" + "time" + "git.golem15.com/golem15/sm-menu-plugin/console" "git.golem15.com/golem15/sm-menu-plugin/models" "git.golem15.com/golem15/sm-menu-plugin/updates" @@ -12,14 +15,17 @@ import ( "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" "github.com/go-gormigrate/gormigrate/v2" ) var ( - _ party.Plugin = (*Plugin)(nil) - _ pact.HasMigrations = (*Plugin)(nil) - _ pact.HasModels = (*Plugin)(nil) - _ pact.HasCommands = (*Plugin)(nil) + _ party.Plugin = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) ) // Plugin is the golem15.menu plugin. @@ -52,6 +58,26 @@ func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } func (p *Plugin) Models() []any { return models.All() } +// Buckets registers menu-public-api: 120 requests a minute per client IP +// (behind the configured trusted proxies). 429 bodies are surf's +// {"message":"Too Many Attempts."}. +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + return map[string]surf.Bucket{ + "menu-public-api": { + Name: BucketPublicAPI, + Max: 120, + Decay: time.Minute, + Key: func(r *http.Request) string { + return "menu-public|" + surf.ClientIP(r, trusted) + }, + }, + } +} + func init() { party.Register(&Plugin{}) } diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..42961d6 --- /dev/null +++ b/routes.go @@ -0,0 +1,21 @@ +package menu + +import ( + apicontrollers "git.golem15.com/golem15/sm-menu-plugin/controllers/api" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// BucketPublicAPI is the throttle bucket of the public menu API. +const BucketPublicAPI = "menu-public-api" + +// Routes mounts the anonymous read API under /_menu/api/v1 (D-16). The group +// never attaches the backend middleware: anonymous callers would get 401. +// The menu code is checked in the handler, so every miss is the JSON 404. +// CORS is the host's: it adds _menu/api/* to cors.paths. +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_menu/api/v1", surf.Use("throttle:"+BucketPublicAPI), func(g pact.Router) { + g.Get("/menus/{code}", apicontrollers.Show(p.app)) + }) + return nil +}