trails/openspec/changes/activity-photos/design.md
Ullrich Schäfer 0a330e4466
Break up route-features into focused specs, add new changes
Archive the monolithic route-features spec and replace with 9 focused
OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping),
undo-redo, local-dev-stack, route-sharing, route-discovery,
activity-photos, osm-overlays, plus the existing changelog and
komoot-import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:53:41 +02:00

187 lines
7.9 KiB
Markdown

## Context
The Journal stores activities in `journal.activities` with text fields (name,
description, stats) and an optional GPX track. The existing `photos` jsonb
column on activities was a placeholder -- it stores an array of strings but has
no upload pipeline, no storage backend, and no UI.
The architecture plan calls for Garage (S3-compatible, self-hosted) as the
object store. The Garage container is already defined in docker-compose.yml but
commented out. The Journal app has no S3 client code.
Activity detail page (`activities.$id.tsx`) currently shows stats, description,
and route linking -- no photo display.
## Goals / Non-Goals
**Goals:**
- Photo upload on activity pages, stored in S3-compatible storage (Garage)
- Photo gallery display on activity detail page
- Client-side image processing (resize, EXIF strip) before upload
- Presigned URL upload flow (no server proxying)
- Soft delete with S3 cleanup
- Privacy manifest update
**Non-Goals:**
- Video uploads
- GPS-tagged photo placement on route maps
- Photo editing, cropping, or filters
- CDN or edge caching
- Cross-activity photo albums
## Decisions
### D1: Storage -- Garage (S3-compatible, self-hosted)
Enable the existing Garage container in docker-compose.yml. Single bucket
(`activity-photos`) for all photo objects. Keys follow the pattern:
```
{activityId}/{photoId}.webp
```
Garage runs as an internal service -- not exposed to the public internet. The
Journal app generates presigned URLs for both upload (PUT) and download (GET).
This keeps the storage layer behind the application.
Environment variables added to the Journal service:
- `S3_ENDPOINT` -- internal Garage URL (e.g. `http://garage:3900`)
- `S3_ACCESS_KEY` / `S3_SECRET_KEY` -- Garage API credentials
- `S3_BUCKET` -- bucket name (`activity-photos`)
- `S3_PUBLIC_ENDPOINT` -- optional public URL for serving photos (falls back
to presigned GET URLs if not set)
### D2: Upload flow -- presigned PUT URLs
The client never sends photo bytes to the Journal app server. Flow:
1. Client selects photo(s) and processes them (resize, EXIF strip, convert to
WebP)
2. Client requests a presigned PUT URL from `POST /api/activities/:id/photos`
with metadata (filename, content type, dimensions)
3. Server validates the user owns the activity, generates a presigned PUT URL
(5 min expiry), creates a pending `activity_photos` row, returns the URL
and photo ID
4. Client uploads directly to Garage using the presigned PUT URL
5. Client confirms upload via `PATCH /api/activities/:id/photos/:photoId` with
`status: 'uploaded'`
6. Server verifies the object exists in S3 and marks the photo as active
This avoids proxying large files through the Node.js server and keeps the app
server lightweight. Pending photos that are never confirmed are cleaned up by
a periodic check (or on next page load).
### D3: Schema -- `journal.activity_photos` table
```sql
journal.activity_photos (
id text PRIMARY KEY,
activity_id text NOT NULL REFERENCES journal.activities(id) ON DELETE CASCADE,
s3_key text NOT NULL,
alt_text text,
width integer,
height integer,
size_bytes integer,
status text NOT NULL DEFAULT 'pending', -- 'pending', 'active', 'deleted'
created_at timestamptz NOT NULL DEFAULT now()
)
```
The existing `photos` jsonb column on `journal.activities` is superseded by
this table. It can be dropped in a future migration once this feature is stable.
Photos are ordered by `created_at` (upload order). No explicit `position`
column initially -- reordering can be added later if users request it.
### D4: Image processing -- client-side resize and EXIF strip
All image processing happens in the browser before upload:
- **Resize**: Max 2048px on the long edge. Maintains aspect ratio. Uses
canvas `drawImage` for the resize.
- **Format**: Convert to WebP (broad browser support, good compression). Fall
back to JPEG for browsers without WebP encode support.
- **EXIF stripping**: Read the image via canvas `drawImage` -- this
inherently drops EXIF data since canvas does not preserve metadata. This
removes GPS coordinates, camera info, timestamps, and other metadata that
users may not want to share.
- **Size limit**: Max 5 MB per photo after processing. The presigned URL has a
content-length condition enforcing this.
No server-side image processing. This keeps the server simple and avoids
adding Sharp or similar native dependencies.
### D5: Gallery -- grid display with lightbox
Activity detail page shows photos in a responsive grid:
- 1 column on mobile, 2 on tablet, 3 on desktop
- Aspect ratio preserved via `object-cover` in fixed-height cells
- Lazy loading via `loading="lazy"` on `<img>` tags
- Click opens a lightbox overlay with full-size image, left/right navigation,
close on escape/click-outside
- Alt text shown below image in lightbox (if provided)
- Owner sees an "Add photos" button and delete icons on each photo
The gallery uses presigned GET URLs with 1-hour expiry, generated at page load
time in the loader. For the lightbox, the same URLs are reused (they are valid
long enough for a viewing session).
### D6: Deletion -- soft delete with background cleanup
Deleting a photo sets `status = 'deleted'` in the database. The photo
immediately disappears from the UI. The actual S3 object is cleaned up:
- On the next page load of the activity (loader checks for deleted photos and
removes them from S3)
- Or by a periodic cleanup that runs on app startup and every 24 hours
This avoids the complexity of a separate job queue while ensuring S3 objects
are eventually cleaned up. The cleanup is idempotent -- deleting a
non-existent S3 key is a no-op.
### D7: Privacy -- manifest and EXIF documentation
Update the privacy page to document:
- **Photo storage**: Photos uploaded to activities are stored in S3-compatible
object storage (Garage) on the same server as the Journal. Self-hosters
control their own storage.
- **EXIF stripping**: All photo metadata (GPS coordinates, camera info,
timestamps) is stripped in the browser before upload. The server never sees
original EXIF data.
- **Deletion**: Deleted photos are removed from storage. No backups are kept
beyond what the storage system provides.
- **Data export**: Photos are included in data exports (future).
### D8: Limits -- per-activity and per-photo constraints
- Max **20 photos** per activity. Enforced server-side when generating
presigned URLs. Client shows remaining count.
- Max **5 MB** per photo (after client-side processing). Enforced via
presigned URL content-length condition.
- Max **2048px** long edge (client-side resize). Not enforced server-side --
the 5 MB limit is the hard constraint.
- Supported input formats: JPEG, PNG, WebP, HEIC (converted to WebP on
client).
## Risks / Trade-offs
- **Garage operational complexity**: Adding another container increases the
operational surface. Garage is lightweight but needs monitoring. Mitigate by
adding a Garage health check to the existing Prometheus setup.
- **Client-side processing reliability**: Canvas-based resize may behave
differently across browsers, especially for HEIC on non-Safari browsers.
Mitigate by testing on Chrome, Firefox, and Safari, and falling back to
JPEG if WebP encoding fails.
- **Presigned URL expiry**: If a user's upload takes longer than 5 minutes
(slow connection, large batch), the presigned URL expires. Mitigate by
generating URLs one at a time as each upload starts, not all upfront.
- **No server-side validation of image content**: The server trusts that the
client uploaded a valid image. A malicious user could upload non-image data.
Mitigate by checking Content-Type on the S3 object during confirmation step
and adding server-side validation later if abuse occurs.
- **EXIF stripping depends on canvas**: The canvas approach strips EXIF
reliably but loses all metadata including orientation. The resize step
handles orientation via `createImageBitmap` with `imageOrientation: 'from-image'`
before drawing to canvas.