diff --git a/SPEC.md b/SPEC.md index 8bc21bf..42cdb02 100644 --- a/SPEC.md +++ b/SPEC.md @@ -15,6 +15,7 @@ FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getToken - 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/`; 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 - 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 @@ -24,7 +25,7 @@ FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getToken ## §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, created_at, updated_at}]}` (id ASC) +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=` header `X-Api-Key: ` → 200 `{id:int, counter:int}` | 401 bad/missing secret (or secret unset) | 404 unknown id api: GET `/img/dapps/` + `/public/img/dapps/` → 200 image (mime from file) | 404 unknown (traversal-guarded) api: GET `/img/` → 200 image/png | 404 unknown @@ -56,6 +57,7 @@ V17: `is_live` admin-owned by default (editable in form, writes honored). Option V18: `Dapp` seeded from `dapps.json` (empty DB only); seed-mirrored image URLs = `{IDRS_PUBLIC_URL}/img/dapps/`; 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); flips after 2 consecutive failed passes, 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: `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 @@ -88,6 +90,8 @@ T26|x|`Dapp` table + seed from `dapps.json` (mirror images to `icons/dapps/`, re 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=` (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|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 diff --git a/app/admin.py b/app/admin.py index bf51c9c..0213769 100644 --- a/app/admin.py +++ b/app/admin.py @@ -181,6 +181,7 @@ class DappForm(Form): choices=[("", "—"), ("true", "Yes"), ("false", "No")], default="", ) + is_new_version = BooleanField("New Version") class DappAdmin(ModelView, model=Dapp): @@ -195,6 +196,7 @@ class DappAdmin(ModelView, model=Dapp): "counter", "category_id", "is_available_indonesia", + "is_new_version", ] column_labels = { "is_live": "Live", @@ -211,6 +213,7 @@ class DappAdmin(ModelView, model=Dapp): "counter", "category_id", "is_available_indonesia", + "is_new_version", "created_at", "updated_at", ] diff --git a/app/db.py b/app/db.py index ec5bfbb..2ea5986 100644 --- a/app/db.py +++ b/app/db.py @@ -60,6 +60,11 @@ def _ensure_migrations(engine): with engine.begin() as conn: conn.execute(text("ALTER TABLE dapps ADD COLUMN is_live_override BOOLEAN NOT NULL DEFAULT 0")) + if "is_new_version" not in cols: + from sqlalchemy import text + + with engine.begin() as conn: + conn.execute(text("ALTER TABLE dapps ADD COLUMN is_new_version BOOLEAN NOT NULL DEFAULT 0")) def init_db(): diff --git a/app/main.py b/app/main.py index a6b1a5e..7b4db16 100644 --- a/app/main.py +++ b/app/main.py @@ -108,6 +108,7 @@ def get_dapps(): "counter": d.counter, "category_id": d.category_id, "is_available_indonesia": d.is_available_indonesia, + "is_new_version": bool(d.is_new_version), "created_at": d.created_at, "updated_at": d.updated_at, } diff --git a/app/models.py b/app/models.py index 4a30eed..360e4de 100644 --- a/app/models.py +++ b/app/models.py @@ -64,6 +64,7 @@ class Dapp(Base): counter = Column("counter", Integer, nullable=False, default=0) category_id = Column("category_id", Integer, nullable=True) is_available_indonesia = Column("is_available_indonesia", Boolean, nullable=True) + is_new_version = Column("is_new_version", Boolean, nullable=False, default=False) created_at = Column("created_at", String, nullable=True) updated_at = Column("updated_at", String, nullable=True) is_live_override = Column("is_live_override", Boolean, nullable=False, default=False)