- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
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 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 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.
Features
- Boot-time schema compilation:
cabana.CompileListandcabana.CompileFormread a controller's YAML from the plugin's embedded tree, check thatmodelClassmatches 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, 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'spact.ListExtendQueryandpact.FormExtendQueryhooks.cabana.ExecuteListapplies 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), andcabana.ProjectWritableFieldsdrops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through lagoon; a value that does not fit its column (alagoon.FillTypeError, such as a fraction for an integer field) is a 422validation_failedon that field, and the form lifecycle hooks declared inpact(before and after create, update and delete) run around each write. - Relations:
type: relationform 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 bycabana.RelationServicefor listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (cabana.RelationBelongsToMany, the kind of a contract that leavesKindempty) with a pivot model, or a hasMany (cabana.RelationHasMany) whoseForeignKeycolumn on the related model points at the parent. A relation's child form comes frommanage.forminconfig_relation.yaml(or a top-levelform, WinterCMS's fallback), its read-only preview fromview.form(same fallback), and a belongsToMany's pivot columns are edited throughpivot.form(WinterCMSpivot[x]field names compile tox; pivot keys, timestamps and hook columns are refused); WinterCMS$/<vendor>/<plugin>/...paths resolve inside the same plugin only. A relation form accepts the scalar field types plusdatepickerandfileupload;relation,relation-manager,widgetandpartialfail boot. The view panel'stoolbarButtons(create,update,delete,link,unlink) are the capability of their routes. A relation'smessagesblock takes the link keys (link,linkHint,candidateSearch,linked,unlinkSelected,unlinkConfirm,unlinked,empty) and the child and pivot modal keys (create,createTitle,updateTitle,previewTitle,created,updated,deleteSelected,deleteConfirm,deleteOneConfirm,deleted,pivotTitle,pivotSaved,editPivot,createSubmit,updateSubmit,pivotSubmit,linkSubmit), each defaulting tobackend::lang.messages.relation.*. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. - Form widgets and controller actions: a
type: widgetfield infields.yamlnames a plugin custom element (widget:, which must start with the owning plugin's{vendor}-{plugin}-prefix), the controller action it runs (action:, registered throughpact.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 whose values encode as JSON scalars (a value whoseMarshalJSONwrites an array or object, NaN or an infinity is dropped). Like the list schema'stoolbarActions, the form schema carries a widget field only when the requesting administrator may run its action. The field'scontextapplies to the action route as it does on save: a request withoutrecord_idis the create form's and one with it the update form's, and a widget its context hides on that form answers 404. 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.AdminClientAssetsnames JS (.js,.mjs) and CSS files under its plugin'sassets/directory, Winter'saddJs/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 underassetsas same-origin URLs with a?v=content hash. A form with a widget needs at least one JS file. - Toolbar actions:
toolbar.buttonsinconfig_list.yamllists the built-increateanddeletenext to names the controller registers throughpact.HasAdminActions. Registered actions share one namespace with widget actions,createanddeleteare reserved, and each toolbar action needs a label. The list schema'stoolbarActionscarries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Server-rendered partials:
headerPartial: <name>inconfig_list.yaml(a strip above the list) andtype: partialwithpath: <name>infields.yamlrender the template{ConfigDir}/_<name>.htmwithhtml/templateagainst a view model from the controller'spact.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 withoutpact.AdminPartialDatafails boot. - Date pickers: a
type: datepickerfield infields.yamledits a date (mode: date, alagoon.Datecolumn), a date and time (mode: datetime, the default, atime.Timecolumn stored in UTC) or a time of day (mode: time, alagoon.TimeOfDaycolumn); pointers to the three types make the value optional. It accepts WinterCMS'smode,format(a PHPdate()format, served also asdisplayFormatin the SPA's tokens),minDate,maxDate,yearRange,firstDay,twelveHourandignoreTimezone; any other key, a format letter with no equivalent, bounds onmode: time,ignoreTimezoneoutsidemode: datetimeor a column whose Go type does not match the mode fails boot. The save rechecksminDateandmaxDateon the calendar date and answers 422 on the field. List columns taketype: dateandtype: timefor these columns, and a struct column that implementssql.Scannerordriver.Valueris never taken for a relation. - File uploads: a
type: fileuploadfield infields.yamledits an attachOne or attachMany relation the record model declares throughattach.HasRelations(itsAttachRelationsmethod) next toattach.Owner. The field accepts WinterCMS'smode(imageorfile),fileTypes,mimeTypes,maxFilesize(megabytes),maxFiles(attachMany only),imageWidth,imageHeight,thumbOptions(onlymode:auto,exact,croporfit),useCaptionandprompt; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or amaxFilesizeabovehttp.body_limits.upload_bytesfails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in theX-Session-Keyheader (cabana.SessionKeyHeader) with every file call and with the save, the server keeps the pending work indeferred_bindingsagainst that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller ofhttp.body_limits.upload_bytesandmaxFilesizeplus 64 KiB and answers 413payload_too_largepast it; the size, type and image checks run on the server (throughattach.Store) and answer 422 on the field. A file list (cabana.FileItem) carriesurlandthumb_urlonly for a public relation. - Singleton settings screens declared with
pact.HasSettings, read and saved bycabana.SettingsService. - Backend navigation (
pact.HasNavigation) and permissions (pact.HasPermissions), filtered per user bycabana.Registry.Metadata. An administrator's ownbackend_users.permissionsare merged over the role's as in Winter (a-1denies a code the role grants).cabana.Allowsimplements the permission check with Winter'shasAnyAccesssemantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in.*covers every code with that prefix, and a required code ending in.*is met by any grant under it). - Admin authentication against WinterCMS's
backend_usersandbackend_user_rolestables (cabana.BackendUser,cabana.BackendUserRole,cabana.BackendUsers): a JWT guard registered in bouncer asbackend(a guard another plugin already registered under that name failscabana.Activate), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sendsX-Requested-With: XMLHttpRequestand receives the token in the HttpOnly, SameSite=Strict cookie named bycabana.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.WriteErrorandcabana.WriteErrorDetails, typed for documentation ascabana.Envelope,cabana.ListEnvelope,cabana.RecordEnvelopeandcabana.ErrorEnvelope. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body. - OpenAPI documentation:
cabana.AdminList,cabana.AdminCreateand the otherAdmin*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, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; 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 (needs a form and create in toolbar.buttons). |
GET, PUT and DELETE /{vendor}/{plugin}/{controller}/{id} |
Show, update and delete a record (update and delete need a form). |
POST /{vendor}/{plugin}/{controller}/bulk-delete |
Delete a set of records in one transaction; needs delete in toolbar.buttons. |
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. A link body may carry pivot values for one id on a relation with a pivot form. On a hasMany, link sets and unlink clears the related record's foreign key. |
POST .../{id}/relations/{name}/records |
Create a related record through the relation's manage form and attach it to the record (a hasMany foreign key is set by the server; a belongsToMany gets its pivot row). Needs create in the view panel's toolbarButtons (403 otherwise). |
GET and PUT .../{id}/relations/{name}/records/{child} |
Show a child (needs update or a view form) and save it through the manage form (needs update). |
POST .../{id}/relations/{name}/delete |
{ids}: delete children through their model (needs delete). A belongsToMany record loses this record's pivot row first. All or nothing. |
GET and PUT .../{id}/relations/{name}/pivot/{child} |
Read and save the pivot form values of one link (needs a pivot form and link or update). |
GET and POST .../{id}/relations/{name}/records/{child}/files/{field}, PUT and DELETE .../files/{field}/{file}, POST .../files/{field}/reorder, GET .../files/{field}/{file}/download and /thumb |
The file routes of a fileupload field in the relation's manage form, keyed by X-Child-Session-Key; {child} 0 is the child being created. |
GET .../{id}/files/{field} |
The files of a type: fileupload field: attached files minus the session's pending removals, plus its pending uploads, in sort_order. {id} 0 is the record being created and needs X-Session-Key. |
POST .../{id}/files/{field} |
Upload one multipart file_data part; it is bound to the X-Session-Key session (required) and attached by the record's next save. Answers 201 with the pending cabana.FileItem. |
PUT .../{id}/files/{field}/{file} |
Save a file's title and description at once; the field must declare useCaption (403 otherwise). |
DELETE .../{id}/files/{field}/{file} |
Remove a file: an attached file is removed by the next save with the same X-Session-Key (required), a pending upload is deleted at once. |
POST .../{id}/files/{field}/reorder |
{ids} in the new order, exactly the field's visible files; they take the existing sort_order values at once. attachMany only (403 otherwise). |
GET .../{id}/files/{field}/{file}/download, GET .../{id}/files/{field}/{file}/thumb |
Stream a protected file or its preview thumbnail; see the protected files note below. |
Every relation route accepts {id} 0 for the record being created when the relation is deferrable (a belongsToMany, or a hasMany with a nullable foreign key), the request carries X-Session-Key and the controller declares create: children created, linked, unlinked and deleted there are held against the key and applied by the record's first save, in its transaction, and an ineligible link answers 422 on the relation-manager field. A deferrable relation that declares create needs its related model in some plugin's pact.HasModels list, so deferred:purge can delete abandoned children; boot fails otherwise. Existing belongsToMany managers without context: update therefore appear on create screens.
Every relation child and pivot route loads the record through pact.FormExtendQuery and finds the child with one query that carries the record: a hasMany child by its foreign key, a belongsToMany record by a pivot row. A child of another record is not_found, never forbidden.
Every file route resolves its file with one query scoped to the record (loaded through pact.FormExtendQuery) or to the administrator's own pending uploads: a file of another record is not_found, never forbidden. The save applies the session's file work in its transaction: a pending upload on an attachOne field replaces the file attached before, a deferred removal deletes the attached file, and the blobs of deleted files are removed after commit. After that the save checks maxFiles and a required fileupload field (at least one file) and answers 422 on the field when either fails; the pending work stays for the next attempt.
Protected files (a relation with Public false) never get a public URL. The download and thumb routes serve only is_public false files, with X-Content-Type-Options: nosniff, Cache-Control: private, no-store and Content-Security-Policy: default-src 'none'; sandbox; JPEG, PNG, GIF and WebP are served inline with their type, every other type as an application/octet-stream attachment. The thumb route answers 404 for a file that is not one of those images.
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 walks its type through pointers, slices, arrays, maps, struct fields and the results of its exported methods (templates call methods), and the values held in interface-typed members such as map[string]any. It refuses the controller's own model type, any other GORM model (a struct with a TableName method, a gorm struct tag, gorm.Model or gorm.DeletedAt) and html/template's pre-escaped content types anywhere in that structure, so escaping stays on for every record value. A method that returns an interface is not called, so its run-time result is not checked. 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 baseare removed with everything inside them, and comments disappear. - Attributes:
class title lang dir role,aria-*anddata-*everywhere;a[href]andimg[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,styleand 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:
<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>
Partial style kit and plugin CSS variables
The admin SPA ships a small set of stable CSS classes that partial templates may use through the allowlisted class attribute, so server-rendered content looks native without any plugin CSS:
| Class | Use |
|---|---|
summer-partial |
Set by the SPA on every partial's root: 14px/1.5 body text, long words and URLs wrap, p/ul/ol spaced 8px apart, links underlined with the focus ring. |
summer-stats |
A card strip (surface background, border, 16px radius, card shadow, 16px 20px padding) whose items wrap onto more rows with a 32px column gap and an 8px row gap. Safe on a <dl>. |
summer-stat |
One item of the strip: the value is shown above the label while <dt> stays first in the DOM. |
summer-stat__label |
The item label: 13px, muted, wraps. |
summer-stat__value |
The item value: 20px, weight 600, tabular numbers. |
Use <dl class="summer-stats"> with one <div class="summer-stat"> per item holding a <dt class="summer-stat__label"> and a <dd class="summer-stat__value">, as in the example above.
Plugin CSS (declared through pact.AdminClientAssets) and any widget shadow DOM may read only these public variables. They inherit into shadow roots and switch automatically in dark mode: --c-bg, --c-surface, --c-subtle, --c-border, --c-border-strong, --c-text, --c-muted, --c-placeholder, --c-primary, --c-on-primary, --c-danger, --c-danger-soft, --c-hover, --c-sel, --c-skel, --c-ring. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes: the SPA build purges every utility it does not use itself. A controller's stylesheets are disabled while another controller's list or form is open.
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.
Usage
A plugin exposes an admin controller and embeds its YAML. summer make:admin-controller scaffolds the controller type and its four YAML files:
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 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, child create, show, update and delete (CreateChild, ShowChild, UpdateChild, DeleteChildren) and pivot values (ShowPivot, UpdatePivot); its SessionKey makes record id 0 the record being created in that session. |
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.RelationBelongsToMany / cabana.RelationHasMany |
The two RelationContract.Kind values; an empty Kind is a belongsToMany. |
cabana.AdminRelationChildCreate / cabana.AdminRelationChildShow / cabana.AdminRelationChildUpdate / cabana.AdminRelationChildDelete / cabana.AdminRelationPivotShow / cabana.AdminRelationPivotUpdate |
Swag annotations of the relation child and pivot routes. |
cabana.RelationMutationInput |
Body of the link and unlink routes: IDs and, for a link of one id, Pivot form values. |
cabana.AdminRelationLinkRequest |
Documented body of the link route: ids and the optional pivot object. |
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.TxFromContext |
The transaction a write route is running in, from the context of a lifecycle hook or scope. |
cabana.WriteData / cabana.WriteError / cabana.WriteErrorDetails |
Write the admin success and error envelopes. |
cabana.ValidationError / cabana.ListValidationError |
Field-level validation_failed errors. A pact.AdminAction may return a cabana.ValidationError to answer 422. |
cabana.AdminActionRequest |
Body of an action route: optional record_id and the widget's values. Unknown keys are refused. |
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.SessionKeyHeader |
X-Session-Key, the header that carries the form session key of deferred file and relation work. |
cabana.ChildSessionKeyHeader |
X-Child-Session-Key, the header that carries a relation child form's own session key for its file uploads. |
cabana.RecordInput |
A create or update body (Body) and the form session key (SessionKey) whose file bindings the save applies. |
cabana.FileItem |
One file of a fileupload field: id, name, size, content type, title, description, sort_order, pending, and url/thumb_url for public relations. |
cabana.ThumbOptions |
A fileupload field's thumbOptions (the preview thumbnail mode). |
cabana.FileMutationResult |
Answer of the file removal route: removed. |
cabana.AdminFileCaptionRequest |
Body of the file caption route: optional title and description. Unknown keys are refused. |
cabana.AdminFileList / cabana.AdminFileUpload / cabana.AdminFileUpdate / cabana.AdminFileRemove / cabana.AdminFileReorder / cabana.AdminFileDownload / cabana.AdminFileThumb |
Swag annotations of the file routes. |
cabana.AdminRelationChildFileList / cabana.AdminRelationChildFileUpload / cabana.AdminRelationChildFileUpdate / cabana.AdminRelationChildFileRemove / cabana.AdminRelationChildFileReorder / cabana.AdminRelationChildFileDownload / cabana.AdminRelationChildFileThumb |
Swag annotations of the relation child file routes. |
cabana.PartialView / cabana.PartialNode |
A rendered partial: a list of nodes, each an allowlisted element (tag, attrs, children) or a text node (text). |
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. |
http.body_limits.upload_bytes |
none | Read from the HTTP configuration: caps the body of a file upload (together with the field's maxFilesize plus 64 KiB), and no fileupload field may declare a larger maxFilesize. Without it the cap is the field's limit, or 128 MiB. |
http.body_limits.default_bytes |
none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes and of the relation child routes (1 MiB when not set; 413 payload_too_large past it). |
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 (required), --login (defaults to the lower-cased email), --role <code>, --superuser, --password (deprecated) |
Creates an activated backend administrator. The password is read from a hidden prompt, or from stdin when it is not a terminal. |
admin:reset-password |
<identifier> (login or email), --password (deprecated) |
Sets a new password, read like the one of admin:create, and revokes every token issued before the reset. |
./bin/acme admin:create --email admin@example.com --superuser
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:reset-password admin@example.com
--password still works, but it prints a deprecation warning: a value on the command line is visible in the process list and stays in the shell history.
Dependencies
- SummerCMS modules: backpack, boardwalk, bonfire, bouncer, lagoon, pact, party, phrasebook, towel.
- Third-party:
gorm.io/gorm(withgorm.io/gorm/clause),github.com/goccy/go-yaml(with itsastpackage),golang.org/x/net/html(with itsatompackage; 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-goand itsmodules/postgrespackage.
Testing
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/.