Case study

photos.madcowsailing.com

A sailing club’s photo site: parents and coaches send regatta and practice photos from their phones, and nothing is public until it has been approved.

Role
Sole developer: the decision record, the server, the phone-side encoding, the tests, and the production checks.
Stack
Cloudflare Pages Functions, D1 and a private R2 bucket; plain HTML, CSS and JavaScript with no framework and no library; email-and-password accounts hashed with scrypt from the runtime’s own crypto module; Cloudflare Turnstile on the request and reset forms; Resend for email; Node’s built-in test runner.
Timeframe
Decided on 26 September 2026, with public albums on production four days later. Still in progress.
Links
photos.madcowsailing.com · Source on GitHub

The problem

At a regatta or a practice, the best photos are on parents’ phones, and there was nowhere the whole team could find them. A shared album was the obvious answer, and it was weighed. What it lacks is what matters when the people in the photos are children: approval before anything is public, a way to take a photo down from the public page itself, and the photo’s metadata gone before it leaves the phone.

That last one was not hypothetical. The day before this site was decided, six phone originals for a trip log sat in this repository’s served folder, one careless commit from going live on the sailing site, each carrying the GPS position where it was taken. Nothing in the build would have refused them. A phone photo says where a family was standing, and an upload form that forgets that is publishing it.

Consent comes from the families’ registration media release, so approval is the one place a photo is checked against it. The design has to make that check unavoidable, and make everything around it safe whether or not the check is perfect.

What I built

A Pages project whose functions sit in front of a D1 database and a private R2 bucket, written in plain HTML, CSS and JavaScript with no framework and no library on either side.

The share page does the heavy work on the phone. Each photo is decoded the moment it is chosen and drawn into a canvas at three sizes, 2560, 1600 and 480 pixels on the long edge, as fresh JPEGs that carry none of the original’s metadata. Only the capture time is read out first, so an album can be put in order. The page sends no more than three photos at once, and never keeps more than one photo’s pixels in memory.

The server trusts none of that. The upload route rebuilds every JPEG before storing it, keeps all three sizes pending, and an admin approves or rejects them a batch at a time. Every image request then goes through a function that reads the photo’s state in the database before serving a byte, and a browser may keep its copy for 300 seconds at most. Each public photo carries a control that hides it at once and queues it for review, so a takedown does not wait for me.

The first release let people in two ways: a shared invite link for families, and a separate Cloudflare Access sign-in for coaches. Both have since been replaced by accounts. A sender asks for one, an admin approves it one team at a time, and the password is hashed with scrypt, the strongest password hash the runtime carries without a library. Admins also enter a code emailed for each sign-in. The old routes stayed open until accounts worked on production, so nobody was left without a way in, and a coach is now simply an account with the coach role. A policy page says who sees the photos, and the site sends its own email from its own domain.

One privacy fault is worth telling, because its test passed. A coach’s upload row kept the second that coach signed in. The same second sits beside the coach’s address in the sign-in provider’s log, so the two together name the coach. A test that searched the row for the coach’s identity passed throughout, because the identity was never in the row; the join was. The row stopped keeping that time, and for as long as that sign-in existed the policy admitted that a send time could still be lined up against the provider’s log. Read on 2026-10-08, just before accounts replaced it, production held no photo sent that way, so the caveat went with the route instead of being kept as history.

A decision I’d defend

Metadata is removed by keeping only what a decoder needs, on the phone and again on the server, never by deleting what is known to be dangerous.

A JPEG is a run of segments. The server’s rebuild keeps the frame, the Huffman and quantisation tables, the restart interval and the scans, byte for byte, and drops everything else: every application segment (EXIF with its GPS, XMP, IPTC, the colour profile), every comment, every marker it does not recognise, and whatever follows the end-of-image marker, which is where a motion photo carries its video. The compressed data is copied as it came, so the picture does not change.

That was checked rather than assumed. Ten Pillow variants and Chrome’s canvas output decoded to identical pixels after the rebuild, and a real local run stored canvas JPEGs with a made-up GPS position spliced in and read none of it back.

The phone’s re-encode already leaves the metadata behind, and the server strips again anyway. The server cannot tell the share page from anything else that posts to the same address, so the second strip is the one that holds whatever arrives.

What it rejected, and why:

The cost is that the strip takes everything, including what a photo needs to look right. Orientation goes, so the share page must draw each photo upright before encoding. Chrome’s decoder already turns all eight orientations by itself, so the page asks the browser once, with a two-pixel test image marked “turn 90°”, rather than turning a photo twice. The colour profile goes, which is harmless only while the canvas stays sRGB. Lossless, arithmetic-coded and 12-bit JPEGs are refused outright. The capture time does leave the phone, because an album needs it. And an iPhone sending to production has not been checked end to end yet; that is an open story.

Outcome

Public albums reached production on 30 September 2026, four days after the platform was decided, and coach sign-in and email followed on 1 October.

Measured on production on 1 October, Lighthouse on a phone profile read 98 for performance on the album list and 96 on an album of twelve photos, with accessibility at 100 and no layout shift on either. Each public route’s slowest request in a sampled minute used 7.1 to 7.5 ms of CPU, against a limit of 10.

The suite is 932 tests on Node’s own runner, with hand-written stand-ins for D1 and R2. The admin guard’s tests were made to fail on purpose before they were trusted: 26 mutations, every one red exactly as predicted.

There are no usage numbers here, because none have been recorded yet, and this page will not estimate them. Since those readings, accounts have replaced the invite link and the coaches’ sign-in, and each team’s albums have a section of their own, and video clips, approved as the photos are, play in the albums beside them. Still in progress.

Parents and coaches looking for the photos rather than the engineering should go straight to photos.madcowsailing.com.