See what a roll of film actually looks like before you buy one.
AvoidXray is a community archive of film photography. People upload their scans and tag each frame with the camera and the film stock it was shot on. Every stock and every camera then has a page built entirely out of those frames.
Sample images for a film stock usually come from the manufacturer. They are shot by a professional, on a tripod, in good light, and scanned on equipment you do not own. They show you what the stock is capable of, not what it will do for you.
This shows the other thing. Every photo on a stock or camera page was taken by someone in the community and scanned however they scan. Uploads are stored exactly as they arrived, apart from stripping the GPS coordinates out of the EXIF.
- Upload scans in bulk, tag them with camera and film stock, keep the original file
- A page for every film stock and every camera, assembled from community frames
- Albums, following, likes and comments
- Community notes on gear pages, with voting, so corrections come from people who shot it
- Public or private per photo
- Reporting, blocking and a moderation queue for user-submitted gear
Next.js (App Router), React 19, TypeScript, Prisma on PostgreSQL, NextAuth, sharp, Aliyun OSS. Around 40 pages, 50 API routes and 17 models.
It runs on one small VPS with 2GB of memory, and film scans are large: uploads average about 8MB and the biggest on record is 7956x7483. Most of the interesting decisions come from that.
The image pipeline budgets decoded pixels, not file size. A 2MB PNG can
decode to 400 megapixels while a 50MB scan is comparatively modest, so
sharpConfig.ts caps input pixels, libvips threads and cache rather than
trusting an upload limit. Each upload produces a thumbnail, a display rendition
and an untouched original, generated one at a time so two full-size decodes are
never in flight together.
The image cache is doing the job of a CDN. /_next/image is around 40% of
all requests, and resizing from local disk is 2 to 3 times faster than
round-tripping to object storage. prune-image-cache.mjs holds it inside a disk
budget, and clear-build-cache.mjs exists because the obvious
rm -rf .next/cache before a build throws the whole thing away on every deploy.
Feeds are seeded rather than random. A grid that reshuffles on every render
loses your place when you come back from a photo, and a component that shuffles
during render disagrees with its own server HTML. Ordering by md5(id || seed)
gives variety that survives a round trip.
One function decides who can see a photograph. There are roughly forty
queries that need the rule, and a rule remembered forty times is one that will
eventually be forgotten, so they all go through photoVisibility.ts.
check-api-leaks.ts then walks every route and asserts against real responses,
because the first time a passwordHash leak was fixed by hand it missed an
identical second call a few lines further down.
Node 20+ and PostgreSQL.
npm install
cp .env.example .env # fill in
npx prisma generate
npx prisma db push # first run only
npm run devUploads need Aliyun OSS credentials and email needs a Mailtrap key. Everything else runs without them.
| Command | |
|---|---|
npm run dev |
Development server |
npm run build |
Production build |
npm test |
919 assertions |
npm run lint |
ESLint |
src/app/ Routes; api/ holds route handlers
src/components/ Shared UI; ui/ is the design system
src/lib/ Domain logic
scripts/ Tests and maintenance tools
prisma/ Schema; migrations are hand-written SQL in scripts/sql
Production runs under pm2 from ecosystem.config.js:
pm2 start ecosystem.config.js
pm2 save
Start it from that file rather than with pm2 start npm -- run start. With npm
in between, pm2 supervises the shell instead of the server: it reported 79MB for
this app while the Next process under it held 1.3GB, so max_memory_restart
watched something that never grows.
The file sets UV_THREADPOOL_SIZE from the core count and pins the app to a
single fork. Both are load-bearing. sharp puts every render on a libuv thread
before libvips picks it up, so a pool smaller than the render slot count is a
ceiling nothing reports; and the semaphore, the source cache and the rate
limiter are all process-local, so a second worker would quietly double every
bound they exist to enforce.
Each server logs what it decided about the machine on startup:
[capacity] 6 cores, 8000MB total, 5 render slots, libvips concurrency 6
A few rules the codebase depends on:
- Photo visibility goes through
photoVisibility.ts. SpreadPUBLIC_PHOTO, or usevisibleToViewer(viewerId)for a feed belonging to the person viewing it. feedWhereandfeedScopeSqlhave to agree. The random tab needs raw SQL, sophotoFeed.tscarries two implementations and the types stop you adding aFeedScopekey without handling both.- Never
include: { user: true }. It returnspasswordHash. UsepublicUserSelectorbylineUserSelect. - Storage keys never change. Objects are served
immutablefor a year, so replacing an image means a new key and a database update. - Rate limits all live in
rateLimitPolicy.ts. The limiter is in-process, so it is correct only while this runs as a single pm2 fork. - Thread counts and render slots come from
capacity.ts, which reads the machine. Do not write another one by hand: the last set were sized for a 3-core, 2GB box and were still in force two upgrades later, so the resize bought nothing and nothing said so. Memory ceilings are the exception and stay as literals, because each cites a peak measured against real scans. - Build UI from
components/ui/. Restyling in place is how a site ends up with eight kinds of link.
Tests cover the logic where a wrong answer is silent rather than loud: rate limiting, feed scope, profile URLs, link parsing, the image pipeline, duplicate detection, blocks, feedback state and EXIF stripping. There is no component or browser suite, so UI changes are checked by using them.
Commits are one change each, Conventional Commits.
style: means formatting, not CSS.
© Zigao Wang. All rights reserved.
Public for reference only. This is not open source: no permission is given to use, copy, modify or distribute this code, or the AvoidXray name and branding.