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:
208
README.md
Normal file
208
README.md
Normal 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
227
classes/tree.go
Normal 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
101
controllers/api/menus.go
Normal 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
45
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
|
||||
|
||||
22
go.sum
22
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=
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
34
plugin.go
34
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{})
|
||||
}
|
||||
|
||||
21
routes.go
Normal file
21
routes.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user