--- phase: 05-data-layer-full-fidelity plan: 04 subsystem: database tags: [attach, system_files, gocloud, blob, imaging, thumbnails, morph] requires: - phase: 05-data-layer-full-fidelity provides: Winter-directory registries, Album/Collection models, lagoon.Migrate, HasAfterDelete provides: - framework-owned system_files table migrated before every plugin set - lagoon/attach File model, gocloud.dev/blob bucket, Winter-exact Thumb() naming with lazy imaging - DeleteForOwner two-phase contract (rows in-tx, blobs after commit) - StaticHandler for public attachments (tested, not route-wired) - Album/Collection MorphName PHP class strings affects: [05-05-remaining-models, 05-06-tests, 06-http, 12-api-parity] tech-stack: added: - gocloud.dev v0.46.0 - github.com/disintegration/imaging v1.6.2 patterns: - lagoon.Migrate runs attach.Migrations before iterating plugins - Thumb exists-check then resize; memblob in tests, fileblob in config - DeleteForOwner collects blob keys in-tx; DeleteKeys runs after commit key-files: created: - lagoon/attach/file.go - lagoon/attach/file_test.go - lagoon/attach/migrations.go - lagoon/attach/bucket.go - lagoon/attach/bucket_test.go - lagoon/attach/thumb.go - lagoon/attach/thumb_test.go - lagoon/attach/static.go - lagoon/attach/static_test.go - lagoon/attach/lifecycle_test.go - config/storage.yaml modified: - lagoon/migrations.go - lagoon/migrations_test.go - go.mod - go.sum - plugins/golem15/fonoteka/models/album.go - plugins/golem15/fonoteka/models/collection.go key-decisions: - "Blob keys are partition+disk_name (no public/protected prefix); fileblob roots at storage/app/uploads" - "DeleteForOwner afterCommit is an in-tx key collector; DeleteKeys removes originals and thumb__ prefixes after commit" - "Album/Collection MorphName returns the PHP class string and does not import attach (models stay a leaf)" patterns-established: - "system_files is framework-owned; lagoon.Migrate always runs it first, even with an empty plugin list" - "StaticHandler rebuilds keys from disk_name via PartitionDirectory; unvalidated path segments never reach NewReader" requirements-completed: [DATA-08, DATA-09] duration: 18min completed: 2026-09-18 --- # Phase 5 Plan 04: Attachments Summary **Framework-owned `system_files` with gocloud.dev/blob, Winter-exact `Thumb()` naming and lazy `disintegration/imaging` resize, post-commit force-delete blob removal, and Album/Collection morph names** ## Performance - **Duration:** 18 min - **Started:** 2026-09-18T17:46:04Z - **Completed:** 2026-09-18T18:03:39Z - **Tasks:** 3 - **Files modified:** 18 ## Accomplishments - `lagoon.Migrate(gdb, nil)` creates `system_files` (Winter column set plus metadata and a composite attachment lookup index) before any plugin set - `ThumbFilename` / `PartitionDirectory` match Winter Storm (`thumb_42_200_200_0_0_crop.jpg`, `abc/123/xyz/`); `File.Thumb` resizes once via `disintegration/imaging` and reuses the blob on the second call - `OpenBucket` fails loud on empty `storage.uploads.bucket_url`; `fileblob`/`memblob` via `gocloud.dev/blob`; `fonoteka.go/config/storage.yaml` mirrors `cms.php` uploads - Soft-delete of an owner keeps `system_files` rows and blobs; force-delete removes rows inside the transaction and blobs (original + `thumb__`) only after commit - `StaticHandler` serves exact `prefix//` keys with `Content-Type` and 404s traversal/mismatch; not mounted on any route - Album and Collection implement `attach.Owner` with `Golem15\Fonoteka\Models\Album` and `...\Collection` ## Task Commits Each task was committed atomically: 1. **Task 1 RED: attach tests** - `2d84ae4` (test) in summercms.go 2. **Task 1 GREEN: File, Thumb, bucket, system_files migration** - `ba9c992` (feat) in summercms.go 3. **Task 1: storage.uploads config** - `81ca63b` (feat) in fonoteka.go 4. **Task 2: delete lifecycle and static handler** - `d8ecb41` (feat) in summercms.go 5. **Task 3: Album/Collection MorphName** - `a2c3816` (feat) in fonoteka.go **Plan metadata:** pending (this file) ## Files Created/Modified - `lagoon/attach/file.go`, `file_test.go` — `File` (`system_files`), `Owner`, `DeleteForOwner`/`DeleteKeys` - `lagoon/attach/migrations.go` — `202609180001_create_system_files` - `lagoon/attach/bucket.go`, `bucket_test.go` — `OpenBucket`/`Publish`/`PublicPathPrefix` - `lagoon/attach/thumb.go`, `thumb_test.go` — Winter naming + lazy imaging - `lagoon/attach/static.go`, `static_test.go` — public file handler (httptest only) - `lagoon/attach/lifecycle_test.go` — soft-delete keep / force-delete two-phase - `lagoon/migrations.go`, `migrations_test.go` — attach set runs first; `TestMigrateRunsSystemFilesFirst` - `go.mod`, `go.sum` — `gocloud.dev v0.46.0`, `disintegration/imaging v1.6.2` - `fonoteka.go/config/storage.yaml` — `uploads.bucket_url` / `public_path_prefix` - `fonoteka.go/plugins/golem15/fonoteka/models/{album,collection}.go` — `MorphName()` - `fonoteka.go/plugins/golem15/fonoteka/go.mod`, `go.sum` — transitives from `lagoon` importing `attach` ## Decisions Made - Blob keys are `PartitionDirectory(disk_name)+disk_name` with no `public/`/`protected/` prefix; the fileblob root is `storage/app/uploads` (cutover can point the URL at `.../uploads/public`) - `DeleteForOwner`'s `afterCommit` callback only records keys while still inside the GORM transaction; `DeleteKeys` is the post-commit blob remover (prefix keys ending in `_` are listed) - Models satisfy `attach.Owner` by method set only and do not import `lagoon/attach`, keeping `models/` a leaf ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Lifecycle tests cannot live in package attach and import lagoon** - **Found during:** Task 2 (TestFileLifecycle) - **Issue:** `lagoon` already imports `lagoon/attach`; an internal-package test importing `lagoon` is a cycle - **Fix:** `lifecycle_test.go` is `package attach_test` so it can call `lagoon.Migrate`/`lagoon.Use` against a dedicated testcontainers Postgres - **Files modified:** `lagoon/attach/lifecycle_test.go` - **Verification:** `go test ./lagoon/attach/... -run TestFileLifecycle` - **Committed in:** `d8ecb41` (Task 2) --- **Total deviations:** 1 auto-fixed (1 blocking) **Impact on plan:** Required for a compiling lifecycle test that proves `Migrate(gdb, nil)` plus the two-phase delete contract. No scope creep. ## Issues Encountered None beyond the auto-fix above. ## Authentication Gates None. ## TDD Gate Compliance - RED: `2d84ae4` `test(05-04): add failing tests for attach File, Thumb, and bucket` - GREEN: `ba9c992` `feat(05-04): implement attach File, Thumb, blob bucket, system_files migration` - REFACTOR: not needed - Task 1 is `tdd="true"`; Tasks 2–3 are standard `type="auto"` ## Known Stubs - `StaticHandler` is intentionally not registered on any plugin route (D-16 / this phase's HTTP-route boundary; `is_public` gate is Phase 6/12, T-05-16) - HTTP upload endpoint stays Phase 12 (API-02) ## User Setup Required None - no external service configuration required. Operators set `storage.uploads.bucket_url` (default `file://./storage/app/uploads` in `config/storage.yaml`). ## Next Phase Readiness Ready for 05-05 (remaining models). Attachments are usable from Album/Collection via `MorphName()` and `system_files`. Plan 05-06 should smoke-test photos/image query helpers on the classes/ layer. ## Self-Check: PASSED - Key files exist on disk (`lagoon/attach/{file,migrations,bucket,thumb,static}.go`, `fonoteka.go/config/storage.yaml`, MorphName on Album/Collection) - Commits `2d84ae4`, `ba9c992`, `d8ecb41` (summercms.go) and `81ca63b`, `a2c3816` (fonoteka.go) exist - `go vet ./lagoon/...` and `go test ./lagoon/attach/...` green in summercms.go (short and full) - `go vet ./...` and `go build ./...` green in fonoteka.go - `go.mod` pins `gocloud.dev v0.46.0` and `github.com/disintegration/imaging v1.6.2` - `ThumbFilename(42, 200, 200, 0, 0, "crop", "jpg")` is `thumb_42_200_200_0_0_crop.jpg`; `PartitionDirectory("abc123xyz.jpg")` is `abc/123/xyz/` --- *Phase: 05-data-layer-full-fidelity* *Completed: 2026-09-18*