deciphered

Tools / Things

antidote

12 Oct 2026 · live, close family only · Jul to Aug 2026 · PHP 8 and MySQL, htmx and Alpine, self-hosted

antidote's timeline on a phone: a post of a jetty and a sunset, above a lake walk

I'm a fairly private person, and I'm not a fan of modern social media: everything shared for maximum reach, in a feed that's mostly things nobody I know posted. antidote is "anti-social media": a place to share photos with family, and to scroll back and actually look at places I've been without them sitting forgotten in a phone gallery.

You see your own posts and posts from people you've connected with, newest first. No algorithm, no adverts, nothing picked for you. Just the places you've been and the cool stuff that was photo-worthy. The name is a bit tongue in cheek - it started as a moderately humorous working title but nothing better came along, so it stuck.

Handling images

the full-screen viewer with the phone held sideways: a jetty with flaps on the posts, photo 1 of 2
the full-screen viewer, showing the 2048 px copy

Photos upload one at a time as they're added, not lumped together in one giant form POST. Before anything leaves the device it's decoded in the browser (honouring the camera's rotation flag) and where necessary, shrunk to 2048 px on the longest side. A 12 MP phone picture goes up as about a 3 MP JPEG.

The server doesn't trust the client-side handling. Files have to be under 8 MB, and the server checks the actual bytes to see what the file is rather than believing its name: JPEG, PNG or WebP, nothing else. Anything over 40 million pixels is refused before it's decoded.

Next, the image is decoded and drawn into three new WebP files: 480px square for grids, 1080 for the feed, 2048 for full screen, never upscaled. The re-encode is multi-functional - location, camera details and anything hidden in the file don't survive being turned into pixels and written out again, so there's no separate EXIF-stripping step that could miss something. Files then get a random 32-character name, and live outside the web root. Every photo is served through PHP, which checks you're allowed to see its post first. Guess an id and you get a 404, the same as for a photo that doesn't exist.

I did build in an extra thing: an admin tool to backdate posts. I had pictures from places going back years, and adding them as new posts would have lumped them all on the day I uploaded them, with no feed to look through, dates attached or locations specified. So when in admin mode, the server reads the date taken from the original before re-encoding, and the post can be backdated to when the photo was taken. For everyone else the EXIF is never read. This was bespoke one-off behaviour, an extra step that didn't need to be there most of the time; I just wanted it so I could fill the feed with some interesting pictures to give a better example of a working tool.

The uploader

the new-post page with six photo tiles: two waiting, two uploading, one done, one red tile rejecting a file that isn't an image
mid-batch: waiting, uploading, done, and one file that isn't a photo

Photos get their own tile in a grid as they are added. A tile gets a progress bar on the bottom that fills as it uploads, and sits faded with a spinner while it's processed. It goes green and full colour once the server has it, and if one fails, the tile highlights red and says why on the tile itself. The post button stays off until everything has finished.

The first version lagged badly with a handful of photos. Each little tile was showing the full-resolution original, so eight 12 to 30 MP images were decoded and held in memory at once. Now one decode per photo makes both the upload copy and a 400 px preview, the preview's temporary copy is freed once the tile has drawn it, and no more than three photos are processed at a time.

The feed

a profile with four post tiles in a grid: a jetty, a field, a cat, and fog
a profile: one tile per post, stacked icon for more than one photo

A post is a caption, an optional place name you type yourself (not GPS, no location data), and as many photos as you like. Ten photos from one day out are one post, not ten.

The timeline loads four posts, and when you scroll far enough down it fetches the next four. Each batch asks for posts older than the last one it showed (by time, then id) rather than "skip the first 20", so a new post arriving while you scroll doesn't shift everything along and show you something twice.

Small on purpose

Plain PHP and MySQL, no chunky framework. The only outside code is two small JavaScript libraries, htmx and Alpine, for a bit of quality of life: likes that update without a reload, the infinite scroll, the full-screen photo viewer. Both are served from antidote itself, not a CDN.

antidote sends a strict CSP (Content Security Policy), telling the browser to run only scripts that come from antidote itself, and never to turn text into code - which shuts out injected scripts. Normal Alpine turns text into code at runtime, so antidote uses Alpine's CSP build instead, with every interactive component registered up front.

Moderation, built then shelved

the People page: one connection, and nobody left to find
everyone you can see posts from

I built moderation early: roles, a report button on posts, an admin queue to handle and action reports, and a few anti-bot sign-up protection tricks. A secure image sharing tool could be used for nefarious purposes, so instead I decided it was never going to be rolled out beyond immediate family, and stopped worrying about it. A useful learning experience nonetheless.

Passwords, then no passwords

Accounts started the usual way: sign up, verify your email, log in with a password, I built that first without much thought. It had what felt like a slightly silly limit of 72 characters on passwords, thanks to bcrypt ignoring everything over 72 bytes and it felt wrong to just silently truncate. No "must contain a symbol" rules, which just makes passwords hard for us to remember and leads to bad practice, Obligatory XKCD.

I also checked new passwords against HaveIBeenPwned. Only the first five characters of the password's SHA-1 hash leave the server - the service sends back every leaked hash starting with those five and the match happens here, so the password is never sent anywhere. It seemed a decent trade-off to find out whether something had turned up in a data breach somewhere.

A personal silly fun thing that no one would ever see, the rejection was tailored to how terrible your password is. Passive-aggressive about your password sucking, rather than telling you to add uppercase, numbers, symbols, sausages, whatever. Seen in a breach a handful of times: "not the worst, but let's use one that hasn't." Over a thousand: "a little too well-known. Give us something fresher." Over a hundred thousand: "you're in very crowded company. Try something less popular." Past a million times:

Bold choice. It's basically public property now; pick literally anything else.

antidote, turning down a password

However, I then decided I didn't want yet another password login: one more to remember, forget, write down and then lose. I was mildly sidetracked with another project at the time and didn't want to start stacking logins either, so antidote's sign-in turned into a separate unified ID service: one account for everything I make.

Signing in is now your email address and a six-digit code emailed to you. A code rather than a link, so email scanners can't burn links by checking them. The ID service then hands antidote a short-lived, signed token saying who you are. antidote checks the signature, and won't accept a token that was meant for a different app.

Signed out every time you put the phone down

antidote's "419 Signed out" page with a Log in button
what "antidote not working" looked like

Moving to emailed codes had a cost I hadn't anticipated; I was getting reports of "antidote not working" on mobile. Being signed out used to mean typing a password - now it meant leaving the app, opening your email, finding a code and coming back. And people were being signed out constantly.

There were two causes, both relating to session lifetime. The session cookie was set with a lifetime of 0, which makes it a browser-session cookie - it only lasts until the browser closes, and on a phone that's whenever the OS decides it wants the memory back. On the server side, PHP's session garbage collection was clearing sessions after 24 minutes of inactivity (the default gc_maxlifetime of 1440 seconds), regardless of how long antidote wanted them to last. Now the cookie lasts 30 days and rolls forward on every visit, and antidote keeps and cleans up its own sessions.

What's left is a plain, responsive tool I can use to scroll back through photos of places I've been, shared with the people I'd show them to anyway. It loads nothing it doesn't need, so it's about as quick as the server can serve it. No adverts, and nothing I didn't ask for.

back to Tools / Things