7.7 KiB
SPEC — IDRS API server (Python FastAPI)
§G — Goal
FastAPI + SQLite server mirroring idrs-api live endpoints — GET /api/getTokenInfo + GET /api/version/android + icon files. Admin panel (sqladmin, login-protected) to enter/edit data → served as JSON API. Seed from existing tokenList.json + versionAndroid.json on first run.
§C — Constraints
- Python 3, FastAPI + SQLAlchemy + sqlite (WAL) +
sqladminadmin panel (Bootstrap UI, built-in login) — venv./venv, deps:fastapi,uvicorn,sqlalchemy,sqladmin,jinja2,python-multipart(upload); install in venv, ⊥ global - endpoints match live paths verbatim:
/api/getTokenInfo,/api/version/android,/img/<filename>; admin panel at/admin(login-gated) - admin CRUD via
sqladminModelAdmin: token (10 fields: idfToken, position, typeBlockchain, name, symbol, contractAddr, decimals, iconUrl, filename, is_live) + version record (idVersion, appName, acceptableVersion, latestVersion); ⊖ hand-rolled admin.html + old CRUD routes - auth: single admin via env
ADMIN_USERNAME+ADMIN_PASSWORD; session-signing secret auto-generated on first run + persisted locally (survives restart); creds unset → fail closed (∀ login refused + warning logged, ⊥ default/empty password) - icon upload PNG → stored under
token/subdir → served/img/token/<filename>; upload setsiconUrl={IDRS_PUBLIC_URL}/img/token/<filename>;iconUrlotherwise free-form (external URL allowed) - cleaned types OK:
decimalsint,is_livebool — byte drift from live API (string-typed there) accepted; shape + field names kept verbatim - seed once on first run (empty DB) from repo-root
tokenList.json(23 tokens) +versionAndroid.json; ⊥ reseed if DB non-empty idfTokenunique; response orderedpositionASC- version rule:
acceptableVersion≤latestVersion! enforced (else reject) - out of scope: multi-user table, roles, 2FA, password reset, on-chain calls, audit trail, change history
- data survival: existing
idrs.dbfile + schema kept (migration-free); SQLAlchemy reads the same file - run:
./venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000+run.shwrapper
§I — Interfaces
api: GET /api/getTokenInfo → 200 {tokenList:[{idfToken:int, position:int, typeBlockchain:string, name:string, symbol:string, contractAddr:string, decimals:int, iconUrl:string, filename:string, is_live:bool}]} (position ASC)
api: GET /api/version/android → 200 {idVersion:int, appName:string, acceptableVersion:int, latestVersion:int}
api: GET /img/<filename> → 200 image/png | 404 unknown
web: GET /admin → sqladmin panel (login-gated; login page when unauthenticated)
web: GET /admin/login → login form; POST → session cookie on success
web: GET /admin/logout → end session
web: POST /admin/token/<id>/icon → upload PNG (multipart) → sets iconUrl /img/<filename> (custom panel view/action)
env: ADMIN_USERNAME, ADMIN_PASSWORD (login); IDRS_SESSION_SECRET (session signing, auto-gen + persist on first run)
file: tokenList.json, versionAndroid.json → seed source (repo root); read-only after seed
§V — Invariants
V1: ∀ token → idfToken unique; position unique; response ordered position ASC
V2: response shape fixed: getTokenInfo = {tokenList:[...]}, version/android = flat object; field names verbatim from live API
V3: decimals int, is_live bool in DB + serialized (cleaned types; drift from live string-typing accepted)
V4: acceptableVersion ≤ latestVersion; update violating rule → reject
V5: seed runs once: empty DB → import tokenList.json + versionAndroid.json; DB non-empty → ⊥ reseed
V6: ∀ upload → PNG, filename sanitized (basename, safe chars), written under token/ subdir of icons dir; duplicate filename → overwrite (idempotent)
V7: ∀ token → iconUrl = {IDRS_PUBLIC_URL}/img/token/<filename> after upload; token without upload → keep entered URL (external ok)
V8: token delete → row removed; response excludes it on next GET
V9: ∀ admin view → auth check before render; unauthenticated → redirect /admin/login; wrong creds → reject; logout ends session
V10: ADMIN_USERNAME/ADMIN_PASSWORD unset → ∀ login refused + warning logged; ⊥ default/empty credential
V11: public API endpoints (/api/getTokenInfo, /api/version/android, /img/...) ⊥ auth; response shapes unchanged (V2,V3)
V12: same idrs.db file + schema used by raw-sqlite AND SQLAlchemy (no migration); empty DB → seed still runs
V13: version table single row (id=1) maintained — panel ⊥ creates duplicates
V14: ∀ upload → PNG verified by magic bytes \x89PNG\r\n\x1a\n (⊥ trust content-type header), extension forced .png, size ≤ 512KB; /img/<filename> served with image/png (⊥ guess_type)
V15: token edit/create form upload → on_model_change sets filename + iconUrl={IDRS_PUBLIC_URL}/img/token/<filename> (base env var, default https://idrs.databisnis.id, trailing / stripped) before persist; no new file → keep existing (⊥ clear)
V16: idfToken auto-increment only — not in form, never written from form data (⊥ injectable); typeBlockchain fixed "Vexanium" — not in form, always forced on write (⊥ injectable); position editable, default = next idfToken value
§T — Tasks
id|status|task|cites
T1|x|venv + deps (fastapi, uvicorn, jinja2, python-multipart)|§C
T2|x|sqlite schema + WAL + seed from tokenList.json/versionAndroid.json|V5
T3|x|GET /api/getTokenInfo (position ASC, cleaned types)|V1,V2,V3
T4|x|GET /api/version/android|V2,V3
T5|x|icon upload + /img/<filename> serving|V6,V7
T6|x|/admin token list + CRUD|V1,V3,V8
T7|x|/admin version edit + rule check|V4
T8|x|run.sh + README quickstart (seed, run, admin)|§C
T9|x|deps sqlalchemy + sqladmin; SQLAlchemy models over existing idrs.db|V12
T10|x|auth backend (env creds, fail-closed) + persisted session secret|V9,V10
T11|x|ModelAdmin Token + Version (single-row guard)|V1,V4,V13
T12|x|port seed to SQLAlchemy; public endpoints keep shapes on same DB|V11,V12
T13|x|icon upload in panel (custom view/action); drop admin.html + old CRUD routes|V6,V7
T14|x|run.sh/.env + README update (creds, secret, login)|§C,V10
T15|x|custom WTForms form on TokenAdmin (FileField icon) + on_model_change file handling|V15
T16|x|remove IconUploadView BaseView + upload.html + /admin/upload|V9
T17|x|PNG magic-byte + extension + size validation; force image/png on /img|V14
T18|x|IDRS_PUBLIC_URL env base for upload iconUrl (default domain); one-time backfill of relative /img/... iconUrl|V15
T19|x|form: hide idfToken (auto) + typeBlockchain (fixed Vexanium); position default = next id, editable; empty position → auto-default|V16
T20|x|Dockerfile (python:3.12-slim, uvicorn single worker, non-root) + .dockerignore; DB (/app/data) and icons (/app/app/static/icons) volumes persist|§C
T21|x|tokenList.json: replace seed iconUrl domain idrs.kriptoteknologi.io → idrs.databisnis.id (14 rows; existing DBs need manual update)|V5
T22|x|icon upload → token/ subdir + /img/token/<filename> route (flat route removed); migrate ayam-logo.png into token/, backfill token1 iconUrl|V15
§B — Bug log
id|date|cause|fix
B1|2026-08-16|seed tokenList.json: token CHIP "filename":null → t.get("filename","") returns None (key exists) → NOT NULL constraint failed|seed null-coalesces t.get("filename") or "", t.get("iconUrl") or "" (V5)
B2|2026-08-16|icon upload checks only client content-type header, saves any filename, /img serves mime via guess_type → uploaded x.html served as text/html on API origin (stored XSS)|V14: magic-bytes PNG check + .png extension + size cap + forced image/png content-type on /img