Astro · Cloudflare Workers · R2 · D1 · live site
A place for trip photos to live that isn't a dying group chat. I shoot a lot of film-style digital on trips with friends, and every good set of photos used to meet the same fate: compressed into a thread, scrolled past, and gone in a month. Bender Adventure gives each trip a permanent gallery — invite-only, full resolution, and downloadable, so the people in the photos actually end up with them.
The constraint that shaped the whole build was cost. Photo hosting is a great way to accumulate a bill: hundreds of large files per trip, served to a handful of people, forever. Putting the originals in Cloudflare R2 means zero egress fees, so serving a 40-megapixel file to a friend who wants a print costs nothing. That single decision is why the site can stay up indefinitely without becoming a subscription I resent.
Access is per event rather than per site. Each gallery carries its own password, hashed with PBKDF2 and checked in a Worker before any image key is handed out, which means I can send one trip to one group of friends without giving them the rest of the archive. The schema already has a second access mode for real accounts, for when passwords stop being enough.
The caption plate reads camera, lens, shutter, aperture, and ISO straight off the file's EXIF, pulled during ingest and stored alongside the photo. It's a small thing that changes how the galleries feel — less like a shared folder and more like a contact sheet. The download button hands over the untouched original, which is the entire point: a friend who wants to print a shot at 20 inches can, without asking me to dig up the file.
Every photo is stored in three renditions — a 500px thumbnail, a 2048px preview, and the original — generated locally at ingest time rather than resized on the fly. A CLI walks a directory of JPEGs, hashes each file to derive a stable ID, reads its EXIF, builds the three sizes, and uploads them to R2. Serving then costs nothing but a key lookup.
Astro running in a Cloudflare Worker, with D1 holding event and photo metadata and R2 holding the bytes. Images never get a public URL: a route resolves slug / size / id against the database, checks the caller's session for that event, and streams from R2 only if it passes. Photo IDs are the first twelve hex characters of the file's SHA-256, so re-running an ingest is idempotent — the same photo can't land twice, and a re-upload after a caption fix overwrites rather than duplicates.