17 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) is_liveadmin-owned by default; optional verifier gated by envIDRS_VERIFY_TOKENS(default off): when enabled, token live ifficonUrlreachable — local URL (host =IDRS_PUBLIC_URL) → file exists undericons/token/; external URL → HTTP HEAD/GET 2xx (~3s timeout, follow redirects); empty iconUrl → not live; flips after 2 consecutive failed passes, auto-recovers; interval envIDRS_VERIFY_INTERVAL(default 600s); external hosts must be reachable from the server. Same rules for dapplinkreachability, enabled by default (disable viaIDRS_VERIFY_DAPPS=0/false/off): strict 2xx, whitespace-stripped, empty → not live- dapps:
Dapptable seeded once from repo-rootdapps.json(empty DB only); at seed, mirror each dappimagetoicons/dapps/+ rewriteimage={IDRS_PUBLIC_URL}/img/dapps/<file>; download gate: acceptimage/*only (sniff magic foroctet-stream;text/html/other → skip), filename = sanitized URL basename stem + content-type ext, collision →-Nsuffix; download/validation failure → keep original URL, continue (seed never hard-fails); admin panel can upload a new image (anyimage/*, ≤2MB) → rewrites URL, or edit URL directly - dapp
is_new_version: non-nullable Boolean, default false; admin-editable (form + details); surfaced in/api/getDappsasis_new_version:bool; existing DBs migrated additively via startupALTER TABLE dapps ADD COLUMN is_new_version BOOLEAN NOT NULL DEFAULT 0guarded by column-existence check (⊥ data loss; all existing rows → false); ⊥ reseed - dapp counter: public POST endpoint increments
counterby 1; auth-gated by shared secret envIDRS_SECRETsent asX-Api-Keyheader (constant-time compare; unset → fail-closed 401);idquery param; atomicUPDATE ... SET counter=counter+1; unknown id → 404 - whitelist:
Wallettable (account unique), admin-managed via panel (WalletAdmin); seeded once on empty DB with first entriessusukudaliar,vexessential; public endpoint returns account strings - out of scope: multi-user table, roles, 2FA, password reset, on-chain calls, audit trail, change history
- data survival: existing
idrs.dbfile + schema kept; startup runs idempotent additiveALTER TABLE dapps ADD COLUMN is_live_override BOOLEAN NOT NULL DEFAULT 0(guarded byPRAGMA table_info) — columns added, no data rewritten; ⊥ reseed if DB non-empty - run:
./venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000+run.shwrapper - deploy:
docker-compose.yml→ Docker Swarm stack (docker stack deploy -c docker-compose.yml); traefik edge (Hostidrs.databisnis.id, websecure, letsencrypt); NFS bind mounts for DB (/app/data) + static (/app/app/static); single uvicorn worker
§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 /api/getDapps → 200 {dappList:[{id:int, name, description, link, image, is_live:bool, counter:int, category_id:int|null, is_available_indonesia:bool|null, is_new_version:bool, created_at, updated_at}]} (id ASC)
api: POST /api/increaseDappCounter?id=<int> header X-Api-Key: <IDRS_SECRET> → 200 {id:int, counter:int} | 401 bad/missing secret (or secret unset) | 404 unknown id
api: GET /api/getWhitelist → 200 {walletList:[string, ...]} (account names, insertion order)
api: GET /img/dapps/<filename> + /public/img/dapps/<filename> → 200 image (mime from file) | 404 unknown (traversal-guarded)
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); IDRS_SECRET (dapp counter endpoint; unset → endpoint always 401)
file: tokenList.json, versionAndroid.json, dapps.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; empty DB → seed still runs; startup runs idempotent additive ALTER TABLE for is_live_override (no data loss)
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
V17: is_live admin-owned by default (editable in form, writes honored). Optional verifier — runs only when IDRS_VERIFY_TOKENS truthy (absent/0/false/off → off): when enabled, verifier overwrites token is_live each pass (live iff iconUrl reachable; empty → false; flips after 2 consecutive failed passes, recovers next success)
V18: Dapp seeded from dapps.json (empty DB only); seed-mirrored image URLs = {IDRS_PUBLIC_URL}/img/dapps/<file>; download gate accepts image/* only (sniff octet-stream; text/html → keep original URL); admin-uploaded image = any image/* ≤2MB (magic-sniffed), rewrites URL; id never writable from form (⊥ injectable); created_at set on create, updated_at on every change
V19: dapp-link verifier — enabled by default; disabled when IDRS_VERIFY_DAPPS is 0/false/off: dapp live iff link reachable (whitespace-stripped; empty → false; HTTP HEAD/GET strict 2xx, Accept: text/html, 3s timeout, follow redirects); grace (2 consecutive failed passes) only delays a live→false flip — a row already false stays false until its link verifies reachable (⊥ grace-resurrection after restart); recovers next success; admin-set is_live=false is sticky: dapp sets is_live_override=True, and the verifier never flips an overridden dapp (in either direction)
V20: dapp counter increment — POST only, id required, secret-gated (IDRS_SECRET, X-Api-Key header, constant-time compare; unset → always 401); +1 atomic update; returns new counter; unknown id → 404; ⊥ decrement/reset via this endpoint
V21: whitelist — Wallet.account unique (⊥ duplicate add via panel or seed); endpoint returns plain account strings only (⊥ leaks ids/timestamps); public ⊥ auth
V21: Dapp.is_new_version — non-nullable Boolean, default false; surfaced as is_new_version:bool in /api/getDapps; admin-editable (form + column_details_list); existing DBs auto-migrated via additive startup ALTER TABLE dapps ADD COLUMN is_new_version BOOLEAN NOT NULL DEFAULT 0 guarded by column-existence check (mirrors is_live_override; all pre-existing rows → false); ⊥ reseed
§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
T23|x|docker-compose.yml: swarm stack — service idrs-api (registry image git.proit.id/proitlab/idrs-api), traefik labels (Host idrs.databisnis.id, websecure, letsencrypt), constraint node.hostname != server2U, external traefik-net, NFS bind mounts (data + static)|§C
T24|x|/public/img/token/<filename> alias route (seed iconUrl path) serving icons/token/; FORWARDED_ALLOW_IPS=* in stack env so uvicorn trusts traefik scheme (admin assets load over https)|V14,V15
T25|x|icon-reachability verifier (app/verify.py): daemon thread, local file stat + external HTTP HEAD/GET, 2-pass grace, writes is_live; gated by IDRS_VERIFY_TOKENS (default off) — when off, is_live admin-editable|V17
T26|x|Dapp table + seed from dapps.json (mirror images to icons/dapps/, rewrite URL, gate image/*, keep-URL-on-failure, collision suffix); GET /api/getDapps + /img/dapps/<f> + /public/img/dapps/<f>; DappAdmin (URL + upload, auto created_at/updated_at, id ⊥ injectable)|V18
T27|x|dapp-link verifier (DappVerifier in app/verify.py): strict 2xx on link, whitespace-stripped, 2-pass grace + recover, enabled by default (IDRS_VERIFY_DAPPS=0/false/off disables; admin-owned then), skips dapps with is_live_override=True (sticky admin-off); tokens opt-in via IDRS_VERIFY_TOKENS; verifier refactored to base class + TokenVerifier/DappVerifier; env IDRS_VERIFY_ENABLED renamed → IDRS_VERIFY_TOKENS|V17,V19
T28|x|is_live_override column on Dapp (additive startup ALTER TABLE); DappAdmin.on_model_change sets it True on admin-set false (create, or edit only when is_live changes), never injected; verifier skips overridden dapps|V19
T29|x|POST /api/increaseDappCounter?id=<int> (header X-Api-Key = IDRS_SECRET, fail-closed, constant-time; atomic counter+1; 200/401/404); db.increment_dapp_counter with SQLite RETURNING|V20
T30|x|Wallet table + WalletAdmin (unique account, dup rejected, auto created_at, id ⊥ injectable) + seed first entries (susukudaliar, vexessential, empty-DB-only); GET /api/getWhitelist → {walletList:[...]}|V21
T30|x|add is_new_version Boolean column to Dapp model (default False, nullable=False) + additive startup ALTER TABLE dapps ADD COLUMN is_new_version BOOLEAN NOT NULL DEFAULT 0 guarded by column-existence check in db._ensure_migrations (mirrors is_live_override handling)|V21,§C
T31|x|expose is_new_version:bool in /api/getDapps (main.py get_dapps returns d.is_new_version) + DappForm BooleanField + DappAdmin column_list/column_details_list; seeded rows default false via DB default (dapps.json has no such key)|V21,§I
§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
B3|2026-08-16|behind traefik, uvicorn ignores X-Forwarded-Proto from non-loopback proxy (default FORWARDED_ALLOW_IPS=127.0.0.1) → sqladmin statics URLs absolute http:// → browser blocks as mixed content → admin unstyled|FORWARDED_ALLOW_IPS=* in stack env so uvicorn honors forwarded scheme
B4|2026-08-16|seed iconUrls use /public/img/token/<file> (original API path shape) but app only served /img/token/<file> → seeded icons 404 on mirror host|/public/img/token/<filename> alias route serves icons/token/
B5|2026-08-18|DappAdmin is_available_indonesia SelectField yields string "true"/"false" → strict bool column Not a boolean value on create|on_model_change maps "true"/"false" → bool, "" → None (V18)
B6|2026-08-18|Dapp-link verifier: dapps.json has 2 records with leading whitespace in link (" https://...", " http://...") → urlopen fails on the space|DappVerifier strips whitespace before checking (V19)
B6|2026-08-18|dapps.json: 2 records with leading whitespace in link (" https://...", " http://...") → would always fail reachability|verifier strips whitespace before checking (V19); data left as-is