A dynamic QR code service: submit a URL, get back a short token + scannable PNG. The QR encodes a short URL that 302-redirects through this server, so the destination can be modified after the QR has been printed.
Built in stages from the build-moat-live-sessions QR exercise scaffold. See DECISIONS.md for behavioral differences from the reference answer, and ANSWERS.md for the 5 design-question write-ups.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/qr/create |
session | Create a short URL + QR code (optional expires_at). Returns a one-time edit_token for programmatic clients. |
GET |
/r/{token} |
none | 302 redirect to the original URL (cache → DB → 404/410). |
GET |
/api/qr/{token} |
none | Metadata (URL, timestamps, deletion/expiry state). |
PATCH |
/api/qr/{token} |
session OR bearer | Update target URL and/or expiration. Owner shortcut: a signed-in caller who owns the mapping skips the bearer. |
DELETE |
/api/qr/{token} |
session OR bearer | Soft delete; row stays in DB, subsequent redirects return 410. Recorded in audit_logs. |
GET |
/api/qr/{token}/image |
none | PNG of the QR. Optional style query params (see below): fill, back, scale, border, ecc, module, gradient, fill2. |
POST |
/api/qr/{token}/image |
none | Same render, multipart — adds an optional center logo file (binary, so it can't ride a GET query string). |
GET |
/api/qr/{token}/analytics |
none | Total scans + scans-by-day breakdown. |
GET |
/api/qr/{token}/audit |
owner | History of every mutation (create / patch / delete / rotate). Readable even after delete; non-owner gets 403. |
POST |
/api/qr/{token}/rotate-edit-token |
session OR bearer | Issue a fresh edit_token; old one is invalidated. |
GET |
/api/qr/mine |
session | List the signed-in user's QRs (anonymous returns empty). |
GET /api/qr/{token}/image takes optional query params to restyle the
PNG. Styling is stateless — a pure function of the params, never
stored on the mapping — so it never changes where the QR points, and
the same token can be rendered in different palettes for different
contexts. The web UI exposes all of these live under 🎨 Customize
appearance in the result panel.
| Param | Default | Range / values | Effect |
|---|---|---|---|
fill |
000000 |
hex rgb/rrggbb, # optional (encode as %23) |
Color of the dark modules (gradient start). |
back |
ffffff |
same as fill |
Background / quiet-zone color. |
scale |
10 |
1–40 |
Pixels per module — higher = larger, sharper PNG. |
border |
4 |
0–20 |
Quiet-zone width in modules (spec recommends ≥ 4). |
ecc |
M |
L / M / Q / H |
Error-correction level (~7 / 15 / 25 / 30 % recovery). |
module |
square |
square / rounded / circle / gapped |
Module (dot) shape. |
gradient |
none |
none / radial / square / horizontal / vertical |
Foreground gradient; sweeps fill → fill2. |
fill2 |
5b9eff |
same as fill |
Gradient end color (only used when gradient ≠ none). |
# Rounded navy→blue radial gradient, hi-res:
curl "http://localhost:8000/api/qr/Xedis7d/image?module=rounded&gradient=radial&fill=%231a3b7c&fill2=%235b9eff&scale=16" -o qr.pngCenter logo is the one knob that can't ride a query string (it's
binary), so it uses the POST twin as multipart/form-data — same
fields as Form parts, plus a logo file and an optional logo_ratio
(0.1–0.3, default 0.22). A logo occludes the center modules, so the
server forces ecc=H whenever one is present. The render stays
stateless: the logo is composited into that one response, never stored.
# Rounded gradient QR with a centered logo:
curl -X POST "http://localhost:8000/api/qr/Xedis7d/image" \
-F "module=rounded" -F "gradient=radial" -F "fill=1a3b7c" -F "fill2=5b9eff" \
-F "logo=@logo.png" -o qr-with-logo.pngBad input is rejected: 422 for malformed hex, out-of-range size,
unknown ecc / module / gradient, a non-image logo, or fill ==
back on a solid (non-gradient) fill — which would render an unscannable
solid block; 413 for a logo over 2 MB.
| Method | Path | Description |
|---|---|---|
POST |
/api/auth/request-link |
Magic-link sign-in. Dev mode prints the link to the server console; production sends email. |
GET |
/api/auth/verify?token=... |
Consume a magic link, create session, set qrs_session cookie. |
GET |
/api/auth/me |
Current user (or null if signed out). |
POST |
/api/auth/logout |
Delete session row + clear cookie. |
GET |
/api/auth/github/login |
OAuth: redirect to GitHub authorize (only registered when GITHUB_CLIENT_ID set). |
GET |
/api/auth/github/callback |
OAuth callback: exchange code, find-or-create user, set session. |
GET |
/api/auth/github/available |
Probe whether GitHub OAuth is configured server-side. |
Two ways to authenticate a mutation:
- Browser users — sign in via magic link or GitHub; the
qrs_sessioncookie carries the credential. The UI never asks for anedit_token. - Programmatic clients (CI scripts, curl, automation) — issue an
edit_tokenfrom the web UI's API token button (next to the Token field in any owned QR's result panel), then send it asAuthorization: Bearer <token>onPATCH/DELETE/rotate-edit-token. The DB stores only the SHA-256 hash; losing the plaintext means rotating to issue a fresh one. Full walkthrough + curl examples + FAQ indocs/API.md.
Accounts unify on email: signing in via magic link and then via GitHub with the same email merges into one user row. GitHub primary-email matches link an existing magic-link account to the GitHub identity; differing emails create separate users.
| Endpoint | Limit | Notes |
|---|---|---|
POST /api/qr/create |
10 / min / IP | Hardest path: hash + retry + DB write. |
GET /r/{token} |
300 / min / IP | Plus per-(token, ip) 1-second dedup on the scan-event INSERT, so refresh-spam can't bloat scan_events. |
PATCH /api/qr/{token} |
30 / min / IP | Defense-in-depth on the bearer-token check. |
DELETE /api/qr/{token} |
30 / min / IP | Same as PATCH. |
All via slowapi. Buckets are keyed by (endpoint, IP) so an attacker iterating tokens shares one bucket per handler. Default backend is in-process memory; set RATE_LIMIT_STORAGE_URI=redis://... to share buckets across uvicorn workers.
All configuration is centralized in app/config.py and reads os.environ at import time. Every value has a dev-safe default — set the env var only to override.
| Env var | Default | What it does |
|---|---|---|
DEPLOY_ENV |
dev |
production hides /docs, disables the dev no-cache middleware + dev BASE_URL auto-derive, and enables Secure session cookies. |
DATABASE_URL |
sqlite:///./qr_code.db |
SQLAlchemy URL. Postgres / MySQL also work. |
BASE_URL |
(see notes) | Public URL encoded into the QR + returned in short_url. Dev: leave blank — auto-derives from request.base_url so the URL always matches your actual port. Prod: set explicitly (e.g. https://qr.example.com) so QRs encode the public domain, not the internal proxy address. |
CREATE_RATE_LIMIT |
10/minute |
slowapi expression for POST /api/qr/create. |
REDIRECT_RATE_LIMIT |
300/minute |
slowapi expression for GET /r/{token}. |
MUTATION_RATE_LIMIT |
30/minute |
slowapi expression for PATCH/DELETE. |
RATE_LIMIT_STORAGE_URI |
memory:// |
redis://host:6379/0 to share buckets across workers. |
SCAN_DEDUP_WINDOW |
1.0 |
Seconds; per-(token, ip) burst dedup on scan recording. |
SCAN_FLUSH_BATCH_SIZE |
10 |
Buffered scans flush after N rows. |
SCAN_FLUSH_INTERVAL |
5.0 |
Buffered scans flush after N seconds since last flush. |
SESSION_COOKIE_NAME |
qrs_session |
Cookie name for the opaque session token. |
SESSION_TTL_DAYS |
30 |
How long a session row stays valid. |
MAGIC_LINK_TTL_MINUTES |
15 |
How long a magic link stays redeemable. |
AUTH_REQUEST_RATE_LIMIT |
3/minute |
Per-IP cap on POST /api/auth/request-link. |
EMAIL_PROVIDER |
(empty) |
console / (empty) prints the magic link to stdout (dev). resend routes through Resend's HTTP API (requires RESEND_API_KEY). |
EMAIL_FROM |
noreply@localhost |
Sender address. For Resend without a verified domain, use onboarding@resend.dev. |
RESEND_API_KEY |
(empty) |
Only required when EMAIL_PROVIDER=resend. Sign up at https://resend.com (free: 3000/month). |
GITHUB_CLIENT_ID |
(empty) |
Set to enable GitHub OAuth. Register at https://github.com/settings/developers. |
GITHUB_CLIENT_SECRET |
(empty) |
Paired with GITHUB_CLIENT_ID. |
The app loads .env from the project root automatically at startup via python-dotenv — copy .env.example to .env, fill in values, restart uvicorn. Real env vars (e.g. those set by Render / Docker) still take precedence so a deployed instance isn't shadowed by a stray local .env.
┌─────────────────────────────────────────────────────────────────┐
│ Browser / QR scanner │
└──────────────┬──────────────────────────────┬───────────────────┘
│ GET / │ GET /r/{token}
│ │ (the hot path)
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ static/ │ │ redirect() │
│ index.html │ │ ┌──────────────┐ │
│ app.js │ │ │ in-mem cache │ │ hit → 302
│ styles.css │ │ │ (url, exp) │──┼──→ Location
└──────┬───────┘ │ └──────┬───────┘ │
│ │ │ miss │
│ fetch │ ▼ │
│ POST /api/qr/create │ ┌──────────────┐ │
▼ │ │ SQLite │ │ not found → 404
┌──────────────┐ │ │ url_mappings │──┼──→ deleted/expired → 410
│ create_qr │ │ │ + scan_events│ │ ok → 302 + cache warm
│ (limited: │ │ └──────────────┘ │
│ 10/min/IP) │ └────────────────────┘
└──────┬───────┘
│ validate_url() → normalize + blocklist
│ generate_token() → SHA-256 + CSPRNG nonce + Base62 + retry
▼
┌──────────────┐
│ SQLite │
│ url_mappings │
└──────────────┘
Why this shape:
- In-memory cache fronting SQLite — the redirect path is the hottest endpoint. Cache stores
(url, expires_at)so it can serve permanent and time-limited links from memory and self-evict past-TTL entries on hit. - Hash-with-CSPRNG-nonce tokens — 7-char Base62 (62⁷ ≈ 3.5 T) with per-attempt random nonces, falling back to DB-uniqueness-check + retry on collision.
- Rate limit only on the create path — the only write endpoint that does real CPU work (hash + retry). Reads stay unbounded.
- Static frontend separate from API —
static/mounted underStaticFiles; the API isn't templated, so the UI can move to a CDN or be replaced by a SPA without touching Python.
qr-code-generator/
├── app/
│ ├── main.py FastAPI app: API + StaticFiles + 429 handler + lifespan
│ ├── config.py single source of truth for env-driven settings
│ ├── routes.py 9 QR endpoints + analytics
│ ├── schemas.py Pydantic request/response models
│ ├── models.py SQLAlchemy: url_mappings, scan_events, users,
│ │ user_sessions, magic_links, audit_logs
│ ├── database.py SQLAlchemy engine + session factory
│ ├── token_gen.py SHA-256 + Base62 + collision retry, + edit_token
│ ├── url_validator.py normalize + length + scheme + SSRF + blocklist
│ ├── limiter.py shared slowapi Limiter
│ ├── auth.py get_current_user dependency
│ ├── auth_routes.py /api/auth/* magic-link endpoints
│ ├── oauth_github.py /api/auth/github/* OAuth flow
│ ├── email_service.py pluggable EmailService (Console / Resend HTTP API)
│ ├── url_helpers.py base_url() — auto-derives in dev, explicit in prod
│ └── ...
├── static/ vanilla-JS UI served at / (login, My QRs, edit)
├── tests/ ~150 pytest cases (api / auth / audit / features / email)
├── tests/e2e/ Playwright end-to-end against a real uvicorn subprocess
├── scripts/smoke.ps1 end-to-end PowerShell script (needs session cookie)
├── .github/workflows/ CI runs pytest on push/PR
├── Dockerfile single-stage Python 3.12-slim, $PORT-aware CMD
├── render.yaml Render Blueprint: free-tier Web Service config
├── .dockerignore keeps tests, .venv, .git, .env out of the image
├── DECISIONS.md code-level deviations from answers/ with reasoning
├── ANSWERS.md 5 PROMPT.md design-question write-ups
├── docs/API.md programmatic-access guide (bearer token, curl, FAQ)
├── .env.example every env var documented; copy to .env (auto-loaded)
├── requirements.txt runtime deps
└── requirements-dev.txt adds pytest + httpx + playwright
Prerequisite: Python 3.10+.
python -m venv .venv
# Windows
.\.venv\Scripts\Activate.ps1
# macOS / Linux
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload- Web UI: http://localhost:8000/ — paste a URL, get a QR + short link.
- API docs: http://localhost:8000/docs
Two layers, each covering the same scenarios:
pytest (in-process, no server required) — for CI and quick iteration:
pip install -r requirements-dev.txt
pytest -v91 tests across three files (test_api.py / test_auth.py / test_audit.py) covering:
- 8 PROMPT.md scenarios + regressions for the Stage 2–4 design choices
- URL normalization, blocklist (incl. IDN/punycode homographs), SSRF block
- Rate limits (create / redirect / mutation) firing at threshold
- edit_token bearer auth, owner-shortcut auth, rotation chain
- Magic-link sign-in flow + session cookie + logout
- QR ownership isolation between users
- Audit log records every mutation (create / patch_url / patch_expires / delete / rotate)
Each test gets a fresh in-memory SQLite, a pre-authenticated session (test-runner@example.com), reset caches, and disabled rate limiter. Tests that exercise anonymous behavior call client.cookies.clear() explicitly.
CI runs the same suite on every push to main and every PR — see .github/workflows/test.yml.
scripts/smoke.ps1 (Windows, against a running server) — for manual verification end-to-end:
# Terminal 1
.\.venv\Scripts\Activate.ps1
uvicorn app.main:app --reload
# Sign in once via http://127.0.0.1:8000/ in your browser, then
# DevTools -> Application -> Cookies -> copy the `qrs_session` value.
# Terminal 2
$env:QRS_SESSION_COOKIE = '<paste the cookie value>'
.\scripts\smoke.ps1Hits the running server with 12 scenarios (PROMPT.md basics + tz-aware expiry + edit_token rotation + anonymous-create-rejected) and prints PASS/FAIL per assertion. Exits non-zero on any failure so it can gate a release.
A Dockerfile + render.yaml Blueprint are checked in. Free-tier Render gets you a public https://...onrender.com URL with no credit card.
- Push to GitHub. Render reads
render.yamlfrom the repo. - Register a production GitHub OAuth app at https://github.com/settings/developers:
- Authorization callback URL:
https://your-render-url.onrender.com/api/auth/github/callback - Keep the dev callback (
http://127.0.0.1:8001/api/auth/github/callback) too — GitHub allows multiple since 2021. - Save the Client ID + a fresh Client Secret.
- Authorization callback URL:
- Sign up at https://resend.com (free: 3000 emails/month). Create an API key. For the initial deploy use
onboarding@resend.devas theFrom— verify a real domain once you have one.
- Render dashboard → New + → Blueprint → paste your GitHub repo URL.
- Render detects
render.yamland prompts for thesync: falsesecrets:BASE_URL— leave blank for now (first deploy doesn't know its own URL yet).GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET— from step 2 above.RESEND_API_KEY— from step 3 above.
- Click Apply → Render builds the Docker image and deploys (~3 min).
- Copy the public URL Render assigns:
https://qr-code-generator-xxxx.onrender.com. - Set
BASE_URLin the dashboard to that URL → Render auto-redeploys. - Update the GitHub OAuth app's callback URL to use your actual Render URL.
- Sleeps after 15 min of inactivity. First request after a sleep cold-starts (~30 s). Acceptable for portfolio / demo.
- SQLite resets on every deploy / restart — Render free disk is ephemeral. For persistence, switch
DATABASE_URLto a free Postgres host (Neon offers a permanent free tier:postgresql+psycopg://user:pass@ep-xxx.neon.tech/db) and addpsycopg[binary]torequirements.txt. - 512 MB RAM, shared CPU — fine for the prototype's load, not for anything user-facing under steady traffic.
docker build -t qr-code-generator .
docker run -p 8000:8000 -e DEPLOY_ENV=dev qr-code-generator
# UI at http://localhost:8000/PROMPT.md core (Stages 1–8):
- Stage 1 — initial scaffold
- Stage 2 —
feat(token): SHA-256 + Base62 + collision retry - Stage 3 —
feat(url): normalize + blocklist - Stage 4 —
feat(redirect): cache → DB → 404/410 fallback - Stage 5 — pytest suite + PowerShell smoke script
- Stage 6 — static HTML frontend (vanilla JS +
fetch) - Stage 7 — rate limit on create (
slowapi, 10/minute per IP) - Stage 8 — design-decision write-up + CI + architecture docs
Post-review hardening:
- SSRF + CRLF + userinfo + subdomain blocklist in
validate_url -
edit_tokenbearer auth on PATCH/DELETE - Per-(token, ip) scan dedup + redirect rate limit
- PATCH/DELETE rate limit
-
cachetools.TTLCachefor the redirect cache - Batched + async-flush scan_event writes
- Env-driven config (
app/config.py) - IDN/punycode homograph fold on blocklist
-
rotate-edit-tokenendpoint
User identity layer:
- Magic-link auth (users / user_sessions / magic_links tables)
- Sign-in / sign-out UI with auth bar
- QR ownership +
GET /api/qr/mine+ My-QRs sidebar - GitHub OAuth as second sign-in path (
oauth_github.py) -
POST /api/qr/createnow requires authentication -
audit_logstable — every create/patch/delete/rotate recorded
Stretch features:
- 302 → 301 promote flag with
Cache-Control: max-age=300cap - PNG download button (Content-Disposition attachment)
- Sidebar search / sort / "Show deleted" toggle
- Analytics date-range filter (
?from=&to=) - Soft-delete restore (per-row ↻ + dedicated endpoint)
- Bulk delete (per-row checkboxes + all-or-nothing endpoint)
- Dev: BASE_URL auto-derives from request, no-cache for
/app.js//styles.css - Playwright e2e suite covering the stretch flows
Production-ready packaging:
-
Dockerfile+render.yamlBlueprint for one-click Render deploy - Resend HTTP-API email backend (
EMAIL_PROVIDER=resend) -
python-dotenvautoload of project-root.env