Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md

16 KiB

phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def plan: 02 subsystem: admin tags: [cabana, fileupload, datepicker, deferred-binding, attach, openapi, swag] requires: - phase: 12.2-01 provides: deferred_bindings store ops, attach.Store, attach.Relation/HasRelations, File.ThumbKey, lagoon.Date/TimeOfDay, Fill text fallback provides: - type: fileupload (D-08 keys, boot-bound to attach.Relation, maxFilesize vs http.body_limits.upload_bytes) - X-Session-Key (cabana.SessionKeyHeader) and RecordInput.SessionKey - seven file routes under .../{id}/files/{field} (list, upload, caption, remove, reorder, protected download, protected thumb) - commitDeferred in CRUDService.save: binds, unbinds, attachOne replace, maxFiles/required recheck - type: datepicker (D-20 keys, displayFormat, D-19 Go type check, server min/max) - columns.yaml type: date and type: time; Scanner/Valuer structs are not relations - swagger2openapi: formData -> multipart requestBody, file responses -> binary affects: [12.2-03 relation child CRUD (reuses fileScope, sessionKeyFrom, commitDeferred), 12.2-04 admin SPA, 12.2-05 unit and security tests] actuals: tokens: 66000 tasks: 3 commits: 4 plan_head_before: edda803dc1 plan_head_after: 67d4c7ff13 tech-stack: added: [] patterns: - "File handlers take a resolved fileScope (field, owner or id 0, DeferredKey), so a child scope can reuse them" - "Every file id is resolved by one parent-scoped query; a miss is 404, never 403" - "Blob deletes of files removed in a save run through lagoon.AfterCommit" - "Binary admin routes are documented with @Success 200 {file} file and checked by a raw conformance case" key-files: created: - modules/cabana/field_file.go - modules/cabana/field_date.go - modules/cabana/deferred.go - modules/cabana/fileupload_smoke_test.go - modules/cabana/datepicker_smoke_test.go modified: - modules/cabana/form_schema.go - modules/cabana/schema_types.go - modules/cabana/crud.go - modules/cabana/http.go - modules/cabana/registry.go - modules/cabana/contracts.go - modules/cabana/settings.go - modules/cabana/list_schema.go - modules/cabana/auth.go - modules/cabana/admin_openapi.go - modules/cabana/security_coverage_test.go - modules/cabana/openapi_conformance_test.go - modules/cabana/phase10_csrf_test.go - modules/cabana/phase10_coverage_test.go - internal/tools/swagger2openapi/main.go - internal/tools/swagger2openapi/main_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 key-decisions: - "12.2-02: file routes on id 0 answer 404 when the controller does not declare create, as for a missing session key" - "12.2-02: the save consumes (forgets) a file bind whose row is gone or already attached; unbinds of a file not attached to this owner are consumed the same way" - "12.2-02: removing a file already pending removal is an idempotent 200; removing it through another record is 404" - "12.2-02: preview thumbnails use imageWidth x imageHeight (one given means a square), else 240 x 240, in thumbOptions.mode or crop" - "12.2-02: displayFormat is served only when format is set; the SPA otherwise formats by locale" - "12.2-02: a fileupload field on a settings form fails boot (no controller owns file routes there); a settings datepicker gets the D-19 type check" - "12.2-02: file limit and date bound messages use lagoon::validation lines with Laravel's English fallback and the field name (underscores as spaces) as :attribute" patterns-established: - "fileScope + findFile: (owner attached) OR (pending in the admin's own key), one query" - "conformCase.raw: binary routes are checked for status, content type and headers, and admin.json must document the 200 as binary" requirements-completed: [SC-1, SC-2, SC-4] coverage: - id: D1 description: "Upload to the create form (id 0), save with the same X-Session-Key: the file is attached and the binding gone; a foreign admin with the key sees and commits nothing" requirement: SC-2 verification: - kind: integration ref: "modules/cabana/fileupload_smoke_test.go#TestFileuploadSmokeCreateCommit|TestFileuploadSmokeForeignAdmin" status: pass human_judgment: false - id: D2 description: "Removing a pending upload deletes row and blob; attachOne replace and deferred removal delete the old file and blob on save" requirement: SC-4 verification: - kind: integration ref: "modules/cabana/fileupload_smoke_test.go#TestFileuploadSmokeRemoveCancelsPending|TestFileuploadSmokeAttachOneReplace" status: pass human_judgment: false - id: D3 description: "Protected download: another record's file and a public file are 404; an SVG is an octet-stream attachment with nosniff, private no-store and sandbox CSP" requirement: SC-2 verification: - kind: integration ref: "modules/cabana/fileupload_smoke_test.go#TestProtectedFileSmoke" status: pass human_judgment: false - id: D4 description: "Datepicker keys, format mapping and Go type check fail boot naming the file; date and datetime save (DATE kept, datetime in UTC); minDate enforced with 422" requirement: SC-1 verification: - kind: integration ref: "modules/cabana/datepicker_smoke_test.go#TestDatepickerSmokeCompile|TestDatepickerSmokeTypeMismatch|TestDatepickerSmokeSave" status: pass human_judgment: false - id: D5 description: "Every new route is inventoried, guarded, CSRF-walked, in admin.json and called once by the conformance suite" verification: - kind: integration ref: "modules/cabana#TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10CSRF|TestPhase10Coverage|TestPhase10OpenAPIConformance" status: pass - kind: other ref: "scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh" status: pass human_judgment: false - id: D6 description: "Docs and READMEs name only existing identifiers; fonoteka.go unchanged and green" verification: - kind: other ref: "go test ./cmd/summer -run TestDocsTree && 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" status: pass human_judgment: false duration: 28min completed: 2026-10-02 status: complete

