/sub-packages/photo-uploader/CLAUDE.md
CLAUDE.md at /sub-packages/photo-uploader/CLAUDE.md
Path: sub-packages/photo-uploader/CLAUDE.md
photo-uploader — agent notes
Vite + React + TypeScript mobile-first photo uploader for the Photo Uploader epic (#1473 under super-epic #1470). Lives alongside sub-packages/zpreorder and follows the same build conventions.
Port
- Dev:
http://localhost:14189(betweenzmdpreview:14188andaddac-order:14190). - Strict port — if
:14189is in use, Vite fails fast instead of picking another.
Layout
sub-packages/photo-uploader/
index.html # Vite entry — loads /src/index.tsx
package.json # scripts: dev / build / test (vitest)
tsconfig.json
vite.config.ts # host:localhost port:14189; /.netlify/functions proxy
vitest.config.ts # node env, tests in tests/**
postcss.config.js
tailwind.config.js # re-uses the design-system tokens
src/
index.tsx # React bootstrap
app.tsx # Routes: /login, / (uploader)
components/
login-route.tsx # Password gate
upload-route.tsx # Picker + preview + per-file progress/error/retry
styles.css # design-system import + Tailwind
utils/
api-client.ts # login / sign / commit / presigned PUT
auth-state.ts # localStorage "remembered" flag only (NEVER stores token)
orientation.ts # deriveOrientation / roundAspectRatio / deriveGeometry
prepare-upload.ts # HEIC->JPEG (heic2any) + EXIF (exifr) + decode dimensions
slug.ts # deriveSlug(bytes, takenAt, uploadedAt) + SLUG_REGEX
tests/
slug.test.ts
orientation.test.ts
Backend (Cloudflare Worker)
The backend is a single Cloudflare Worker at sub-packages/photo-uploader-worker/. It owns all four photo-uploader API routes and accesses Cloudflare D1 via a native binding.
Pre-Wave-8 cutover (Netlify authoritative): The frontend POSTs to /.netlify/functions/photo-uploader-{login,sign,commit} paths, which resolve to thin shim Functions that forward to the Worker via fetch(). Same-origin is preserved so the SameSite=Strict; HttpOnly session cookie survives.
Post-Wave-8 cutover (CF Pages authoritative): The frontend POSTs to /api/photo-uploader-{login,sign,commit} paths (one-line change in src/utils/api-client.ts), which resolve to Pages Functions (functions/api/photo-uploader-*.ts) that proxy to the Worker via Cloudflare service binding PHOTO_UPLOADER. Same-origin is preserved. The Netlify shim Functions (netlify/functions/photo-uploader-{login,sign,commit}.ts) are kept intact until Wave 9 cleanup.
| Route (Worker) | Purpose |
|---|---|
POST /photo-uploader-login | POST password, timingSafeEqual against PHOTO_UPLOADER_PASSWORD, mint signed cookie (HMAC-SHA256 with PHOTO_UPLOADER_SESSION_SECRET, 24h TTL). |
POST /photo-uploader-sign | Validate cookie, return presigned S3 PUT URL for photos/originals/{YYYY}/{MM}/{slug}.{ext} with a 5-minute expiry (signed via aws4fetch). |
POST /photo-uploader-commit | Validate cookie, upsert the photo row in D1 via env.DB, fire-and-forget the Netlify build hook. |
GET /photos.json | Bearer-authed read endpoint for the build pipeline (pnpm photos:build). |
See sub-packages/photo-uploader-worker/CLAUDE.md for the full secrets runbook, D1 binding setup, and deploy procedure.
Auth flow
- Client POSTs password → server verifies via
passwordMatches(SHA-256 hashed +timingSafeEqual). - Server issues a
body.sigtoken (base64url JSON payload{iat,exp}+ HMAC-SHA256 sig), returns it as:Set-Cookie: photo_uploader_session=...; Max-Age=86400; Path=/; HttpOnly; SameSite=Strict; Secure
- Subsequent calls send the cookie;
verifySessionTokenchecks signature + expiry. - Client stores ONLY a boolean
photo-uploader:rememberedflag inlocalStorageso the UI opens on the upload route next time. The token itself never touches JS.
Upload flow
client file → prepareUpload:
- HEIC? transcode to JPEG (heic2any), fall back to raw HEIC on failure
- read EXIF DateTimeOriginal → takenAt
- decode dimensions, compute aspectRatio / orientation
→ deriveSlug({ bytes, takenAt, uploadedAt }) // YYYYMMDD-HHMMSS-sha256[:8]
→ POST photo-uploader-sign → { putUrl, objectKey, slug }
→ PUT putUrl (raw body, Content-Type must match signer)
→ POST photo-uploader-commit (server logs for now)
Security notes
- HTTPS required in deploys. The password is sent over the network in plaintext before being hashed server-side — only the
Secureattribute on the session cookie protects the subsequent session. Netlify enforces HTTPS on preview and production, which is the intended deploy target. Do NOT host this app on a non-HTTPS origin. - No presigned-PUT size cap. The R2
PutObjectCommandsigner does not pinContentLength, so a client with a valid cookie can upload arbitrarily large files. This is acceptable while access is gated behind the shared password; revisit if the access model loosens.
Known gaps
- No rate limiting. Login and sign endpoints log client IP + UA on every attempt but don’t throttle. Revisit when abuse is observed.
- Commit is a stub. The authoritative DB rebuild happens via
photos:build(peer topic, under epic #1473 Phase 2). The commit endpoint exists so the client has a clear success signal and so we have a single server-side log line per upload. It is not idempotent — retries from the client will produce duplicate log lines (but re-uploads to R2 are harmless because the slug is content-derived, so the key is stable). - HEIC fallback uploads raw bytes. If
heic2anyfails in the browser, the client uploads the HEIC as-is.photos:buildhandles HEIC on the server side. - Per-file progress only during the PUT step. The HEIC transcode + EXIF + decode phases don’t report sub-progress — they show a single “Preparing…” state.
Testing conventions
- Vitest unit tests only. Pure helpers (
slug,orientation,photo-uploader-auth) are covered. Do not addport-bindingtests, Playwright, or browser-level integration here — those happen at review/merge time. - Tests run in the Node environment (
vitest.config.ts); they never loadheic2anyorexifr(those are browser-only and are exercised manually). - New browser logic that’s heavy on user interaction (drag-drop, picker flows) should be verified via
/headless-browseror manual QA rather than tests.
Commit scope
Use the [photo-uploader] prefix for changes inside this sub-package and for its three Netlify functions (netlify/functions/photo-uploader-*). Cross-cutting setup (env vars, setup-local.sh, gitignore) goes under [misc].