feat(10.1-01): render header and form partials into an allowlisted node tree
- fields.yaml type: partial with a bare path name and config_list.yaml
headerPartial resolve to {ConfigDir}/_{name}.htm, parsed at boot; the
controller must implement pact.AdminPartialData
- html/template render against a curated view model, then x/net/html
ParseFragment and a tag, attribute and URL allowlist with 64 KiB, 2000-node
and depth-32 caps; the model type and trusted template types are refused
- GET .../partials/{name} with optional ?id= loaded through the form scope
- golang.org/x/net becomes a direct requirement (D-18), no new module
This commit is contained in:
@@ -17,6 +17,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys with scalar values. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
||||
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. `cabana.Allows` implements the permission check: superusers pass, and grants ending in `.*` match by prefix.
|
||||
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend`, login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
|
||||
@@ -41,12 +42,31 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. |
|
||||
| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. |
|
||||
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
|
||||
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
|
||||
| GET `.../fields/{field}/options`, GET `.../filters/{scope}/options` | Choices for a relation field and for a model-backed list filter. |
|
||||
| GET `.../{id}/relations/{name}`, GET `.../{id}/relations/{name}/candidates` | Linked records and link candidates of a relation manager. |
|
||||
| POST `.../{id}/relations/{name}/link`, POST `.../{id}/relations/{name}/unlink` | Link and unlink related records. |
|
||||
|
||||
Every path under the prefix that no API route matches is served by the admin SPA; unmatched API paths return the `not_found` error envelope instead.
|
||||
|
||||
### Partials
|
||||
|
||||
A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` and `path: stats` both resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans "<key>"` translates a phrase key in the request locale. `record` is nil for a header partial and for a form partial on the create form; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself.
|
||||
|
||||
The view model must be a curated struct built for the template. cabana refuses a value of the controller's own model type (or a pointer to or a collection of it) and a type that carries `html/template`'s pre-escaped content types, so escaping stays on for every record value. The rendered output is parsed with `golang.org/x/net/html` and walked through an allowlist:
|
||||
|
||||
- Elements: `div span p strong em b i u s small mark code pre br hr ul ol li dl dt dd h2 h3 h4 h5 h6 table thead tbody tfoot tr th td caption section header footer figure figcaption blockquote q abbr time data meter progress sup sub a img`. Any other element is unwrapped (its children stay); `script style template iframe object embed noscript textarea title xmp svg math form input button select link meta base` are removed with everything inside them, and comments disappear.
|
||||
- Attributes: `class title lang dir role`, `aria-*` and `data-*` everywhere; `a[href]` and `img[src]` only for a same-origin path starting with exactly one `/` (links may also use `#fragment`); `img[alt width height]`, `td`/`th[colspan rowspan scope]`, `time[datetime]`, `data[value]`, `meter[value min max low high optimum]`, `progress[value max]`. `id`, `style` and every event handler are dropped.
|
||||
- Caps: 64 KiB of template output, 2000 nodes and a depth of 32. Exceeding one, a view model error or a refused view model is logged with the controller and partial name and answered with the generic 500 body, never a truncated tree.
|
||||
|
||||
A statistics strip above a list, for example:
|
||||
|
||||
```html
|
||||
<dl class="summer-stats">
|
||||
<div class="summer-stat"><dt class="summer-stat__label">{{ trans "acme.blog::lang.stats.posts" }}</dt><dd class="summer-stat__value">{{ .Data.Total }}</dd></div>
|
||||
</dl>
|
||||
```
|
||||
|
||||
### Controller assets
|
||||
|
||||
`GET <prefix>/assets/{vendor}/{plugin}/{file...}` serves the files controllers declare through `pact.AdminClientAssets`. A plugin file `assets/js/lookup.js` of plugin `acme.blog` is served at `<prefix>/assets/acme/blog/js/lookup.js`, and the schemas list it as `<prefix>/assets/acme/blog/js/lookup.js?v=<first 12 hex characters of its sha256>`. The route is public, like the SPA shell, and serves only the exact files declared at boot, never the plugin's embedded tree: YAML and templates are not reachable, and any other path falls through to the SPA, which also serves its own build assets under `<prefix>/assets/`. Each response carries an explicit JavaScript or CSS `Content-Type`, `X-Content-Type-Options: nosniff`, the admin Content-Security-Policy (`script-src 'self'`), `Cross-Origin-Resource-Policy: same-origin`, `Cache-Control: no-cache` and a sha256 `ETag`, so conditional requests answer 304 and a rebuilt binary is picked up at once.
|
||||
@@ -123,6 +143,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.AdminActionResult` | Answer of an action route: the localized `message` and the filtered `fill` object. |
|
||||
| `cabana.ControllerAssets` | The `assets` object of list and form schemas: `scripts` and `styles` URL lists, always arrays. |
|
||||
| `cabana.ToolbarAction` | One registered toolbar button in a list schema's `toolbarActions`: action name and localized label. |
|
||||
| `cabana.PartialView` / `cabana.PartialNode` | A rendered partial: a list of nodes, each an allowlisted element (`tag`, `attrs`, `children`) or a text node (`text`). |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -158,8 +179,8 @@ Both commands are added to every application binary by the generated `main` and
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md).
|
||||
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package).
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`.
|
||||
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
|
||||
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`.
|
||||
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package.
|
||||
|
||||
## Testing
|
||||
|
||||
Reference in New Issue
Block a user