Files
summercms/modules/cabana/README.md

159 lines
13 KiB
Markdown

# cabana
Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA.
`import "git.golem15.com/golem15/summercms/modules/cabana"`
## Overview
`cabana` is the SummerCMS counterpart of WinterCMS's backend: controllers with the List, Form and Relation behaviors, their `config_list.yaml`, `config_form.yaml`, `config_filter.yaml` and `config_relation.yaml` files, the model `columns.yaml` and `fields.yaml`, backend users, roles and permissions, settings models and backend navigation. Plugins declare admin controllers through the [pact](../pact/README.md) capability interfaces and embed their YAML; `cabana.Activate` compiles all of it once at boot, fails fast on any schema error, and returns the admin routes that [surf](../surf/README.md) mounts under the admin prefix (`backend.uri`, default `/backend`). The JSON API lives under `<prefix>/api/v1`, and every other path under the prefix serves the admin SPA from [boardwalk](../boardwalk/README.md).
## Features
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md), and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates and for linking and unlinking. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
- 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.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`.
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
### Admin API routes
All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.controller` maps to the path `/{vendor}/{plugin}/{controller}`.
| Method and path | Purpose |
|-----------------|---------|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token; return the signed-in administrator. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record. |
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record. |
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction. |
| 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.
## Usage
A plugin exposes an admin controller and embeds its YAML. `summer make:admin-controller` scaffolds the controller type and its four YAML files:
```go
package blog
import (
"embed"
"io/fs"
"git.golem15.com/golem15/summercms/modules/pact"
)
// adminFS holds controllers/post/config_list.yaml, controllers/post/config_form.yaml,
// models/post/columns.yaml and models/post/fields.yaml.
//
//go:embed controllers models
var adminFS embed.FS
type Post struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"column:title"`
}
func (Post) TableName() string { return "acme_blog_posts" }
type postAdmin struct{}
func (postAdmin) ID() string { return "acme.blog.post" }
func (postAdmin) ModelName() string { return "Post" } // must equal modelClass in the YAML
func (postAdmin) ConfigDir() string { return "controllers/post" }
func (postAdmin) NewRecord() any { return &Post{} } // pact.AdminRecordSource
// Plugin also implements party.Plugin (ID, Requires, Register, Boot).
type Plugin struct{}
func (p *Plugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{postAdmin{}}
}
func (p *Plugin) AdminFS() fs.FS { return adminFS }
```
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
## API reference
| Identifier | Description |
|------------|-------------|
| `cabana.Activate` | Compiles every plugin's admin controllers, settings, navigation and permissions and returns the admin `cabana.Routes`, or nil when there are no controllers. |
| `cabana.Routes` | Guard middleware, mount function and normalized prefix of the admin API and SPA. |
| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. |
| `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. |
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. |
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. |
## Configuration
| Key | Default | Effect |
|-----|---------|--------|
| `admin.jwt.secret` | none | HMAC secret for admin tokens. Required as soon as any plugin registers an admin controller; boot fails without it. Set it through `SUMMER_ADMIN__JWT__SECRET` rather than a committed file. |
| `admin.jwt.ttl` | `60` | Access token lifetime in minutes. |
| `admin.jwt.refresh_ttl` | `20160` | Refresh window in minutes (14 days); also the lifetime of the admin cookie. |
| `admin.jwt.blacklist_grace` | `0` | Seconds a token stays valid after it has been refreshed, for requests already in flight. |
| `admin.password.bcrypt_cost` | `10` | Bcrypt cost for administrator passwords; values outside 4 to 31 fall back to 10. |
| `admin.login.max_attempts` | `5` | Login attempts allowed per throttle window. |
| `admin.login.decay_minutes` | `1` | Length of the login throttle window in minutes. |
| `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. |
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
| `app.url` | empty | Base URL used for the token issuer. |
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.
## CLI commands
Both commands are added to every application binary by the generated `main` and open the database themselves when the application has not.
| Command | Arguments and flags | Effect |
|---------|---------------------|--------|
| `admin:create` | `--email`, `--password` (both required), `--login` (defaults to the lower-cased email), `--role <code>`, `--superuser` | Creates an activated backend administrator. |
| `admin:reset-password` | `<identifier>` (login or email), `--password` | Sets a new password and revokes every token issued before the reset. |
```sh
./bin/acme admin:create --email admin@example.com --password '<secret>' --superuser
./bin/acme admin:reset-password admin@example.com --password '<secret>'
```
## 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`, `database/sql`, `encoding/json`, `errors`, `fmt`, `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
```sh
go test ./modules/cabana/...
```
The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`.