Project · ★★ · Intermediate
Photo Wall
A private photo wall for you and your friends. Tap "Add photos" on your phone, pick a few, and a moment later they are on the wall on every device that knows the password. It shrinks photos before uploading, strips out the hidden location data, and keeps the wall private with expiring image links. Along the way you learn the difference between storing files and storing facts about files, which is how most real apps are built.
You need
- Set up your computer, so you have a terminal, git and Node.
- Claude Code or Codex, set up and logged in: Get Claude Code or Get Codex.
- How to work with an agent. Read it once. It saves hours.
- Keep data, especially the parts on R2 and D1.
- A free Cloudflare account (dash.cloudflare.com/sign-up). The free plan covers everything here.
- A free GitHub account, if you want to publish it: Save it on GitHub.
What you'll learn
- Files versus a database: photos go in R2, the list of photos goes in D1.
- Uploads from a phone: the file picker, progress bars, size and type checks.
- Resizing in the browser with a canvas, and why it also removes the location hidden in photos.
- Secrets: a password the Worker knows that never appears in your code.
- Signed, expiring links (HMAC), so an image address cannot be passed around forever.
What this shows the agent can do
- Build both halves of an app, browser and server, and keep them talking the same format.
- Think about what a stranger could upload, and refuse it on the server, when the brief asks.
- Find a bug across two places at once, when you paste both the browser error and the Worker log.
The brief
Make a new folder for the project. Copy the brief below into a file called BRIEF.md in that folder, or download BRIEF.md. Open the agent in the folder and say:
Read BRIEF.md and start with milestone 1. Stop after each milestone so I can check it.
# Photo Wall — upload from your phone to R2 (★★) Build a private photo wall for me and my friends. On a phone, tap "Add photos", pick a few from the camera roll, and they appear on the wall a moment later, on every device that has the wall password. The photos are stored as files in Cloudflare R2. A small D1 database keeps the list of photos: who added them, when, the caption and the size. The point of the project is to see the difference between the two: files go in file storage, facts about the files go in a database. ## Done looks like - A password screen. The wall password is a Worker secret, never in the code or the repo. The right password is remembered on this device. - "Add photos" opens the phone's photo picker (or camera). Several photos can be picked at once. Each shows a progress bar and then appears at the top of the wall. - Before upload, the browser shrinks each photo to at most 2000 px on the long side and makes a 400 px thumbnail. Re-encoding through a canvas also drops the location data hidden in the photo. - The wall is a grid of thumbnails, newest first, with lazy loading and "Load more" after 30. Nothing jumps around while images load. - Tapping a thumbnail opens the full photo with its caption. Swipe or arrow keys move between photos. - Delete removes the photo files from R2 and the row from D1, after a confirm. - The Worker refuses anything that is not JPEG, PNG or WebP (it checks the first bytes of the file, not just the name), and anything over 5 MB, with a clear message. - Photos are not public: image addresses expire after an hour and stop working without a valid signature. - Live at the address `npx wrangler deploy` prints. No console errors. ## Constraints - One Cloudflare Worker with static assets, an R2 bucket binding and a D1 binding. Use wrangler. Free plan only. - Front end: plain HTML, CSS and JavaScript, no frameworks, no build step. - Secret: `WALL_PASSWORD`, set with `npx wrangler secret put WALL_PASSWORD`. A local `.dev.vars` file for testing, listed in `.gitignore`. - Every `/api/` request carries the password in a header. The Worker compares it in constant time and answers 401 if it is wrong. - R2 keys: `photos/<id>.jpg` and `thumbs/<id>.jpg` (or `.webp`), where `id` is random. Never use the uploaded file name. - D1 table: `photos(id, caption, added_by, width, height, bytes, created_at)`. Captions up to 140 characters, names up to 20. - Image addresses: the list endpoint returns `/img/<key>?exp=<time>&sig=<hmac>`, where the signature is an HMAC-SHA256 of key and expiry using a second secret, `SIGNING_KEY`. The `/img/` route checks both before reading from R2. - No accounts, no tracking. Phone works. ## Milestones 1. **The wall, on one device.** A page with an "Add photos" button that takes files from the phone or laptop, shrinks each one in the browser to 2000 px plus a 400 px thumbnail with a canvas, and shows the thumbnails in a grid. Nothing is uploaded yet. Check: pick five phone photos; the grid shows them within a few seconds, and the shrunk copies are under 1 MB each (log their sizes in the console). > That is the wall for one device, and it forgets everything on reload. The rest of the project is the server that keeps the photos and shares them. 2. **Worker and password.** Set up the Worker with static assets and a password screen. `GET /api/check` returns 200 with the right password and 401 without. Check: running locally, a wrong password shows "Wrong password" and the right one opens an empty wall. 3. **Upload to R2.** `PUT /api/photos` stores the body in R2 under a random key, once for the big copy and once for the thumbnail, with progress shown. The Worker checks type by first bytes and size, and refuses anything else. Check: a 4 MB phone photo arrives in R2 as a file under 1 MB plus a small thumbnail; a text file renamed to `.jpg` is refused; a 20 MB file is refused with "Too big". 4. **The list in D1.** Create the `photos` table with a migration. Upload writes a row. `GET /api/photos?before=<time>` returns 30 rows, newest first. Show the thumbnail grid with `loading="lazy"` and width and height set. Check: upload 40 photos; the wall shows 30, "Load more" shows the rest, and the page does not jump while loading. 5. **Signed image addresses.** Add `SIGNING_KEY`, sign each image address in the list, and check it in `/img/`. Check: copy an image address, change one letter of the signature, open it: 403. Wait past the expiry: 403. 6. **Viewer, captions, delete.** Full-screen viewer with swipe and arrow keys. Add a caption on upload. Delete removes both R2 objects and the D1 row. Check: delete a photo on the phone and reload the laptop: it is gone, and the R2 bucket no longer has its two files. 7. **Go live and test on a phone.** Deploy. Add the secrets to the live Worker. Upload from a real phone on mobile data. Check: a photo taken a minute ago on the phone shows up on the laptop's wall after a reload. ## Stretch - Albums: an `album` column and a filter bar. - A slideshow mode for a TV: full screen, next photo every 8 seconds, new uploads join the loop. - Two passwords: one that can only view, one that can also add and delete. - Drag and drop many files on a laptop. - A storage meter that adds up `bytes` from D1 and shows how much of the 10 GB free R2 storage the wall uses. ## Leave out - Accounts and public uploads with no password. - Video, face recognition, filters and editing. - Sharing to social apps, comments, likes. Start by reading WRITING.md and the guide chapter on data if you need storage.
Milestones
The same milestones as the brief. Tick each one off when its check passes. Do one per sitting if you like. The dot in the list marks the point where you have something that works on its own. Everything after it makes it remember things or work for more than one person. Stop at the dot and use what you built before you go on.
- The wall, on one device. A page with an "Add photos" button that takes files from the phone or laptop, shrinks each one in the browser to 2000 px plus a 400 px thumbnail with a canvas, and shows the thumbnails in a grid. Nothing is uploaded yet.
Check: pick five phone photos; the grid shows them within a few seconds, and the shrunk copies are under 1 MB each (log their sizes in the console).
- That is the wall for one device, and it forgets everything on reload. The rest of the project is the server that keeps the photos and shares them.
- Worker and password. Set up the Worker with static assets and a password screen.
GET /api/checkreturns 200 with the right password and 401 without.Check: running locally, a wrong password shows "Wrong password" and the right one opens an empty wall.
- Upload to R2.
PUT /api/photosstores the body in R2 under a random key, once for the big copy and once for the thumbnail, with progress shown. The Worker checks type by first bytes and size, and refuses anything else.Check: a 4 MB phone photo arrives in R2 as a file under 1 MB plus a small thumbnail; a text file renamed to
.jpgis refused; a 20 MB file is refused with "Too big". - The list in D1. Create the
photostable with a migration. Upload writes a row.GET /api/photos?before=<time>returns 30 rows, newest first. Show the thumbnail grid withloading="lazy"and width and height set.Check: upload 40 photos; the wall shows 30, "Load more" shows the rest, and the page does not jump while loading.
- Signed image addresses. Add
SIGNING_KEY, sign each image address in the list, and check it in/img/.Check: copy an image address, change one letter of the signature, open it: 403. Wait past the expiry: 403.
- Viewer, captions, delete. Full-screen viewer with swipe and arrow keys. Add a caption on upload. Delete removes both R2 objects and the D1 row.
Check: delete a photo on the phone and reload the laptop: it is gone, and the R2 bucket no longer has its two files.
- Go live and test on a phone. Deploy. Add the secrets to the live Worker. Upload from a real phone on mobile data.
Check: a photo taken a minute ago on the phone shows up on the laptop's wall after a reload.
Make it yours
Once it works, pick one of these and ask for it as a new milestone.
- Albums: an
albumcolumn and a filter bar. - A slideshow mode for a TV: full screen, next photo every 8 seconds, new uploads join the loop.
- Two passwords: one that can only view, one that can also add and delete.
- Drag and drop many files on a laptop.
- A storage meter that adds up
bytesfrom D1 and shows how much of the 10 GB free R2 storage the wall uses.
If it goes wrong
Every upload fails with 401.
The password header is missing, or the local .dev.vars file was not set up, or the live secret was never added. Say this to the agent:
Uploads fail with 401. Check the password header is sent on every request, and tell me how to set WALL_PASSWORD locally and on the live Worker.
Photos from the phone come out sideways.
The camera stored the rotation as a hidden flag. Drawing to a canvas can lose it. Say this to the agent:
Photos from my phone are rotated. Use createImageBitmap with imageOrientation set to from-image before drawing to the canvas, and test with a portrait photo.
Images break after an hour.
That is the expiry working. The wall needs to ask for fresh links. Say this to the agent:
Images go broken after the page has been open a while. When a signed link fails, fetch a fresh list and swap in the new addresses.