Phase 12.2 Plan 02: Admin API for fileupload and datepicker fields Summary

WinterCMS-style type: fileupload with uploads and removals deferred against the SPA's X-Session-Key and applied inside the record's save transaction, seven parent-scoped file routes with hardened protected downloads, and type: datepicker with a boot-time Go type check and server-side date bounds

Performance

  • Duration: about 28 min
  • Started: 2026-10-02T15:51:49Z
  • Completed: 2026-10-02T16:20:00Z
  • Tasks: 3
  • Files modified: 29 (summercms.go only; fonoteka.go untouched)

Accomplishments

  • type: fileupload compiles exactly the D-08 keys. At boot the field is bound to the model's attach.Relation (the model must implement attach.Owner and attach.HasRelations). multiple and protected are derived from the relation, and maxFiles on attachOne or a maxFilesize above http.body_limits.upload_bytes stops boot.
  • X-Session-Key is validated against ^[A-Za-z0-9_-]{32,128}$. Every binding read and write is keyed by the key, the admin id from bouncer and the controller's morph type, so another admin's key matches nothing.
  • The upload route caps the body at min(upload_bytes, maxFilesize + 64 KiB) and answers 413 payload_too_large past it. It accepts exactly one file_data part, stores it with attach.Store and binds it to the session. When the transaction rolls back it deletes the blob itself. Store refusals answer 422 on the field.
  • The file list returns the attached files minus the session's pending removals plus its pending uploads. It sets pending, and gives URLs only for public relations. Caption (needs useCaption), remove (defers the removal, or cancels a pending upload at once) and reorder (attachMany only, the id set must match the visible files exactly) all resolve the file with one parent-scoped query.
  • Protected download and thumb routes serve only is_public=false files, with nosniff, private, no-store and default-src 'none'; sandbox. They serve jpeg, png, gif and webp inline and send everything else as an octet-stream attachment.
  • commitDeferred runs in CRUDService.save after syncBelongsToMany and before FormAfterCreate/FormAfterUpdate. It reads the bindings FOR UPDATE, attaches binds (on attachOne the replaced file is deleted first) and deletes unbound files, with blob deletes after commit. It then rechecks maxFiles and a required fileupload field. A 422 rolls back and leaves the bindings in place.
  • type: datepicker compiles the D-20 keys and maps the PHP format to displayFormat with WinterCMS's momentFormat table; unmapped letters fail boot. Each mode is checked against its Go type (time.Time, lagoon.Date or lagoon.TimeOfDay, or a pointer to one). The field binds as a writable scalar, and the save rechecks minDate/maxDate with Laravel's after_or_equal/before_or_equal text.
  • Lists accept type: date and type: time. isListRelation no longer classes Scanner/Valuer structs as relations.

Task Commits

  1. Task 1: fileupload field, upload and list routes, commit on save - 044e045 (feat)
  2. Task 2: remove, caption, reorder, protected download and thumb, commit rules - e54fd25 (feat)
  3. Task 3: datepicker field, bounds, date and time list columns - 67d4c7f (feat)

The measured count of 4 commits since plan_head_before includes bbb8049 docs(13): capture phase context. Another session committed it on master during this run, and it is not part of this plan.

Files Created/Modified

  • modules/cabana/field_file.go: fileupload compile and boot checks, configBytes, FileItem, fileScope/parentFileScope/findFile/visibleFiles, the seven handlers, the error mapping, AdminFileCaptionRequest
  • modules/cabana/deferred.go: SessionKeyHeader, sessionKeyFrom, commitDeferred and the bind/unbind/delete helpers
  • modules/cabana/field_date.go: datepicker compile, momentFormat, checkDateType, compileDateFields, dateBoundDetails
  • modules/cabana/crud.go: RecordInput.SessionKey, commit and bounds in save, datepicker in scalarFormField, the bucket and translator on CRUDService
  • modules/cabana/http.go: the routes, the nestedGet files case, the body limits read in Activate, the session key on create and update
  • modules/cabana/admin_openapi.go: FileMutationResult and seven AdminFile* doc funcs; the X-Session-Key param on create and update
  • internal/tools/swagger2openapi: formData to a multipart requestBody, {file} responses to binary (JSON errors kept JSON)
  • Tests: the inventories, CSRF walk counts, the conformance fixture (photos public attachMany, manual protected attachOne, released_on/starts_at datepickers, a released_on date column, a memblob bucket) and the smoke tests
  • Docs: cabana README (features, routes, API, configuration), forms (Date pickers, File uploads), lists-and-filters (date/time columns), attachments (Protected files in the admin)

