Component: sticker-api
Who is this for
Section titled “Who is this for”A developer who adds or changes an HTTP endpoint, or an operator who wants to know what exactly answers behind
https://stickers.eventtalks.uz.
sticker-apiis one uvicorn process running the FastAPI appapi:app. It serves three audiences from one app: the kiosk (no authentication), the admin panel (HTTP Basic) and the landing (POST /leads, shared secret).- It never generates anything. For a photo it validates, re-encodes, saves the file, inserts a
queuedjob and answers at once; a background task then sends the guest one Telegram message. - It does not poll Telegram. It creates a
Botobject only to send one-off messages. - Status changes go through conditional updates in
db.py; the API holds no session state in memory. Only rate limiters live in memory — that is why the process must stay single-worker. - On start it validates configuration (
config.validate("api")), attacheslogs/api.logand runs the database migration (db.init()), exactly like the bot does.
Component diagram
Section titled “Component diagram”flowchart TB
kiosk["Kiosk page"]
admin["Operator browser"]
landing["Landing server action"]
subgraph api["sticker-api process"]
app["api.py - FastAPI app, lifespan, kiosk endpoints"]
static["StaticFiles mount at /kiosk"]
panel["admin_panel.py - /admin and /admin/api"]
leadsapi["leads_api.py - POST /leads"]
leadsvc["services/leads.py - validation and limits"]
maint["services/maintenance.py - pause state"]
catalog["catalog.py - outfit catalogue"]
admindata["services/admin_data.py and usage.py - summary and balance"]
dbmod["db/db.py - Database"]
store["services/storage.py - session_dir"]
end
sqlite[("SQLite app.db")]
disk[("sessions directory")]
tg["Telegram Bot API"]
kiosk --> app
kiosk --> static
admin --> panel
landing --> leadsapi
app --> maint
app --> catalog
app --> dbmod
app --> store
app -- "one-off sendMessage" --> tg
panel --> admindata
panel --> dbmod
leadsapi --> leadsvc
leadsapi --> dbmod
admindata --> dbmod
maint --> dbmod
dbmod --> sqlite
store --> disk
Modules
Section titled “Modules”| Module | Responsibility | Source |
|---|---|---|
| App and lifespan | Creates the FastAPI app; on start validates config, attaches the file log, logs a STARTUP line, runs db.init(); on stop closes the bot session. Adds the admin response headers middleware. Includes the admin and leads routers and mounts the kiosk directory last |
bot/api.py |
| Kiosk endpoints | Session creation, status, QR, templates, template choice, expiry, photo upload (table below) | bot/api.py |
| Photo normalisation | _normalize_photo: verifies the upload is an image, applies EXIF orientation, rejects anything with a side under 256 px, downsizes to at most 2048 px, re-encodes to JPEG quality 92. Re-encoding drops EXIF (GPS, device model) |
bot/api.py |
| Guest notifications | notify_generating and notify_pack_limit: one sendMessage after the HTTP response has gone out, so the kiosk never waits for Telegram. Failures are logged and ignored |
bot/api.py, bot/texts.py |
| Admin panel | /admin (one HTML page) and /admin/api/* (JSON). HTTP Basic with constant-time comparison; 5 failed logins from one IP in 10 minutes block that IP for 10 minutes; every failure is delayed by 0.5 s. POST actions additionally require the header X-Admin-Action: 1 (CSRF protection) and are written to audit_log |
bot/admin_panel.py, bot/admin/index.html |
| Admin summary | One function builds the numbers for both the web panel and the bot command /admin_stats, so the two never disagree: spend, balance estimate, packs, queue, errors in 24 h, daily image request counter, maintenance state |
bot/services/admin_data.py, bot/services/usage.py |
| Leads endpoint | POST /leads: secret check, body size cap (16 KiB), validation, duplicate detection, rate limits, insert. Answers right after the insert and never calls Telegram for the card — the bot publishes it |
bot/leads_api.py |
| Lead rules | Pure functions: field validation and normalisation (same rules and error codes as the landing), UTM parsing, IP hashing (HMAC-SHA256 with LEADS_IP_SALT, 32 hex characters), SlidingLimit |
bot/services/leads.py |
| Maintenance state | state(db): MAINTENANCE_MODE=1 from the environment, or the maintenance row in settings (written automatically on a provider “no funds” answer, or by /pause). While active, POST /session, template choice and photo upload answer 503 with {"detail": {"code": "maintenance"}} |
bot/services/maintenance.py |
| Catalogue | The single source of truth for outfits: id, label, files, outfit text, readiness flag. The API lists only ready entries unless SHOW_PLACEHOLDER_TEMPLATES=1 |
bot/catalog.py |
| Configuration | Reads bot/.env on import; validate("api") returns errors and warnings. In APP_ENV=prod an empty ADMIN_PANEL_PASSWORD is a start-up error |
bot/config.py |
| Database access | All SQL lives in one class; the API uses the same class as the bot | bot/db/db.py |
| Session directory | session_dir(data_dir, token) creates sessions/<token>/ where the selfie is written |
bot/services/storage.py |
Endpoints at a glance
Section titled “Endpoints at a glance”Full request and response shapes are on the API reference page. This table shows which component owns each route and what state it changes.
Kiosk (no authentication)
Section titled “Kiosk (no authentication)”| Route | Effect |
|---|---|
GET / |
Redirect to /kiosk/ |
GET /kiosk/… |
Static kiosk files. GET /kiosk/light serves the same page with the light theme |
GET /kiosk-theme.js |
A tiny script that sets the theme before first paint (KIOSK_THEME, or ?theme=) |
GET /kiosk-config |
idle_sec and idle_prompt_sec from KIOSK_IDLE_SEC / KIOSK_IDLE_PROMPT_SEC |
GET /branding |
Footer logos that actually exist in bot/kiosk/static/ |
POST /session |
Creates a session (idle) with a random token (secrets.token_urlsafe(12)); returns the token and the deep link |
GET /session/{token} |
Returns status, template_id, notice — the kiosk polls this |
GET /session/{token}/qr.svg |
QR code with the deep link, rendered on the server (the kiosk needs no external library) |
GET /templates |
Outfit cards in catalogue order, each with a versioned preview URL |
GET /templates/{id}.png |
Preview image; with the current ?v=<hash> it is cached for a year, otherwise revalidated by ETag |
POST /session/{token}/template |
consented → template_chosen (or a changed choice). 409 before consent; 409 with code pack_limit when the guest’s limit is used up |
POST /session/{token}/photo |
Saves the photo, moves the session to photo_taken, inserts a queued job — atomically. 413 above 15 MB, 422 for a bad image, 409 for a wrong status or the pack limit |
POST /session/{token}/expire?reason= |
Marks an unfinished session expired with a close reason |
GET /health |
ok, database check, active job count, maintenance source, version, render mode. 503 when the database does not answer |
GET /robots.txt |
Disallows everything — the site is a kiosk and an admin panel |
Admin panel (HTTP Basic)
Section titled “Admin panel (HTTP Basic)”| Route | Effect |
|---|---|
GET /admin |
The panel page |
GET /admin/api/summary |
Spend, balance estimate, packs, queue, errors |
GET /admin/api/guests?q=&status= |
One row per job plus guests without jobs; no photos, paths, appearance data or session tokens |
GET /admin/api/jobs/{id}/cost |
Cost of one job by stage |
GET /admin/api/jobs/{id}/anchor |
The anchor portrait (a stylised character, not the photo) while its file still exists; disabled by ADMIN_SHOW_ANCHOR=0 |
GET /admin/api/jobs/{id}/consistency |
Skin-tone and quality-check summary of a job, without the appearance description |
GET /admin/api/audit |
Last 100 audit entries |
POST /admin/api/balance |
Stores the real billing balance entered by the operator (settings.billing_balance_override) |
POST /admin/api/guests/{id}/grant |
Adds one extra pack to the guest’s limit |
POST /admin/api/guests/{id}/reset |
Backs up the database file, then deletes the guest’s users, sessions and jobs rows |
POST /admin/api/jobs/{id}/retry |
failed → queued; refused when the selfie file is gone or the pack limit would be exceeded |
Every response under /admin carries Cache-Control: no-store, X-Robots-Tag: noindex, X-Frame-Options: DENY
and a Content-Security-Policy without external sources (middleware in api.py).
Leads (shared secret)
Section titled “Leads (shared secret)”| Route | Effect |
|---|---|
POST /leads |
404 when the module is off (LEADS_ENABLED=0 or LEADS_SECRET empty); 401 on a wrong secret; 413 above 16 KiB; 422 on invalid fields; 429 on a limit (with Retry-After); 201 for a new lead; 200 for a repeated request_id or a repeat contact within 24 hours |
Order of checks inside create_lead, and why:
- Secret-guessing block, then the secret itself — 5 wrong secrets from one caller IP in 10 minutes block it for 10 minutes.
- Body size, JSON, field validation.
- Duplicate
request_idbefore any rate limit — a retry from the landing must get its lead number back, not a 429. - Per-visitor limit (
LEADS_IP_LIMITperLEADS_IP_WINDOW_SEC, keyed by the hashed visitor IP), then the global hourly limit (LEADS_HOURLY_LIMIT), which also sends one alert per hour. db.lead_submitin one transaction: new lead, or a repeat row when the same Telegram username or phone already submitted within 24 hours.
Things that are easy to get wrong
Section titled “Things that are easy to get wrong”- Single worker only. Login blocking and lead rate limits are per-process dictionaries. A second uvicorn worker would give each its own counters.
- Client IP comes from Caddy. The unit file starts uvicorn with
--proxy-headersand trusts forwarded headers only from the loopback interface. ForPOST /leadsthe caller IP is the landing server; the visitor’s IP arrives in the body asclient_ip. - The photo endpoint checks the status twice. The early check gives a friendly 409; the authoritative check is
inside the
BEGIN IMMEDIATEtransaction oftake_photo_and_enqueue. A losing duplicate request deletes its own file (each upload gets a unique file name). - Two processes log the render mode. The API logs the mode it sees when the job is created; the worker logs the mode it actually renders with. The second line decides.
Where to go next
Section titled “Where to go next”- API reference — request and response bodies for every route.
- State machines — which endpoint causes which status change.
- Component: kiosk — the client of the kiosk endpoints.
- Security — authentication and rate limits across the system.
- Runbook: kiosk 502 — when Caddy cannot reach this process.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.