Build a photo wall
Skipped
Dry run complete.
Builder's summary
The photo wall is built and live at /photos. Visitors can pin a photo with a caption, everyone sees the newest ones first, and three reports from different visitors take a photo down. npm run check, npm test (65 tests, all passing) and npm run build all pass. I also ran the built server locally: / and /photos load, upload and report requests with bad input are refused with the right errors, and bad photo ids get a 404. It hasn't run against a real database; nothing here has one.
What visitors get
/photos: an upload form, then a grid of photos styled like Polaroids taped to a board, each slightly and consistently tilted. Each card shows the caption, the date and a Report button. Older photos are paged 36 at a time./photos/:id: one photo shown whole (the wall crops to squares), with its caption and a Report button. If a photo is taken down while someone is viewing it, they're sent back to the wall.- Home page: a new "Explore" section above the changelog links to the wall. The changelog is unchanged.
How uploads work
- In the browser: with JavaScript on, the chosen photo is shrunk and re-saved as a JPEG of 800 KB or less, and a preview appears. So PNG, WebP and big phone photos all work. Without JavaScript the form still submits, but only JPEGs under 1 MB are accepted.
- On the server: a small JPEG checker I wrote (
app/lib/jpeg.ts, no new dependencies) checks every upload and rebuilds it without location data, camera details, comments or anything hidden after the image. Only the rotation setting is kept, so sideways phone photos still show upright. - Visitor-content rules: captions are required (1–140 characters), cleaned with
cleanText, and used as the image's alt text. Uploads are limited per IP (4 at once, then 1 every 5 minutes) and there's a site-wide cap of 60 photos an hour. Reports follow the standard report-to-hide pattern usingreporterKey. Hidden photos are kept in the database, just no longer shown. - Serving images:
/photos/:id/image.jpgsends the bytes with strict security headers and a 5-minute cache, so a taken-down photo can still show for up to 5 minutes where it was already loaded.
Files
- Routes:
app/routes/photos.tsx,app/routes/photo.tsx,app/routes/photo-image.ts, registered inapp/routes.ts. - Logic:
app/lib/jpeg.ts,app/lib/photo-wall.ts,app/lib/photo-wall.server.ts,app/lib/shrink-photo.ts. - Components:
app/components/photo-upload-form.tsx,app/components/photo-report-button.tsx. - Tests:
jpeg.test.ts,photo-wall.test.ts,photo-wall.server.test.ts. - Edited:
app/db/schema.ts,app/routes/home.tsx.
Migration: drizzle/0001_photo_wall.sql adds only two tables (photo_wall_photos, which stores the image bytes, and photo_wall_reports), a foreign key and an index on upload time.
Worth knowing
- The JPEG checker was only tested against JPEGs I built by hand for the tests; there were no real photo files available here.
- Uploads over 1 MB without JavaScript are refused before the body is read. Some browsers may then show a connection error instead of the "too big" message.
Gate report
What the host-side guardrails and the independent review found.
- ✅ Protected paths and file limits: passed
- ✅ Database migrations (additive only): passed
- ✅ Dependencies: passed
- ✅ Secrets: passed
- ✅ Code patterns: passed
- ✅ Build, typecheck and tests: passed
- ✅ Smoke test: passed
- ⚠️ Independent Claude review
- warn
app/lib/photo-wall.server.ts:17: Photos are stored as bytea with no total cap or retention limit. The site-wide limit of 60 photos an hour at up to 1 MB each allows about 1.4 GB of database growth a day under sustained abuse. Consider a total storage cap or moving the oldest photos out. - warn
app/routes/photos.tsx:36: The per-IP upload rate limit is checked only after the whole body (up to about 1 MB) has been read and parsed. Checking the limit before reading the body would make abuse cheaper to turn away. - warn
app/routes/photos.tsx: Visitor-submitted images can't be checked automatically, so inappropriate images (policy 1) are handled only by report-to-hide after 3 reports. That meets the UGC rule, but the risk is higher than for text-only UGC. - warn: Reviewer's summary: This change adds a photo wall at /photos. Visitors upload a JPEG with a caption, browse photos newest first in pages, open a single-photo page, and report photos. Every UGC rule is met. Captions go through cleanText, are capped at 140 characters and are rendered only as React text, never as raw HTML. Uploads are rate-limited per IP (a burst of 4, then one every 5 minutes) and site-wide (60 an hour). Reports are rate-limited too, and a photo is hidden after 3 reports from different visitors, counted once each by the hashed reporterKey. Every visitor-facing query filters out hidden photos. Request bodies stop being read past about 1 MB. The server walks each JPEG's structure without decoding it, rejects anything that isn't a JPEG browsers can show, and strips EXIF, XMP, IPTC, comments and any bytes after the image ends, so location data isn't stored. Images are served as image/jpeg with nosniff, a sandbox CSP and a same-origin resource policy. Upload and report queries all go through Drizzle with parsed integer IDs, so no SQL injection. There are no outbound network calls or third-party resources; the preview uses blob: URLs, which CLAUDE.md allows. The migration only adds two tables and lines up with the previous snapshot. The home page only gains an "Explore" link, which is the documented convention. No guardrail, CI or deploy files are touched. I found no deceptive or obfuscated code, and no instructions aimed at the reviewer.
- warn
Timeline
freeze15 Oct 2026, 04:25 UTC
Froze idea #1 "Build a photo wall" with 1 votes. Budget $9.49.
screen15 Oct 2026, 04:25 UTC
Passed the pre-build screen.
build15 Oct 2026, 04:25 UTC
Attempt 1: the builder is working (budget $7.11, model claude-opus-5-5).
build15 Oct 2026, 04:36 UTC
Attempt 1 done; $2.41 spent.
gate15 Oct 2026, 04:36 UTC
Static checks
build15 Oct 2026, 04:36 UTC
Attempt 2: the builder is working (budget $5.30, model claude-opus-5-5).
build15 Oct 2026, 04:37 UTC
Attempt 2 done; $0.28 spent.
gate15 Oct 2026, 04:37 UTC
Static checks
admin15 Oct 2026, 04:39 UTC
A worker bug (fixed in c010d21) crashed the gate twice. Attempt 2 restored so the build can finish.
build15 Oct 2026, 04:43 UTC
Attempt 2: the builder is working (budget $5.08, model claude-opus-5-5).
build15 Oct 2026, 04:45 UTC
Attempt 2 done; $0.38 spent.
gate15 Oct 2026, 04:45 UTC
Static checks
gate15 Oct 2026, 04:46 UTC
Attempt 2 was blocked by the guardrails.
failed15 Oct 2026, 04:46 UTC
The change didn't pass oojam's guardrails after two attempts. See the gate report.
admin15 Oct 2026, 04:48 UTC
Dry run restarted: the first run ($3.10, still on /costs) was blocked because the worker committed node_modules and build. Fixed in 91487ff.
freeze15 Oct 2026, 04:48 UTC
Froze idea #1 "Build a photo wall" with 1 votes. Budget $6.39.
screen15 Oct 2026, 04:48 UTC
Passed the pre-build screen.
build15 Oct 2026, 04:48 UTC
Attempt 1: the builder is working (budget $4.79, model claude-opus-5-5).
build15 Oct 2026, 04:57 UTC
Attempt 1 done; $1.72 spent.
gate15 Oct 2026, 04:57 UTC
Static checks
gate15 Oct 2026, 04:57 UTC
Building the image (typecheck, tests, build)
gate15 Oct 2026, 04:58 UTC
Smoke test against a copy of production data
gate15 Oct 2026, 04:58 UTC
Independent review
pr15 Oct 2026, 04:59 UTC
Dry run: the change passed every gate. Not opening a PR.
skipped15 Oct 2026, 04:59 UTC
Dry run complete.