Skip to content

Component: sticker-api

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-api is one uvicorn process running the FastAPI app api: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 queued job and answers at once; a background task then sends the guest one Telegram message.
  • It does not poll Telegram. It creates a Bot object 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")), attaches logs/api.log and runs the database migration (db.init()), exactly like the bot does.
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
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

Full request and response shapes are on the API reference page. This table shows which component owns each route and what state it changes.

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

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:

  1. Secret-guessing block, then the secret itself — 5 wrong secrets from one caller IP in 10 minutes block it for 10 minutes.
  2. Body size, JSON, field validation.
  3. Duplicate request_id before any rate limit — a retry from the landing must get its lead number back, not a 429.
  4. Per-visitor limit (LEADS_IP_LIMIT per LEADS_IP_WINDOW_SEC, keyed by the hashed visitor IP), then the global hourly limit (LEADS_HOURLY_LIMIT), which also sends one alert per hour.
  5. db.lead_submit in one transaction: new lead, or a repeat row when the same Telegram username or phone already submitted within 24 hours.
  • 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-headers and trusts forwarded headers only from the loopback interface. For POST /leads the caller IP is the landing server; the visitor’s IP arrives in the body as client_ip.
  • The photo endpoint checks the status twice. The early check gives a friendly 409; the authoritative check is inside the BEGIN IMMEDIATE transaction of take_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.

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.