Five sequential plans: foundations (deferred_bindings, attach.Store, lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation child CRUD with deferral, admin SPA, and unit and security tests. Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
315 lines
45 KiB
Markdown
315 lines
45 KiB
Markdown
---
|
|
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["12.2-01"]
|
|
files_modified:
|
|
- modules/cabana/form_schema.go
|
|
- modules/cabana/schema_types.go
|
|
- modules/cabana/field_file.go
|
|
- modules/cabana/field_date.go
|
|
- modules/cabana/deferred.go
|
|
- modules/cabana/crud.go
|
|
- modules/cabana/http.go
|
|
- modules/cabana/registry.go
|
|
- modules/cabana/contracts.go
|
|
- modules/cabana/list_schema.go
|
|
- modules/cabana/partial_render.go
|
|
- modules/cabana/query.go
|
|
- modules/cabana/admin_openapi.go
|
|
- modules/cabana/security_coverage_test.go
|
|
- modules/cabana/openapi_conformance_test.go
|
|
- modules/cabana/fileupload_smoke_test.go
|
|
- modules/cabana/datepicker_smoke_test.go
|
|
- admin/openapi/admin.json
|
|
- admin/src/api/schema.d.ts
|
|
- modules/cabana/README.md
|
|
- docs/backend/forms.md
|
|
- docs/backend/lists-and-filters.md
|
|
- docs/database/attachments.md
|
|
autonomous: true
|
|
requirements: [SC-1, SC-2, SC-4]
|
|
estimate:
|
|
tokens: 260000
|
|
raw_tokens: 260000
|
|
tasks: 3
|
|
confidence: low
|
|
must_haves:
|
|
truths:
|
|
- "Per D-08, `fields.yaml` accepts `type: fileupload` with exactly the keys mode (image|file, default file), fileTypes, mimeTypes, maxFilesize (MB), maxFiles, imageWidth, imageHeight, thumbOptions (mapping with only `mode` in auto|exact|crop|fit), useCaption and prompt, beside the generic field keys; any other key, a datepicker-only key, or an image-mode fileType outside jpg, jpeg, png, gif, webp stops boot with an error naming plugin, controller and file."
|
|
- "Per D-06, a fileupload field whose name is not an `attach.Relation` returned by the model's `AttachRelations()`, or whose model does not implement `attach.Owner`, stops boot; `maxFiles` on an attachOne relation and a `maxFilesize` above `http.body_limits.upload_bytes` stop boot."
|
|
- "Per D-02, the SPA's session key arrives in the `X-Session-Key` header; a key outside `^[A-Za-z0-9_-]{32,128}$` answers 422 on `session_key`; record id `0` without a valid key answers 404; bindings are written and read only for the authenticated admin's id."
|
|
- "Per D-03 and D-09, `POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}` stores one multipart `file_data` part with `attach.Store` and only binds it to the session (id 0 for an unsaved record), and `GET .../{id}/files/{field}` lists attached files minus the session's pending removals plus the session's pending uploads, ordered by sort_order, each with `pending`, and with `url`/`thumb_url` only for public relations."
|
|
- "Per D-04, a create or update save with `X-Session-Key` applies every binding of (key, admin, controller morph type) whose master_field is a fileupload field allowed in that operation's context, inside the save transaction after the row write and before FormAfterCreate/FormAfterUpdate; a 422 rolls back and leaves the bindings in place; a successful save deletes the applied rows; bindings for other fields stay for the purge."
|
|
- "Per D-04 (Winter attachOne semantics), applying a bind on an attachOne field deletes the field's previously attached file rows and, after commit, their blobs; applying an unbind deletes the attached row and, after commit, its blobs; after applying, more files than maxFiles or no file on a required fileupload answers 422 on that field."
|
|
- "Per D-09, DELETE `.../files/{field}/{file}` defers a removal (or cancels a pending upload and deletes its row and, after commit, blob), PUT `.../files/{field}/{file}` saves title and description at once when useCaption is set (403 otherwise), and POST `.../files/{field}/reorder` with `{ids}` equal to the visible set assigns the existing sort_order values in the submitted order at once."
|
|
- "Per D-10, GET `.../files/{field}/{file}/download` and `.../thumb` serve only `is_public=false` rows attached to a parent the admin may load through FormExtendQuery or pending in the admin's own session; anything else is 404; responses carry `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and a sandboxing CSP, and only jpeg, png, gif and webp are served inline (everything else is `application/octet-stream` with `Content-Disposition: attachment`)."
|
|
- "Per D-08, the upload route wraps the body in `http.MaxBytesReader` sized min(upload_bytes, maxFilesize plus 64 KiB multipart overhead) and answers 413 `payload_too_large` past it; a file over maxFilesize inside the cap, a disallowed type or a failed image guard answers 422 on the field with the `lagoon::validation.*` message; the JSON file routes cap bodies at `http.body_limits.default_bytes` and refuse unknown keys."
|
|
- "Per D-20, `fields.yaml` accepts `type: datepicker` with exactly mode (date|datetime|time, default datetime), format, minDate, maxDate, yearRange (an integer or a two-year list), firstDay (0-6), twelveHour and ignoreTimezone beside the generic keys; a format token with no SPA equivalent, minDate/maxDate on time mode, ignoreTimezone off datetime mode and any other key stop boot; the served field carries `displayFormat` mapped from the Winter format."
|
|
- "Per D-19, boot fails when a datepicker's mode does not match its model column's Go type (datetime: time.Time or *time.Time; date: lagoon.Date or *lagoon.Date; time: lagoon.TimeOfDay or *lagoon.TimeOfDay); a datepicker is a writable scalar field whose `required` merges into the save rules."
|
|
- "Per D-20, a saved value before minDate or after maxDate (calendar date; the UTC date for datetime, the wall-clock date with ignoreTimezone) answers 422 on the field with the `after_or_equal` / `before_or_equal` message."
|
|
- "Per RESEARCH Pitfall 2 and the UI-SPEC list cells, struct columns that implement sql.Scanner or driver.Valuer (lagoon.Date, lagoon.TimeOfDay) are scalar list columns, not relations, and `columns.yaml` accepts `type: date` and `type: time`."
|
|
- "Edge (D-04 concurrency): two saves with the same session key serialize on the `FOR UPDATE` binding read, so a binding is applied once."
|
|
- "Edge (D-02 replay): a session key that belongs to another admin finds no bindings: its uploads are not listed, not committed and not removable (404)."
|
|
- "Edge (D-09 empty): GET files on a saved record without a session key lists the attached files only; reorder with an id set that differs from the visible set answers 422 on ids."
|
|
artifacts:
|
|
- path: "modules/cabana/field_file.go"
|
|
provides: "fileupload compile and boot checks, fileScope, file list/upload/caption/remove/reorder/download/thumb handlers"
|
|
contains: "MaxBytesReader"
|
|
- path: "modules/cabana/field_date.go"
|
|
provides: "datepicker compile, Go-type check, Winter format token map, min/max check"
|
|
contains: "displayFormat"
|
|
- path: "modules/cabana/deferred.go"
|
|
provides: "SessionKeyHeader, session key parsing, commitDeferred for file bindings"
|
|
contains: "X-Session-Key"
|
|
- path: "admin/openapi/admin.json"
|
|
provides: "the seven file routes and FileItem"
|
|
contains: "/files/{field}"
|
|
key_links:
|
|
- from: "modules/cabana/crud.go"
|
|
to: "modules/cabana/deferred.go"
|
|
via: "save calls commitDeferred after syncBelongsToMany and before formAfterCreate/formAfterUpdate"
|
|
pattern: "commitDeferred"
|
|
- from: "modules/cabana/field_file.go"
|
|
to: "modules/lagoon/attach/store.go"
|
|
via: "upload handler streams the file_data part into attach.Store with the field's Limits"
|
|
pattern: "attach\\.Store"
|
|
- from: "modules/cabana/deferred.go"
|
|
to: "modules/lagoon/deferred.go"
|
|
via: "lagoon.DeferredBindings / DeferredBind / DeferredUnbind / DeferredForget with a DeferredKey carrying the admin id"
|
|
pattern: "lagoon\\.Deferred"
|
|
- from: "modules/cabana/http.go"
|
|
to: "modules/cabana/field_file.go"
|
|
via: "nestedGet dispatches segment files to the file list"
|
|
pattern: "segment == \"files\""
|
|
prohibitions:
|
|
- statement: "The protected file routes MUST NOT serve SVG, HTML or any type outside jpeg, png, gif and webp inline, and MUST NOT serve an is_public=true row"
|
|
status: resolved
|
|
verification: test
|
|
- statement: "A file list MUST NOT emit a public url or thumb_url for a file of a protected (Public false) relation"
|
|
status: resolved
|
|
verification: test
|
|
- statement: "A file route MUST NOT answer 403 for a file of another record; an out-of-scope file is 404"
|
|
status: resolved
|
|
verification: test
|
|
- statement: "fonoteka.go upload code (album photos, collection media, user avatar) MUST NOT change; the fonoteka.go build, vet and admin tests stay green"
|
|
status: resolved
|
|
verification: test
|
|
- statement: "Framework READMEs and docs MUST NOT name a consuming application"
|
|
status: resolved
|
|
verification: test
|
|
---
|
|
|
|
## Phase Goal
|
|
|
|
ROADMAP Phase 12.2 goal (verbatim): A plugin's admin forms cover the three gaps a downstream project on SummerCMS v0.1 hit: a date/datetime field, a file upload field, and creating, editing and deleting related records inside the parent form (WinterCMS RelationController parity). Uploads and related-record changes on a record that is not saved yet use Winter-like deferred binding: they are held against a session key and committed with the parent's first save, or discarded with it.
|
|
|
|
This plan's slice: the admin API side of `type: fileupload` and `type: datepicker`, with file uploads deferred against the SPA's session key and committed in the parent's save transaction. After it an API client (the SPA in plan 04) can upload, list, caption, reorder and remove files on saved and unsaved records, download protected files, and save date, datetime and time fields.
|
|
|
|
<objective>
|
|
Extend cabana (summercms.go) with the datepicker and fileupload field types, the seven file routes, session-key parsing and the commit of file bindings in create/update saves; regenerate the admin OpenAPI document and TS types; update the cabana README and the forms, lists and attachments docs.
|
|
|
|
Purpose: success criteria 1 and 2 and the file half of criterion 4. Plan 03 reuses the session key, the file handlers (through a child file scope) and the commit function for relation bindings.
|
|
Output: compiled field types, routes, commit, OpenAPI, smoke tests, docs.
|
|
|
|
Repo: summercms.go only; fonoteka.go is verified (build, vet, admin tests), never edited. Neutral names (acme, blog) in code, tests and docs. Code and planning docs in separate commits; no co-author tags.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
@~/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
|
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
|
|
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.md
|
|
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md
|
|
@modules/cabana/form_schema.go
|
|
@modules/cabana/crud.go
|
|
@modules/cabana/http.go
|
|
|
|
<interfaces>
|
|
- From plan 01 (lagoon): `DeferredKey{SessionKey string; AdminID uint; MasterType string}`, `DeferredBind(ctx, tx, key, field, slaveType, slaveID string, env *DeferredEnvelope) error`, `DeferredUnbind(ctx, tx, key, field, slaveType, slaveID string) (*DeferredBinding, error)` (returns the cancelled pending bind), `DeferredBindings(ctx, tx, key, fields []string) ([]DeferredBinding, error)` (FOR UPDATE, id order), `DeferredForget(ctx, tx, ids []uint) error`, `DeferredSlaves(tx, key, field, slaveType string, bind bool) *gorm.DB` (subquery of slave_id text), `DeferredFileType = "system_files"`, `MorphType(db, model) (string, error)`, `Transaction`, `AfterCommit`, `Date`, `TimeOfDay`, `ParseDate`.
|
|
- From plan 01 (attach): `Store(ctx, db, bucket, Upload{FileName, Body, Public}, Limits{MaxBytes, Extensions, MIMETypes, Image}) (*File, error)`, `ErrTooLarge`, `ErrFileType`, `ErrMIMEType`, `ErrNotImage`, `DefaultImageExtensions`, `Relation{Name, Many, Public}`, `HasRelations{AttachRelations() []Relation}`, `Owner{MorphName() string}`, `BlobKeys(f File) []string`, `DeleteKeys(ctx, bucket, keys)`, `(*File).Thumb(ctx, bucket, w, h, mode) (publicURL, error)`, `(*File).ThumbKey(ctx, bucket, w, h, mode) (key, error)`, `(*File).URL()`, `AllowedImageMIMEs`.
|
|
- cabana (today): `formFieldTypes`, `formFieldKeys`, `compileFieldNode(name, node)`, `compileWidgetKeys(typ, values, field)` (the per-type gating model: "<key> is only valid on type: <type>"), `nodeString/nodeBool/nodeScalar/sequenceValues/unwrapNode`, `bootErr(pluginID, controllerID, file, err)`, `FormField` (flat omitempty Winter keys; `Multiple bool` exists), `scalarFormField(typ)` (crud.go:800), `BindWritableFields(cc)`, `CRUDService.save` (crud.go:290-391; `syncBelongsToMany` at 373, `formAfterCreate/Update` at 376-383), `RecordInput{Body}`, `loadRecord(ctx, tx, cc, dest, pk)` (FormExtendQuery + FOR UPDATE, `recordNotFound` on miss), `writeCRUDError`, `ValidationError{Details}`, `lifecycleFailure`, `withTx`, `newWritableModel(cc)`, `pathID(r)`, `decodeObject(r)`, `service.protect`, `service.operationDeclared(w, r, cc, op)`, `requireAjax`, `nestedGet` (http.go:299), `constrainController/constrainRelation/constrainNested`, `service.db()`, `service.translator()`, `Activate(app, plugins)`, `compileRegistry(items)`, `isListRelation(t)` (list_schema.go:418), `listColumnTypes` (list_schema.go:22), `embeddedStructType` (model_fields.go:70, the Scanner/Valuer exclusion to copy), struct-kind switches at partial_render.go:351,415,454 and query.go:499,516.
|
|
- Tests: `phase09Routes` (security_coverage_test.go:34) and `phase09ProtectedCalls()` must list every route and handler; `TestPhase09ContractInventory` requires every inventoried route in admin/openapi/admin.json with a 200/201 and, for protected routes, a 401; `TestPhase10OpenAPIConformance` (openapi_conformance_test.go) requires one conformance case per inventoried route against the PostgreSQL fixture `acme.conform` (`conformGadget`, `conformFS()` MapFS, `newConformEnv` publishes the app; body limits are set to 1048576).
|
|
- surf: `requiredBytes(app, "http.body_limits.upload_bytes")` (unexported; mirror its whole-number handling in cabana); cabana routes are GroupRaw, so surf applies no body limit to them.
|
|
- Winter sources: `../examples/golem15-wintercms-starter/modules/backend/formwidgets/FileUpload.php`, `modules/backend/formwidgets/DatePicker.php`, `modules/system/helpers/DateTime.php` (momentFormat token table).
|
|
</interfaces>
|
|
</context>
|
|
|
|
## Artifacts this phase produces
|
|
|
|
(This plan's share.)
|
|
|
|
- Field types `datepicker`, `fileupload`; FormField keys `mode`, `format`, `displayFormat`, `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour`, `ignoreTimezone`, `fileTypes`, `mimeTypes`, `maxFilesize`, `maxFiles`, `imageWidth`, `imageHeight`, `thumbOptions`, `useCaption`, `prompt`, `protected` (true for a Public false relation; `multiple` is true for attachMany); list column types `date`, `time`.
|
|
- cabana exports: `SessionKeyHeader = "X-Session-Key"`, `RecordInput.SessionKey`, `FileItem`, `FileMutationResult{Removed int}`, `AdminFileCaptionRequest{Title, Description *string}`, `ThumbOptions{Mode string}`; error code `payload_too_large` (413).
|
|
- Routes (prefix-relative, backend guard, writes under requireAjax): `GET /{vendor}/{plugin}/{controller}/{id}/files/{field}` (via nestedGet), `POST /{vendor}/{plugin}/{controller}/{id}/files/{field}`, `PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}`, `DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}`, `POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder`, `GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download`, `GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb`; swag doc funcs `AdminFileList`, `AdminFileUpload`, `AdminFileUpdate`, `AdminFileRemove`, `AdminFileReorder`, `AdminFileDownload`, `AdminFileThumb`.
|
|
- Internal: `compiledFile`, `compiledDate`, `fileScope`, `parentFileScope`, `commitDeferred`, `sessionKeyFrom`.
|
|
|
|
## Planner assumptions recorded for this plan
|
|
|
|
- A1: record id `0` in the existing route patterns means "the record being created in this session".
|
|
- A2: the session key travels in the `X-Session-Key` header (discretion item); pattern `^[A-Za-z0-9_-]{32,128}$`.
|
|
- A7: reorder and caption apply immediately (Winter parity); cancel does not revert them.
|
|
- A10: fileupload `required` is checked at commit as at least one file after applying bindings.
|
|
- A11: preview thumbnails default to 240x240 with `thumbOptions.mode` or `crop`.
|
|
- A12: `thumbOptions` accepts only `mode`; A13: Winter keys outside D-08/D-20 (showWeekNumber, attachOnUpload, iconClass, emptyIcon) are boot errors.
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: An admin uploads an image to a fileupload field of a record that is not saved yet, and the record's create save attaches it</name>
|
|
<reversibility rating="reversible">Additive field type, routes and an optional header; the session-key header name is a contract only between this API and the bundled SPA.</reversibility>
|
|
<files>modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/field_file.go, modules/cabana/deferred.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/registry.go, modules/cabana/contracts.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/fileupload_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/forms.md</files>
|
|
<read_first>modules/cabana/form_schema.go (whole file), modules/cabana/schema_types.go (FormField), modules/cabana/crud.go (save, RecordInput, loadRecord, writeCRUDError, scalarFormField), modules/cabana/http.go (Activate, mount, nestedGet, create, update, protect, operationDeclared), modules/cabana/registry.go (compileRegistry), modules/cabana/contracts.go (CompiledController), modules/cabana/admin_openapi.go (AdminRelationLink style, Envelope types), modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go (cases, newConformEnv, conformFS, conformGadget), modules/cabana/csrf.go, modules/lagoon/deferred.go, modules/lagoon/attach/store.go, modules/lagoon/attach/relation.go, modules/surf/router.go (requiredBytes), scripts/check-admin-openapi.sh, ../examples/golem15-wintercms-starter/modules/backend/formwidgets/FileUpload.php, modules/cabana/README.md, docs/backend/forms.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Patterns 1, 4, 5, 6; Pitfalls 4, 6)</read_first>
|
|
<action>Per D-02, D-03, D-04, D-06, D-07, D-08 and D-09, wire upload, list and commit end to end for one fileupload field.
|
|
|
|
(1) Compile (D-08): add `fileupload` to `formFieldTypes` and the D-08 keys to `formFieldKeys`; add `compileFileuploadKeys(typ, values, field)` modelled on compileWidgetKeys: on other types each fileupload-only key answers "<key> is only valid on type: fileupload" (`mode` is shared with datepicker in Task 3, so gate its value per type); on fileupload decode mode (image|file, default file), fileTypes and mimeTypes (a comma- or pipe-separated string or a YAML list; fileTypes tokens `^[a-z0-9]{1,10}$` lower-cased, and in image mode only jpg, jpeg, png, gif, webp), maxFilesize (positive number of MB), maxFiles (positive integer), imageWidth and imageHeight (1..4096), thumbOptions (a mapping whose only key is `mode` in auto|exact|crop|fit), useCaption (bool), prompt (phrase key, localized like `comment`). Refuse `options`, `emptyOption` and `nameFrom` on fileupload. Add the flat omitempty FormField fields named in Artifacts (`ThumbOptions` as `*ThumbOptions`, numbers as pointers so absent stays absent) and localize `prompt` in `FormSchema.Localize`. fileupload stays non-scalar (not in scalarFormField, never a writable column).
|
|
|
|
(2) Boot checks (D-06, D-08) in compileRegistry (or a helper it calls): for every fileupload field the record model from `pact.AdminRecordSource.NewRecord()` must implement `attach.Owner` and `attach.HasRelations` with a Relation whose Name equals the field name; maxFiles on a Relation with Many false is an error; set `field.Multiple = rel.Many` and `field.Protected = !rel.Public`; build an unexported `compiledFile` per field on CompiledController (`files map[string]*compiledFile`: the field, the Relation, `attach.Limits` from mode/fileTypes/mimeTypes/maxFilesize, thumb width/height/mode per A11, the owner morph type via `lagoon.MorphType`). Errors use bootErr with the fields.yaml path. In Activate read `http.body_limits.upload_bytes` and `http.body_limits.default_bytes` when app.Config is set (the surf requiredBytes rules, unexported copy in cabana) onto the service, and refuse a maxFilesize above upload_bytes ("maxFilesize exceeds http.body_limits.upload_bytes").
|
|
|
|
(3) Session key (D-02) in modules/cabana/deferred.go: `SessionKeyHeader = "X-Session-Key"`, `sessionKeyFrom(r) (key string, present bool, err error)` validating `^[A-Za-z0-9_-]{32,128}$` (malformed is a ValidationError on `session_key`); the admin id comes from `bouncer.User(ctx)`. Add `SessionKey string` to `RecordInput` (additive); the create and update handlers fill it from the header (malformed: 422).
|
|
|
|
(4) File scope and routes in modules/cabana/field_file.go: `fileScope` (compiled file, owner morph, owner id with 0 meaning unsaved, the loaded owner or nil, the DeferredKey) and `parentFileScope(ctx, tx, r, cc)`: the field must be a compiledFile allowed by `context` for the operation (id 0 is create, otherwise update) else 404; id 0 needs a valid key (else 404) and the create operation declared; id > 0 loads the owner with `loadRecord` (FormExtendQuery, 404 on miss). Write every file handler as a function of a resolved fileScope so plan 03 can add a child scope without touching handler bodies. Upload (`POST .../{id}/files/{field}`, requireAjax): a valid key is required (422 on session_key); wrap `r.Body` in `http.MaxBytesReader` at min(upload_bytes, maxFilesize bytes + 65536) (128 MiB when neither is configured); read the body with `r.MultipartReader()`: exactly one part, form name `file_data`, with a file name, else 422 on `body`; refuse when attached-minus-pending-removals plus pending uploads already reach maxFiles on attachMany (422 on the field, `lagoon::validation.max.array`); inside one `lagoon.Transaction` call `attach.Store` with the field's Limits and `Public` from the Relation, then `lagoon.DeferredBind(ctx, tx, key, field, lagoon.DeferredFileType, id)`; when the transaction fails after Store returned, delete the stored blob keys with `context.WithoutCancel`. Map ErrTooLarge, ErrFileType, ErrMIMEType and ErrNotImage to a 422 on the field name using `lagoon::validation.max.file`, `mimes`, `mimetypes` and `image` through `s.translator()` (Laravel English text when there is no translator), and a `*http.MaxBytesError` to 413 with code `payload_too_large`. Answer 201 `Envelope[FileItem]` with pending true. List (`GET .../{id}/files/{field}` dispatched by `nestedGet` on `segment == "files"`): rows attached to the owner (attachment_type = morph, attachment_id = id as text, field) minus `DeferredSlaves(...unbinds)`, plus `DeferredSlaves(...binds)` rows when a key is present; order sort_order, id; FileItem.URL and ThumbURL only for public relations (ThumbURL through `(*File).Thumb` at the compiled size and mode, only for image content types); `Pending` true for session-bound rows.
|
|
|
|
(5) Commit (D-04) in deferred.go: `commitDeferred(ctx, tx, cc, target, op, in RecordInput)` called from `CRUDService.save` after syncBelongsToMany and before formAfterCreate/formAfterUpdate, only when `in.SessionKey` is set and an admin is on the context: read `lagoon.DeferredBindings` for (key, admin id, controller morph type) restricted to fileupload fields allowed in op; apply in id order: a bind loads the `system_files` row by id `FOR UPDATE`, ignores it unless its attachment_id is empty, and sets attachment_type, attachment_id (the saved primary key as text) and field; an unbind is applied in Task 2 (until then leave unbind rows untouched for the purge). Delete the applied rows with `DeferredForget` in the same transaction. Bindings of other fields or another operation's context stay in place.
|
|
|
|
(6) OpenAPI and inventories: swag doc funcs `AdminFileList` and `AdminFileUpload` (multipart: `@Accept multipart/form-data`, `@Param file_data formData file true`, `@Param X-Session-Key header string false`, 201 `Envelope[FileItem]`, 401/403/404/413/422 ErrorEnvelope); add both routes to `phase09Routes` (the list with `mounted: nestedGetRoute`) and the handlers to `phase09ProtectedCalls`; extend the conformance fixture: `conformGadget` implements attach.Owner (MorphName `acme.conform.gadget`) and HasRelations (`photos`, Many, Public), conformFS's gadget fields.yaml gains a `photos` fileupload field in image mode, newConformEnv publishes a `mem://` bucket with attach.Publish; add conformance cases that upload a small PNG multipart to id 0 with a session key (201) and list it (200). Run `scripts/check-admin-openapi.sh` and commit admin/openapi/admin.json and admin/src/api/schema.d.ts with the code.
|
|
|
|
(7) Smoke test modules/cabana/fileupload_smoke_test.go `TestFileuploadSmokeCreateCommit` through the assembled router (reuse the conformance env helpers or the same fixture shape): upload to id 0, create the gadget with the same X-Session-Key, then list on the new id shows the file not pending and the deferred_bindings table has no row for the key; `TestFileuploadSmokeForeignAdmin`: a second admin with the first admin's key lists nothing.
|
|
|
|
(8) Docs in the same change: modules/cabana/README.md (Admin API routes table rows for the two routes, the fileupload keys, SessionKeyHeader, FileItem, `payload_too_large`), docs/backend/forms.md (Field types table row for `fileupload`; a "File uploads" section: AttachRelations, the keys, deferral until Save, the session key header, limits enforced on the server, maxFilesize versus `http.body_limits.upload_bytes`). Replace only the file-upload part of the "not provided" sentence here (Task 3 finishes it).</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestFileuploadSmoke.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...</automated>
|
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestFileuploadSmokeCreateCommit", "--- PASS: TestPhase09PermissionMatrix" or "--- PASS: TestPhase10OpenAPIConformance"; check-admin-openapi.sh prints "committed admin OpenAPI output is stale"; a ServeMux pattern conflict panic appears in the output.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c '"fileupload"' modules/cabana/form_schema.go` prints at least 1 and `grep -c 'is only valid on type: fileupload' modules/cabana/form_schema.go modules/cabana/field_file.go | awk -F: '{s+=$2} END {print s}'` prints at least 1.
|
|
- `grep -c 'MaxBytesReader' modules/cabana/field_file.go` prints at least 1 and `grep -c 'commitDeferred' modules/cabana/crud.go` prints at least 1.
|
|
- `grep -c 'files/{field}' modules/cabana/security_coverage_test.go` prints at least 2 and `grep -c '/files/{field}' admin/openapi/admin.json` prints at least 1.
|
|
- `go doc ./modules/cabana SessionKeyHeader` and `go doc ./modules/cabana FileItem` exit 0.
|
|
- `grep -c 'fileupload' docs/backend/forms.md` and `grep -c 'X-Session-Key' modules/cabana/README.md` each print at least 1.
|
|
</acceptance_criteria>
|
|
<done>An upload on the create screen survives until Save and is attached to the new record in the same transaction; another admin cannot see or commit it.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: An admin removes, captions and reorders files on saved and unsaved records, attachOne replaces its file at Save, limits are rechecked at Save, and protected files download only through the admin API</name>
|
|
<reversibility rating="reversible">Additive routes and commit rules inside the existing save transaction.</reversibility>
|
|
<files>modules/cabana/field_file.go, modules/cabana/deferred.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/fileupload_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/database/attachments.md</files>
|
|
<read_first>modules/cabana/field_file.go and modules/cabana/deferred.go (as left by Task 1), modules/cabana/http.go (mount), modules/cabana/csrf.go, modules/lagoon/deferred.go (DeferredUnbind return value), modules/lagoon/attach/file.go (BlobKeys, DeleteKeys), modules/lagoon/attach/thumb.go (ThumbKey), modules/lagoon/attach/static.go (servePublicBlobs headers), modules/lagoon/transaction.go (AfterCommit), ../examples/golem15-wintercms-starter/modules/backend/formwidgets/FileUpload.php (onRemoveAttachment, onSortAttachments, onSaveAttachmentConfig), docs/database/attachments.md</read_first>
|
|
<action>Per D-04, D-08, D-09 and D-10. Every handler below resolves the same fileScope as Task 1 and finds its file with one parent-scoped query: the `{file}` id must be attached to the scope's owner and field, or bound pending in the scope's key; otherwise 404 (never 403 for a foreign file). `{file}` is constrained to `[0-9]+`.
|
|
|
|
(1) Remove (`DELETE .../files/{field}/{file}`, requireAjax, valid key required): `lagoon.DeferredUnbind`; when it returns a cancelled pending bind, delete that file row in the same transaction and register `attach.DeleteKeys(attach.BlobKeys(row))` with `lagoon.AfterCommit`; answer `Envelope[FileMutationResult]` with removed 1.
|
|
|
|
(2) Caption (`PUT .../files/{field}/{file}`, requireAjax): 403 when the field lacks useCaption; body capped with MaxBytesReader at default_bytes, decoded into `AdminFileCaptionRequest` with DisallowUnknownFields and no trailing data; title and description saved at once (A7); answer `Envelope[FileItem]`.
|
|
|
|
(3) Reorder (`POST .../files/{field}/reorder`, requireAjax): attachMany only (403 otherwise); body `{ids}` capped and decoded strictly; the id set must equal the field's visible set (attached minus pending removals plus pending uploads) exactly, else 422 on `ids`; assign the visible rows' existing sort_order values, sorted ascending, to the ids in submitted order in one transaction; answer the reordered `Envelope[[]FileItem]`.
|
|
|
|
(4) Protected download and thumb (`GET .../files/{field}/{file}/download` and `/thumb`): only rows with is_public false (a public row is 404); a key in the header is optional and only widens the scope to pending rows of this admin. Download streams the blob from `BlobKey(disk_name)`; thumb uses `(*File).ThumbKey` at the compiled size and mode and is 404 for a non-image content type. Headers on both: `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store`, `Content-Security-Policy: default-src 'none'; sandbox`; content types in `attach.AllowedImageMIMEs` are served inline with that type, everything else as `application/octet-stream` with `Content-Disposition: attachment; filename*=UTF-8''<percent-encoded file name>`.
|
|
|
|
(5) Commit rules in commitDeferred (D-04): a bind on an attachOne field first deletes the field's currently attached rows for this owner (blob keys through AfterCommit); an unbind deletes the attached row of this owner and field (blob keys through AfterCommit) and is ignored when the row is not attached there; after applying every binding, for each fileupload field allowed in op count the owner's attached rows: more than maxFiles answers 422 on the field (`lagoon::validation.max.array`), zero on a `required: true` field answers 422 on the field (`lagoon::validation.required`), so the transaction rolls back and the bindings remain. A `required` fileupload is checked on every create and update save, with or without a session key.
|
|
|
|
(6) OpenAPI, inventory and conformance: swag doc funcs `AdminFileUpdate`, `AdminFileRemove`, `AdminFileReorder`, `AdminFileDownload` (`@Produce octet-stream`, `@Success 200 {file} file`) and `AdminFileThumb`; add the five routes to phase09Routes and their handlers to phase09ProtectedCalls; give the conformance fixture a protected attachOne relation `manual` (file mode) and add one conformance case per route (binary routes: extend conformCase with a raw response check of status, Content-Type and nosniff instead of JSON decoding); regenerate admin.json and schema.d.ts.
|
|
|
|
(7) Smoke tests in modules/cabana/fileupload_smoke_test.go: `TestFileuploadSmokeRemoveCancelsPending` (row and blob gone after commit), `TestFileuploadSmokeAttachOneReplace`, `TestProtectedFileSmoke` (another gadget's protected file is 404; an SVG stored in file mode downloads as attachment with nosniff).
|
|
|
|
(8) Docs: modules/cabana/README.md route rows for the five routes; docs/database/attachments.md "Protected files in the admin" (the download and thumb routes, their headers, the 404 scoping).</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestFileuploadSmoke.*|TestProtectedFileSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1</automated>
|
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestProtectedFileSmoke" and "--- PASS: TestFileuploadSmokeAttachOneReplace"; check-admin-openapi.sh reports stale output.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c 'nosniff' modules/cabana/field_file.go` prints at least 1 and `grep -c "sandbox" modules/cabana/field_file.go` prints at least 1.
|
|
- `grep -c 'AdminFileDownload\|AdminFileThumb\|AdminFileReorder\|AdminFileRemove\|AdminFileUpdate' modules/cabana/admin_openapi.go` prints at least 5.
|
|
- `grep -c '/files/{field}' modules/cabana/security_coverage_test.go` prints at least 7.
|
|
- `grep -c 'download' docs/database/attachments.md` prints at least 1.
|
|
</acceptance_criteria>
|
|
<done>Every file operation of D-09 works on saved and unsaved records, attachOne replacement and limits hold at Save, and protected files leave only through the scoped admin route.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: An admin saves date, datetime and time values through `type: datepicker`, bounds are enforced on the server, and lists show date and time columns</name>
|
|
<reversibility rating="reversible">Additive field type, keys, list column types and a narrower relation detection that only stops misclassifying Scanner/Valuer structs.</reversibility>
|
|
<files>modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/field_date.go, modules/cabana/crud.go, modules/cabana/registry.go, modules/cabana/list_schema.go, modules/cabana/partial_render.go, modules/cabana/query.go, modules/cabana/openapi_conformance_test.go, modules/cabana/datepicker_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/forms.md, docs/backend/lists-and-filters.md</files>
|
|
<read_first>modules/cabana/form_schema.go, modules/cabana/crud.go (scalarFormField, BindWritableFields, mergedRules, save), modules/cabana/list_schema.go (listColumnTypes, isListRelation, the reflection helpers around line 400), modules/cabana/model_fields.go (embeddedStructType), modules/cabana/partial_render.go (lines 340-460), modules/cabana/query.go (lines 490-520), modules/lagoon/date.go, ../examples/golem15-wintercms-starter/modules/backend/formwidgets/DatePicker.php, ../examples/golem15-wintercms-starter/modules/system/helpers/DateTime.php (momentFormat), docs/backend/forms.md, docs/backend/lists-and-filters.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Pitfalls 2, 12, 13)</read_first>
|
|
<action>Per D-18, D-19 and D-20 and RESEARCH Pitfalls 2, 12 and 13.
|
|
|
|
(1) Compile in modules/cabana/field_date.go, called from compileFieldNode like compileWidgetKeys: add `datepicker` to formFieldTypes and the D-20 keys to formFieldKeys; datepicker-only keys on other types answer "<key> is only valid on type: datepicker"; `mode` on datepicker is date|datetime|time (default datetime, Winter's default); minDate/maxDate parse with `lagoon.ParseDate` (or the date part of an RFC 3339 value) and are refused on time mode and when min is after max; yearRange is a positive integer or a two-integer list with from <= to (served as `yearRange` [n] or [from, to]); firstDay 0..6; twelveHour bool; ignoreTimezone bool, datetime mode only; `format` is Winter's PHP date format, mapped at boot to `displayFormat` with the DateTime::momentFormat table (d to DD, j to D, m to MM, n to M, Y to YYYY, y to YY, H to HH, G to H, h to hh, g to h, i to mm, s to ss, A and a, D/l day names, M/F month names, backslash escapes kept literal) and a boot error naming the token for t, L, B, I, O, P, T, Z, c, r, U or any other unmapped letter. Refuse options, emptyOption and nameFrom on datepicker. Add `datepicker` to scalarFormField so it binds as a writable column and its `required` merges into the rules.
|
|
|
|
(2) Go-type check (D-19) after BindWritableFields in compileRegistry: the bound column's Go type must be time.Time or *time.Time for datetime, lagoon.Date or *lagoon.Date for date, lagoon.TimeOfDay or *lagoon.TimeOfDay for time; any other type stops boot with bootErr naming the field, the mode and the found type. Keep an unexported `compiledDate` per field (mode, min, max, ignoreTimezone) on CompiledController (`dates map[string]*compiledDate`).
|
|
|
|
(3) Server bounds (D-20): in CRUDService.save after Fill and before lagoon.Validate's result is returned, for each datepicker with min or max allowed in op whose value is set: compare the calendar date (date mode: the Date; datetime: the UTC date of the instant, or its wall-clock date with ignoreTimezone) inclusively; a violation is a ValidationError on the field with Laravel's `after_or_equal`/`before_or_equal` English text ("The <field> must be a date after or equal to <min>.").
|
|
|
|
(4) Lists (Pitfall 2): `isListRelation` returns false for a struct type whose pointer implements sql.Scanner or which implements driver.Valuer (the embeddedStructType rule); apply the same exclusion at the struct-kind switches in partial_render.go and query.go wherever a struct is treated as a relation or nested value; add `date` and `time` to listColumnTypes. Record values of Date and TimeOfDay serialise through their MarshalJSON.
|
|
|
|
(5) Conformance and OpenAPI: give `conformGadget` a `released_on` lagoon.Date column (`gorm:"type:date"`) and a `starts_at` *time.Time column with datepicker fields in the fixture fields.yaml (date with minDate, datetime), so the form schema, create and update conformance cases carry them; regenerate admin.json and schema.d.ts.
|
|
|
|
(6) Smoke test modules/cabana/datepicker_smoke_test.go: `TestDatepickerSmokeCompile` (unknown key, ignoreTimezone on date mode and an unmapped format token fail; displayFormat for `d.m.Y H:i` is `DD.MM.YYYY HH:mm`), `TestDatepickerSmokeTypeMismatch` (a date-mode field on a time.Time column fails boot naming the field), `TestDatepickerSmokeSave` (create with `"released_on":"2026-10-02"` and `"starts_at":"2026-10-02T12:30:00+02:00"` stores DATE 2026-10-02 and 10:30 UTC; a date before minDate is 422).
|
|
|
|
(7) Docs: docs/backend/forms.md (Field types row for `datepicker`; a "Date pickers" section with the modes, the Go types per mode, the keys, server-side bounds, time zones per D-18; rewrite the remaining "not provided" sentence so it lists the Winter widgets that are still not provided and no longer names the file upload), docs/backend/lists-and-filters.md (`type: date` and `type: time` columns), modules/cabana/README.md (field types and keys).</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestDatepickerSmoke.*|TestPhase10OpenAPIConformance)$' && go test ./modules/cabana -count=1 && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1</automated>
|
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestDatepickerSmokeSave"; docs:build --check prints a problem line; a fonoteka.go admin test fails (a cabana change broke an application controller).</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c '"datepicker"' modules/cabana/form_schema.go` prints at least 1 and `awk '/^func scalarFormField/,/^}/' modules/cabana/crud.go | grep -c '"datepicker"'` prints 1.
|
|
- `grep -c '"date": {}' modules/cabana/list_schema.go` prints 1 and `grep -c '"time": {}' modules/cabana/list_schema.go` prints 1.
|
|
- `grep -c 'displayFormat' admin/openapi/admin.json` prints at least 1.
|
|
- `grep -c 'datepicker' docs/backend/forms.md` prints at least 2 and `grep -c 'type: date' docs/backend/lists-and-filters.md` prints at least 1.
|
|
- `go test ./modules/cabana -count=1` passes with Docker up (no existing cabana test broken by the relation-detection change).
|
|
</acceptance_criteria>
|
|
<done>Date, datetime and time fields compile, type-check against the model, save with server-side bounds and show in lists, without a plugin defining its own date type.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| SPA (cookie + CSRF header) → admin file routes | Untrusted multipart bodies, file ids, session keys and JSON bodies |
|
|
| Session key header → deferred_bindings | A client-chosen key selects pending work |
|
|
| Admin API → browser (download/thumb) | Stored bytes rendered by the admin's browser |
|
|
| fields.yaml → boot compile | Trusted plugin config, still validated fail-loud |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-12.2-09 | Spoofing | session key replay by another admin | high | mitigate | Every binding read, list and commit is keyed by (key, admin id from bouncer, morph type); a foreign key finds nothing (Task 1). |
|
|
| T-12.2-10 | Tampering | cross-controller binding replay | high | mitigate | commitDeferred filters by the controller's morph type and the fileupload fields allowed in the operation context (Task 1). |
|
|
| T-12.2-11 | Information Disclosure | file ids on remove, caption, reorder, download, thumb | high | mitigate | One parent-scoped query (owner loaded via FormExtendQuery, or the admin's pending bindings); miss is 404 (Task 2). |
|
|
| T-12.2-12 | Elevation of Privilege | protected download rendering active content | high | mitigate | Inline only for jpeg/png/gif/webp; everything else octet-stream attachment; nosniff, private no-store, CSP sandbox (Task 2). |
|
|
| T-12.2-13 | Information Disclosure | protected file reachable by public URL | medium | mitigate | url/thumb_url never emitted for Public false relations; admin route only; docs tell hosts to mount StaticHandlerPublic (Tasks 1, 2; plan 01 docs). |
|
|
| T-12.2-14 | Denial of Service | upload body size | high | mitigate | MaxBytesReader at min(upload_bytes, maxFilesize + 64 KiB), 413; attach.Store counting limit; one part only (Task 1). |
|
|
| T-12.2-15 | Tampering | CSRF on new write routes | high | mitigate | requireAjax on upload, remove, caption, reorder; TestPhase09PermissionMatrix inventories every route (Tasks 1, 2). |
|
|
| T-12.2-16 | Tampering | double submit commits twice | medium | mitigate | lagoon.DeferredBindings reads FOR UPDATE inside the save transaction; applied rows deleted in the same transaction (Task 1). |
|
|
| T-12.2-17 | Tampering | datepicker bounds bypass | medium | mitigate | minDate/maxDate re-checked in the save transaction, 422 (Task 3). |
|
|
| T-12.2-18 | Denial of Service | JSON bodies of caption and reorder | medium | mitigate | MaxBytesReader at default_bytes, DisallowUnknownFields, trailing data refused (Task 2). |
|
|
| T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package added; swag v1.16.6 and openapi-typescript are already pinned and only regenerate committed outputs. |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- summercms.go: `go vet ./... && go test ./... -count=1` green with Docker up; `scripts/check-admin-openapi.sh --check` clean; `scripts/check-admin-dist.sh` still clean (type-only SPA change); `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green.
|
|
- fonoteka.go: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1` green; no fonoteka.go file changed.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `type: fileupload` uploads, lists, removes, captions, reorders and serves protected files on saved and unsaved records, with server-side limits (D-06 to D-10).
|
|
- File bindings commit inside the create/update transaction and survive a 422 (D-04); foreign keys and foreign admins see nothing (D-02).
|
|
- `type: datepicker` compiles, type-checks and saves date, datetime and time columns with server-side bounds (D-18 to D-20); lists render date and time columns.
|
|
- OpenAPI, TS types, route inventory, conformance, README and docs updated in the same change.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md` when done.
|
|
</output>
|