diff --git a/openspec/changes/photo-object-storage/.openspec.yaml b/openspec/changes/photo-object-storage/.openspec.yaml new file mode 100644 index 0000000..878dc31 --- /dev/null +++ b/openspec/changes/photo-object-storage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/photo-object-storage/README.md b/openspec/changes/photo-object-storage/README.md new file mode 100644 index 0000000..ef8ace6 --- /dev/null +++ b/openspec/changes/photo-object-storage/README.md @@ -0,0 +1,3 @@ +# photo-object-storage + +Photos to MinIO object storage (.13): Go uploads/serves via S3, admin Filament S3 disk, photos survive redeploys diff --git a/openspec/changes/photo-object-storage/design.md b/openspec/changes/photo-object-storage/design.md new file mode 100644 index 0000000..8a750ad --- /dev/null +++ b/openspec/changes/photo-object-storage/design.md @@ -0,0 +1,42 @@ +## Context + +Phase 3 added local photo uploads to `static/uploads/`. Phase 6 moves them to MinIO (S3-compatible, self-hosted on .13) so they survive deploys and the admin can manage them. Both apps run on .13 alongside MinIO → no new firewall ports (the Go app serves photos through :3020, already open). + +## Goals / Non-Goals + +**Goals:** +- Uploads go to MinIO; public page serves them via the Go app (`/photos/…`). +- Admin can upload photos to the same bucket (Filament S3 disk). +- Existing uploads migrated; external photo URLs keep working. + +**Non-Goals:** +- Public bucket exposure (photos served through the app, not MinIO directly). +- Image resizing/optimisation (future). +- Cloud S3 (this is self-hosted; swapping to AWS S3 later = same S3 API). + +## Decisions + +1. **MinIO on .13** at `/home/sam/Docker/Containers/wherewoof-minio/` (compose: minio/minio server, API 9000, console 9001, volume `wherewoof-minio-data`; creds in a local `.env`, chmod 600). Create bucket `wherewoof` at startup (entrypoint `mc` alias + mb, or a one-off). +2. **Go storage via minio-go/v7**: `UploadPhoto` reads the multipart file → `PutObject` to bucket `wherewoof`, key `tags/{id}.{ext}` → sets `photo_url = /photos/tags/{id}.{ext}`. Serve route `GET /photos/{key...}` → `GetObject` → stream with `Content-Type` + `Cache-Control: public, max-age=86400`. +3. **Config**: `MINIO_ENDPOINT` (host:port), `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_BUCKET` (default `wherewoof`), env-driven; log-mode/no-op if unset (dev keeps working via the existing local fallback? — no: unset → upload returns a friendly error; local file upload is replaced). Decision: if MINIO_* unset, UploadPhoto responds "storage not configured" (dev DB tests unaffected — they don't upload). +4. **Admin S3 disk**: `composer require league/flysystem-aws-s3-v3`; `config/filesystems.php` adds `minio` disk (driver s3, endpoint http://minio:9000 from the admin container — via docker network or host IP 192.168.20.13:9000, use_ssl false); TagResource photo → `FileUpload::make('photo_url')->disk('minio')` storing to `tags/` and setting the URL via the frontend domain (`https://where-woof.com/photos/…`). +5. **Migration**: one-off script copies `static/uploads/*` objects into MinIO with matching keys (dev data; run once). + +## Risks / Trade-offs + +- [MinIO creds in .env files] → chmod 600, gitignored, consistent with existing pattern. +- [Serving through the app adds a hop] → fine (same host, localhost); gives auth/cache control later. +- [Admin image preview needs the frontend URL] → `` cross-origin is fine; uses the public frontend domain. +- [flysystem package version drift with Filament] → pin as Filament requires (uses its own storage config). + +## Migration Plan + +1. Provision MinIO on .13 (compose + env + bucket); verify with `mc`/curl. +2. Go: add minio-go, UploadPhoto→S3, /photos/ route, config; build + verify suite (upload → served). +3. Admin: s3 disk + Filament upload; verify via admin UI (browser). +4. One-off migrate `static/uploads` → MinIO. +5. Deploy: frontend env + binary; admin .env + rebuild container. Verify live photo round-trip. + +## Open Questions + +- Whether to keep the local `static/uploads` fallback for dev (decided: no — MinIO only; dev can point MINIO_ENDPOINT at the .13 instance). diff --git a/openspec/changes/photo-object-storage/proposal.md b/openspec/changes/photo-object-storage/proposal.md new file mode 100644 index 0000000..c485fac --- /dev/null +++ b/openspec/changes/photo-object-storage/proposal.md @@ -0,0 +1,29 @@ +## Why + +Photos currently save to the app's local `static/uploads/` (Phase 3). That's lost on a fresh deploy or multi-instance setup, and the admin can't manage them. Phase 6 moves photos to **MinIO** (S3-compatible object storage) — a self-hosted Docker service on .13, consistent with the infra pattern and requiring no cloud dependency. + +## What Changes + +- **MinIO** container on .13 (API :9000, console :9001, volume-backed) with a `wherewoof` bucket; credentials in a local `.env` (never in the repo). +- **Go frontend**: `UploadPhoto` now PUTs the object to MinIO (`tags/{id}.{ext}`) and stores the serve URL (`/photos/tags/{id}.{ext}`) in `tags.photo_url`. A new `GET /photos/{key...}` route streams the object back (content-type + cache headers). External photo URLs still work unchanged. +- **Laravel admin**: S3 disk (flysystem) pointing at MinIO; Filament `FileUpload` on the Tag resource stores to the same bucket and sets `photo_url` via the frontend's `/photos/` URL. +- **Migration**: copy any existing `static/uploads/*` into MinIO (one-off script). +- **Deploy**: MinIO on .13 (Docker, same host as both apps — no new firewall ports), frontend binary + env (`MINIO_*`), admin `.env` S3 config. + +## Capabilities + +### New Capabilities + +- `photo-object-storage`: MinIO-backed photo storage; uploads + serving via the Go app; photos persist across deploys. + +### Modified Capabilities + +- `database`: none required (photo_url already exists). +- `tag-management` / admin: photo upload available in the Filament admin. + +## Impact + +- **On .13**: `/home/sam/Docker/Containers/wherewoof-minio/` (compose + env + volume), bucket `wherewoof`. +- **Go**: `frontend/internal/handlers/tags.go` (UploadPhoto → MinIO), `photo.go` (serve route), `internal/config` (MINIO_* env), `go.mod` (+minio-go), `main.go` (route). +- **Admin**: `config/filesystems.php` s3 disk, `composer require league/flysystem-aws-s3-v3`, TagResource photo field. +- Env: `MINIO_ENDPOINT/ACCESS/SECRET/BUCKET` in `~/.config/where-woof.env` (.13) and `admin/.env`. diff --git a/openspec/changes/photo-object-storage/specs/photo-object-storage/spec.md b/openspec/changes/photo-object-storage/specs/photo-object-storage/spec.md new file mode 100644 index 0000000..15366a5 --- /dev/null +++ b/openspec/changes/photo-object-storage/specs/photo-object-storage/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Photos stored in MinIO +Photo uploads SHALL be stored as objects in the MinIO bucket, keyed by tag (`tags/{id}.{ext}`), with `tags.photo_url` set to the app's serve path (`/photos/tags/{id}.{ext}`). + +#### Scenario: Upload a photo +- **WHEN** the owner uploads an image on the edit page +- **THEN** the object is stored in MinIO and the tag's `photo_url` points at `/photos/…` + +### Requirement: Photos served by the app +`GET /photos/{key...}` SHALL stream the object from MinIO with the correct content type and cache headers. External photo URLs SHALL continue to work. + +#### Scenario: Public page shows the photo +- **WHEN** a visitor opens an active tag page with an uploaded photo +- **THEN** the image loads via `/photos/…` + +#### Scenario: External URL photo +- **WHEN** `photo_url` is an external https URL +- **THEN** the page uses it directly (unchanged behaviour) + +### Requirement: Photos survive redeploys +Photo objects SHALL persist independently of the app (MinIO volume), so a fresh deploy does not lose photos. + +#### Scenario: Redeploy after upload +- **WHEN** the frontend is redeployed after a photo upload +- **THEN** the photo still loads (object lives in MinIO) diff --git a/openspec/changes/photo-object-storage/tasks.md b/openspec/changes/photo-object-storage/tasks.md new file mode 100644 index 0000000..73ee900 --- /dev/null +++ b/openspec/changes/photo-object-storage/tasks.md @@ -0,0 +1,21 @@ +## 1. MinIO Provisioning + +- [ ] 1.1 Compose + env on .13 (`wherewoof-minio`, API 9000 / console 9001, volume); `docker compose up -d`; create bucket `wherewoof`; verify API (mc or curl) + +## 2. Go Frontend + +- [ ] 2.1 Add `github.com/minio/minio-go/v7`; config (`MINIO_ENDPOINT/ACCESS/SECRET/BUCKET`); client helper (no-op when unset) +- [ ] 2.2 `UploadPhoto` → PutObject (`tags/{id}.{ext}`) → set `photo_url=/photos/tags/{id}.{ext}`; new `GET /photos/{key...}` handler streaming GetObject with content-type + cache headers; route in main.go +- [ ] 2.3 Build + verify: upload via HTTP → object in MinIO → public page + `/photos/…` serve 200 + +## 3. Laravel Admin + +- [ ] 3.1 `composer require league/flysystem-aws-s3-v3`; `config/filesystems.php` `minio` disk (endpoint .13:9000, use_ssl false); `admin/.env` MINIO creds +- [ ] 3.2 TagResource: photo `FileUpload` on the `minio` disk (path `tags/`), URL = `https://where-woof.com/photos/{path}`; verify upload from admin UI + +## 4. Migration & Deploy + +- [ ] 4.1 One-off copy `static/uploads/*` into MinIO (matching keys) +- [ ] 4.2 Deploy: frontend env (`MINIO_*` in ~/.config/where-woof.env) + rebuilt binary + restart; admin .env + rebuild container +- [ ] 4.3 Live round-trip: upload a photo via where-woof.com → loads; via admin → loads +- [ ] 4.4 `openspec validate photo-object-storage`; commit