Skip to content

JShengP/qr-code-generator

Repository files navigation

QR Code Generator

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.

tests

Endpoints

QR

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).

Styling the QR image

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 140 Pixels per module — higher = larger, sharper PNG.
border 4 020 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 fillfill2.
fill2 5b9eff same as fill Gradient end color (only used when gradientnone).
# 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.png

Center 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.10.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.png

Bad 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.

Auth

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:

  1. Browser users — sign in via magic link or GitHub; the qrs_session cookie carries the credential. The UI never asks for an edit_token.
  2. Programmatic clients (CI scripts, curl, automation) — issue an edit_token from the web UI's API token button (next to the Token field in any owned QR's result panel), then send it as Authorization: Bearer <token> on PATCH/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 in docs/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.

Rate limiting

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.

Configuration (env vars)

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.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│ 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 APIstatic/ mounted under StaticFiles; the API isn't templated, so the UI can move to a CDN or be replaced by a SPA without touching Python.

Project Structure

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

Setup

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

Testing

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 -v

91 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.ps1

Hits 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.

Deploy

A Dockerfile + render.yaml Blueprint are checked in. Free-tier Render gets you a public https://...onrender.com URL with no credit card.

One-time setup (per environment)

  1. Push to GitHub. Render reads render.yaml from the repo.
  2. 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.
  3. Sign up at https://resend.com (free: 3000 emails/month). Create an API key. For the initial deploy use onboarding@resend.dev as the From — verify a real domain once you have one.

Deploy via Render Blueprint

  1. Render dashboard → New +Blueprint → paste your GitHub repo URL.
  2. Render detects render.yaml and prompts for the sync: false secrets:
    • BASE_URLleave 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.
  3. Click Apply → Render builds the Docker image and deploys (~3 min).
  4. Copy the public URL Render assigns: https://qr-code-generator-xxxx.onrender.com.
  5. Set BASE_URL in the dashboard to that URL → Render auto-redeploys.
  6. Update the GitHub OAuth app's callback URL to use your actual Render URL.

Free-tier caveats

  • 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_URL to a free Postgres host (Neon offers a permanent free tier: postgresql+psycopg://user:pass@ep-xxx.neon.tech/db) and add psycopg[binary] to requirements.txt.
  • 512 MB RAM, shared CPU — fine for the prototype's load, not for anything user-facing under steady traffic.

Local Docker build (smoke test before deploy)

docker build -t qr-code-generator .
docker run -p 8000:8000 -e DEPLOY_ENV=dev qr-code-generator
# UI at http://localhost:8000/

Roadmap

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_token bearer auth on PATCH/DELETE
  • Per-(token, ip) scan dedup + redirect rate limit
  • PATCH/DELETE rate limit
  • cachetools.TTLCache for the redirect cache
  • Batched + async-flush scan_event writes
  • Env-driven config (app/config.py)
  • IDN/punycode homograph fold on blocklist
  • rotate-edit-token endpoint

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/create now requires authentication
  • audit_logs table — every create/patch/delete/rotate recorded

Stretch features:

  • 302 → 301 promote flag with Cache-Control: max-age=300 cap
  • 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.yaml Blueprint for one-click Render deploy
  • Resend HTTP-API email backend (EMAIL_PROVIDER=resend)
  • python-dotenv autoload of project-root .env

About

Dynamic QR code service with short-URL redirects, customizable QR appearance (color/gradient/logo), soft delete, expiration, and scan analytics — FastAPI + SQLite.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors