someonetoldme.org — public site (catcards project). Deployed to https://someonetoldme.org via CI.
  • Python 83.9%
  • HTML 13.6%
  • Shell 1.4%
  • Dockerfile 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-04 10:57:57 +00:00
.forgejo/workflows ci: build+push the app image on runner-pub (replaces the manual runbook procedure; deploy stays digest-pinned) 2026-08-04 10:57:57 +00:00
callcard Serve local media as an allowlist, with byte ranges 2026-08-02 08:07:58 -05:00
cards Serve local media as an allowlist, with byte ranges 2026-08-02 08:07:58 -05:00
docs Serve local media as an allowlist, with byte ranges 2026-08-02 08:07:58 -05:00
templates Add guest book, contact, portability, and site copy 2026-08-01 16:44:23 -05:00
.containerignore Make the app deployable: container, health endpoints, proxy contract 2026-08-01 18:15:01 -05:00
.gitignore Add entries, phone upload, and the media pipeline 2026-08-01 15:11:13 -05:00
Containerfile Harden config for the deployment guest, pin ffmpeg threads 2026-08-01 19:21:37 -05:00
entrypoint.sh Harden config for the deployment guest, pin ffmpeg threads 2026-08-01 19:21:37 -05:00
manage.py Scaffold callcard: subjects, cards, and the etched-code redirect 2026-08-01 14:37:06 -05:00
README.md Serve local media as an allowlist, with byte ranges 2026-08-02 08:07:58 -05:00
requirements.txt Make the app deployable: container, health endpoints, proxy contract 2026-08-01 18:15:01 -05:00

someone told me

Laser-etched, credit-card-sized cards, handed from person to person. Each one carries a QR code and a four-character code that resolves to a page for a fostered cat — what they eat, what they're like, video of them being cats — plus a guest book that records where the card has travelled.

The point is the chain. A card only moves when someone decides to hand it to someone else, so by the time it reaches whoever adopts, it has passed through people who know each other.

Cats are the first deployment, not the product. Nothing in cards/models.py knows what a cat is: all domain language lives in callcard/vocabulary.py. Point it at lost pets, a tool library, or a seed swap by editing that file.

Two properties everything else defers to

Cards are etched, so they are permanent. A code handed out in 2026 must still resolve in 2036, and must be re-pointable when a cat is adopted. Card identity is therefore separate from subject identity — cards are minted unassigned, resolve server-side, and fall back to the front page rather than 404ing.

The redirect is 302 with Cache-Control: no-store, never 301. A 301 is cached by browsers indefinitely; one of those and every phone that already scanned a card is permanently stuck on a pre-adoption target with no server-side fix. There is a test whose only job is to catch a regression here.

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
apt install ffmpeg                       # required for video
.venv/bin/python manage.py migrate
.venv/bin/python manage.py createsuperuser
.venv/bin/python manage.py runserver     # web
.venv/bin/python manage.py qcluster      # media worker, separate process

Without qcluster running, uploads sit at processing indefinitely. They are not lost — they finish when the worker returns — but nothing appears until it does.

Before the printer runs

manage.py make_batch --count 200 --batch 2026-08-first-run
manage.py make_cards --batch 2026-08-first-run --out ./out

make_cards writes one SVG per card plus manifest.csv, and refuses to emit anything unless the QR encodes in alphanumeric mode. That mode holds https://someonetoldme.org/K7QM in a 25×25 grid; byte mode needs 29×29 for the same payload, a ~16% finer module pitch on an 86×54mm etch. Lowercase characters or a query string in CALLCARD_BASE_URL are what would push it over, and both are also caught by a startup check.

Confirm before committing to a print run: scan a generated SVG at final etch size, off a matte surface, in poor light.

The /why copy needs an owner's read

templates/cards/why.html is the page that has to persuade someone holding a card that this isn't just a listing — it is the most load-bearing text on the site. It was drafted by an assistant, in the site owner's first person, and it makes specific claims about how they foster and how they screen adopters.

It reads well and it is not yet true by anyone's authority. Read it and make it yours before any cards are etched pointing at it.

Environment

variable purpose
CALLCARD_BASE_URL The origin etched onto cards. No trailing slash, no query string.
CALLCARD_SECRET_KEY Generate a real one; the default is a dev placeholder.
CALLCARD_DEBUG 0 in production.
CALLCARD_ALLOWED_HOSTS Comma-separated.
CALLCARD_BEHIND_PROXY 1 only when a proxy you control sets X-Forwarded-For. On without one, anyone can spoof past the rate limits.
CALLCARD_STORAGE local (disk) or s3 (R2). See docs/deploy-r2.md.
CALLCARD_MEDIA_ROOT Where local storage keeps media. On a container this must be a mounted volume; the default resolves inside the image and is discarded on rebuild.
CALLCARD_NOTIFY_EMAIL Where inquiries are mailed. Unset just means they only appear in the admin — a broken mailer never loses one.
CALLCARD_EMAIL_* SMTP settings; defaults to the console backend.
CALLCARD_IP_SALT Salt for hashed submitter IPs. Rotating it invalidates old hashes by design.

Processes

Two, plus ffmpeg on the box: a WSGI server on callcard.wsgi, and manage.py qcluster. Static files via manage.py collectstatic.

With CALLCARD_STORAGE=local, Django serves media itself from cards/media.py, which is an allowlist of the four derived files — never the originals, which keep the metadata the pipeline strips, and never _parts/, which holds in-flight upload chunks. It answers byte ranges, because Django has no range support of its own and iOS will not play a video served without them. Putting a web server in front is still a good idea at any real traffic; it must preserve both properties.

Daily: manage.py reap_orphans, plus a backup of the database and, on local storage, of CALLCARD_MEDIA_ROOT. What is irreplaceable depends on the backend: with media in R2 the database is the only thing on the box worth saving, and with media on disk the media directory is the larger half of it.

Commands

command what it does
make_batch Mint unassigned card codes.
make_cards Printer-ready SVG QRs, with the encoding-mode assertion.
export_redirects Flat static redirect map. If this app is ever dead, that file on any static host keeps every card resolving.
import_subject Rebuild a subject from an export.zip.
reap_orphans Delete uploads never attached to an entry.

Portability

Every subject has export.zip (a versioned, namespaced manifest plus all media and the approved signing trail), a matching importer, and an Atom feed. This is deliberate and it replaced a plan to use Solid pods: "you own this" only means something if leaving is a link rather than a support request. Someone who adopts and wants real independence clones this repo, points their own domain at it, and imports the file — federation by cloning rather than by protocol.