Decisions Made

See key-decisions in the frontmatter.

Deviations from Plan

Auto-fixed Issues

1. [Rule 3 - Blocking] swagger2openapi could not express a multipart upload or a binary response

  • Found during: Tasks 1 and 2
  • Issue: The converter left Swagger 2.0 formData parameters as invalid OpenAPI 3 parameters. It also put every response under every produced media type, so error envelopes would have been listed as octet-stream.
  • Fix: Fold formData parameters into a multipart/form-data requestBody (a file becomes a binary string). Map a {file} response to string/binary under the operation's media types, and keep other schemas under JSON. Covered by TestConvertFormData and TestConvertFileResponse.
  • Files modified: internal/tools/swagger2openapi/main.go, main_test.go
  • Commits: 044e045, e54fd25

2. [Rule 3 - Blocking] Phase 10 tests pin the set of state-changing routes

  • Found during: Task 1
  • Issue: TestPhase10CSRF and TestPhase10Coverage expect exactly 11 unsafe routes.
  • Fix: Added the upload, reorder, caption and remove routes; the count is now 15. All four routes go through requireAjax.
  • Commits: 044e045, e54fd25

3. [Rule 1 - Bug] Conformance fixture leaked files across tests

  • Found during: Task 1
  • Issue: newConformEnv recreates the gadget table, so ids restart. Files attached to "gadget 1" by an earlier test then showed up on a new gadget 1.
  • Fix: The fixture deletes the gadget morph type's system_files rows, unattached rows and deferred_bindings before each run.
  • Commit: 044e045

4. [Rule 2 - Missing critical] Settings forms

  • Found during: Task 1 and Task 3
  • Issue: A settings screen has no controller that owns file routes, so a fileupload field there would render but could never upload. A settings datepicker skipped the D-19 type check.
  • Fix: A fileupload field on a settings form fails boot, and a settings datepicker gets the same Go type check.
  • Files modified: modules/cabana/settings.go
  • Commits: 044e045, 67d4c7f

5. [CLAUDE.md docs rule] Config keys cabana now reads

  • cabana now reads http.body_limits.upload_bytes and default_bytes, so both are listed in the cabana README Configuration table.
  • Commit: 67d4c7f

Not changed although listed

  • modules/cabana/partial_render.go and modules/cabana/query.go: isListRelation is the one relation predicate that list_schema, filter_schema and query use, and it now excludes Scanner/Valuer structs. The struct-kind switches in partial_render.go walk view models for the security guard and never classify relations, so they needed no change.

Total deviations: 5 auto-fixed (2 blocking, 1 bug, 1 missing critical, 1 docs rule) Impact on plan: None add scope beyond the plan's truths; the converter change is needed for the documented upload and download routes.

Issues Encountered

  • GORM logs "record not found" for lagoon's binding lookups (Take in findBinding) during upload. This is noise from plan 01's code, not an error.
  • go test ./... was run with FORCE_COLOR unset (known bonfire noise); the full suite passed at each task commit.

Known Stubs

None.

User Setup Required

None. To store uploads, the host application must publish a storage bucket (attach.Publish). Without one, the upload and protected-file routes answer 500 and log the cause.

Next Phase Readiness

  • Plan 03 can reuse sessionKeyFrom, fileScope (add a child scope constructor; the handler bodies take a resolved scope), and extend commitDeferred with relation bindings: it already reads the session's bindings in the save transaction, keyed by the controller's morph type.
  • Plan 04 (SPA): the form schema carries mode, displayFormat, minDate, maxDate, yearRange, firstDay, twelveHour, ignoreTimezone, fileTypes, mimeTypes, maxFilesize, maxFiles, imageWidth, imageHeight, thumbOptions, useCaption, prompt, multiple and protected. A protected file has no url or thumb_url; the SPA builds the download and thumb route URLs.
  • Plan 05: concurrency of two saves with one key (the FOR UPDATE read serializes them), maxFiles and required at save, and the 413 path deserve dedicated tests.

Self-Check: PASSED

  • Created files exist: field_file.go, field_date.go, deferred.go, fileupload_smoke_test.go, datepicker_smoke_test.go.
  • Commits 044e045, e54fd25 and 67d4c7f are on master in summercms.go; fonoteka.go has no changes.