- Python 83.9%
- HTML 13.6%
- Shell 1.4%
- Dockerfile 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| callcard | ||
| cards | ||
| docs | ||
| templates | ||
| .containerignore | ||
| .gitignore | ||
| Containerfile | ||
| entrypoint.sh | ||
| manage.py | ||
| README.md | ||
| requirements.txt | ||
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.