Files

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) + sqladmin admin 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 sqladmin ModelAdmin: 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 sets iconUrl = {IDRS_PUBLIC_URL}/img/token/<filename>; iconUrl otherwise free-form (external URL allowed)
  • cleaned types OK: decimals int, is_live bool — 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
  • idfToken unique; response ordered position ASC
  • version rule: acceptableVersion ≤ latestVersion ! enforced (else reject)
  • is_live admin-owned by default; optional verifier gated by env IDRS_VERIFY_TOKENS (default off): when enabled, token live iff iconUrl reachable — local URL (host = IDRS_PUBLIC_URL) → file exists under icons/token/; external URL → HTTP HEAD/GET 2xx (~3s timeout, follow redirects); empty iconUrl → not live; flips after 2 consecutive failed passes, auto-recovers; interval env IDRS_VERIFY_INTERVAL (default 600s); external hosts must be reachable from the server. Same rules for dapp link reachability, enabled by default (disable via IDRS_VERIFY_DAPPS=0/false/off): strict 2xx, whitespace-stripped, empty → not live
  • dapps: Dapp table seeded once from repo-root dapps.json (empty DB only); at seed, mirror each dapp image to icons/dapps/ + rewrite image = {IDRS_PUBLIC_URL}/img/dapps/<file>; download gate: accept image/* only (sniff magic for octet-stream; text/html/other → skip), filename = sanitized URL basename stem + content-type ext, collision → -N suffix; download/validation failure → keep original URL, continue (seed never hard-fails); admin panel can upload a new image (any image/*, ≤2MB) → rewrites URL, or edit URL directly
  • dapp is_new_version: non-nullable Boolean, default false; admin-editable (form + details); surfaced in /api/getDapps as is_new_version:bool; existing DBs migrated additively via startup ALTER TABLE dapps ADD COLUMN is_new_version BOOLEAN NOT NULL DEFAULT 0 guarded by column-existence check (⊥ data loss; all existing rows → false); ⊥ reseed
  • dapp counter: public POST endpoint increments counter by 1; auth-gated by shared secret env IDRS_SECRET sent as X-Api-Key header (constant-time compare; unset → fail-closed 401); id query param; atomic UPDATE ... SET counter=counter+1; unknown id → 404
  • whitelist: Wallet table (account unique), admin-managed via panel (WalletAdmin); seeded once on empty DB with first entries susukudaliar, 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.db file + schema kept; startup runs idempotent additive ALTER TABLE dapps ADD COLUMN is_live_override BOOLEAN NOT NULL DEFAULT 0 (guarded by PRAGMA 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.sh wrapper
  • deploy: docker-compose.yml → Docker Swarm stack (docker stack deploy -c docker-compose.yml); traefik edge (Host idrs.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