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
This commit is contained in:
2026-10-12 17:51:27 +02:00
parent e044bc7b7e
commit 33a65ad802
8 changed files with 869 additions and 5 deletions

208
README.md Normal file
View File

@@ -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 <database> < scripts/winter-export-menus.sql > menus.jsonl
mysql -N -B --raw <database> < 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.

227
classes/tree.go Normal file
View File

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

101
controllers/api/menus.go Normal file
View File

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

45
go.mod
View File

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

22
go.sum
View File

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

View File

@@ -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 "<nil>"
}
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())
}
}

View File

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

21
routes.go Normal file
View File

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