# postcard Turn a phone photo into a sendable postcard. Upload a photo, it gets composited onto a vintage postcard template (border, stamp corner, handwriting-style caption) entirely in the browser, then either download it right away or upload it for a shareable link. The caption is auto-drafted by assembling real fragments of that day (git activity, what was playing, a note title) via `server/src/caption.js` — never a free-text model call — and stays editable. With no day-context source configured it falls back to a small set of generic postcard-voice lines. Structurally this is a copy of [`latent`](../latent)'s upload → transform → deliver shape and server (`db.js`/`ids.js`/`cleanup.js`/`ratelimit.js`/ `routes.js`), with the transform swapped for postcard compositing. See [PROPOSAL.md](PROPOSAL.md) for the original design writeup. ## Two finishing paths off one editor 1. **Instant** — composite, then download directly, entirely client-side. Optionally generate a share link (explicit action, not automatic). 2. **Send** — uploads the composited image to server storage, returns a link immediately (no delayed-reveal gate — that's `latent`'s gimmick, not this one's). ## Running locally ``` npm install --prefix server npm start --prefix server ``` Then open http://localhost:3098. `server`'s `start`/`dev` scripts load `../.env` automatically via Node's `--env-file-if-exists` — copy `.env.example` to `.env` at the repo root and adjust. `DATA_DIR` defaults to `./server/data` (gitignored) for the SQLite db and image blobs. ## Deploying `docker compose up` using the provided `docker-compose.yml` (copy `.env.example` to `.env` and adjust — `IMAGE`/`HOST_PORT` are only read by compose, the rest are passed through to the container). ## Config All via env vars: - `TTL_HOURS` — link retention (default 72h; shorter than `latent`'s, since a postcard link is meant to be opened once, not browsed later). - `MAX_UPLOAD_BYTES`, `RATE_LIMIT_PER_HOUR` — basic abuse limits; no accounts or auth on any endpoint, the link itself is the credential. - `DAY_SUMMARY_URL` — optional, no code-level default (set it in `.env`). If set, `GET {url}?date=YYYY-MM-DD` is expected to return `{ git?, spotify?, note? }` fragments for that date. `.env` here points it at `https://goonk.se/api/day-summary`, goonk's live endpoint (confirmed returning `{ git, spotify? }` — no `note` field observed yet). A response body containing an `error` key (goonk's shape for a missing/malformed date) is treated the same as no summary. Left unset, captions fall back to generic lines — no invented personal data. - `NOMINATIM_URL` — reverse-geocoding endpoint for the stamp corner's location line (only queried when a photo has EXIF GPS). Defaults to the public `nominatim.openstreetmap.org`; `server/src/geocode.js` throttles to 1 req/sec and caches by rounded coordinate per that API's usage policy. No API key needed. ## Notable implementation details - EXIF is parsed client-side with no dependency (`exif.js`) — just enough to read `DateTimeOriginal` and GPS lat/lon out of a JPEG. Parse failures/missing tags fall back to upload date / no location stamp, never invented data. - The caption's handwriting font is self-hosted (`fonts/caveat-latin.woff2`, Caveat, SIL OFL — see `fonts/OFL.txt`), not loaded from fonts.googleapis.com at runtime. - The upload card offers separate "take a photo" (camera capture) and "choose from library" buttons rather than relying on the OS's file picker to offer both — some mobile browsers skip the chooser and go straight to the camera when `capture` is set on a single input. ## Non-goals No accounts, no "your postcards" history, no delayed-reveal mechanic, no free-text LLM-generated captions, no collaborative/multi-recipient cards.