feat: POST /api/increaseDappCounter (X-Api-Key = IDRS_SECRET, atomic +1, 200/401/404)

This commit is contained in:
proitlab committed 2026-08-18 15:12:18 +07:00
1 parent 3b68820beb
commit 01ee7ec733
4 files changed
+36 -6

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@
Project conventions and operational gotchas for agents working in this repo.
## Layout
- `app/main.py` — FastAPI app: public endpoints `GET /api/getTokenInfo`, `GET /api/version/android`, `GET /api/getDapps`, `GET /img/token/<filename>`, `GET /img/dapps/<filename>` + `/public/img/...` aliases (seed iconUrl path); mounts `/static`; startup calls `db.init_db()`.
- `app/main.py` — FastAPI app: public endpoints `GET /api/getTokenInfo`, `GET /api/version/android`, `GET /api/getDapps`, `POST /api/increaseDappCounter` (header `X-Api-Key` = env `IDRS_SECRET`, fail-closed 401 if unset/wrong), `GET /img/token/<filename>`, `GET /img/dapps/<filename>` + `/public/img/...` aliases (seed iconUrl path); mounts `/static`; startup calls `db.init_db()`.
- `app/admin.py` — sqladmin panel: `TokenAdmin` (custom WTForms form, `on_model_change`), `VersionAdmin`, `DappAdmin` (URL field + `imageFile` upload, auto `created_at`/`updated_at`, `id` not writable), `create_admin(app)`. Upload icons saved under `app/static/icons/token/` with `iconUrl = {IDRS_PUBLIC_URL}/img/token/<filename>`; dapp uploads under `icons/dapps/`.
- `app/models.py` — SQLAlchemy `Token`/`Version`/`Dapp`, engine, `SessionLocal`, `ICON_DIR`, `DB_PATH` (env `IDRS_DB_PATH`).
- `app/db.py` — seed from `tokenList.json`/`versionAndroid.json`/`dapps.json` (once, empty DB only). Dapp seed mirrors each `image` into `icons/dapps/` and rewrites to `{IDRS_PUBLIC_URL}/img/dapps/<file>` (gate: `image/*` only, sniff `octet-stream`, `text/html`→skip; on failure keep original URL; collision → `-N` suffix).
@@ -12,7 +12,7 @@ Project conventions and operational gotchas for agents working in this repo.
- `tokenList.json` / `versionAndroid.json` / `dapps.json` — seed source (read-only after seed).
## Environment / secrets
- `.env` (gitignored) → `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `IDRS_PUBLIC_URL` (default `https://idrs.databisnis.id`). Test creds `admin`/`testpass123` — change before exposure.
- `.env` (gitignored) → `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `IDRS_PUBLIC_URL` (default `https://idrs.databisnis.id`), `IDRS_SECRET` (dapp counter endpoint; unset → always 401). Test creds `admin`/`testpass123` — change before exposure.
- `.session_secret` (gitignored, auto-generated 0600) if `IDRS_SESSION_SECRET` unset.
- NEVER bake `.env` into the Docker image; pass via `--env-file` / `-e`.
- Untracked, never commit: `.env`, `.session_secret`, `idrs.db*`, `venv/`, `app/static/icons/*`.
+5 -1
View File
@@ -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/<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 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
- run: `./venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000` + `run.sh` wrapper
@@ -24,13 +25,14 @@ FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getToken
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: 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 `/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)
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
@@ -53,6 +55,7 @@ V16: `idfToken` auto-increment only — not in form, never written from form dat
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); 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
## §T — Tasks
id|status|task|cites
@@ -84,6 +87,7 @@ T25|x|icon-reachability verifier (`app/verify.py`): daemon thread, local file st
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
## §B — Bug log
id|date|cause|fix
+13 -1
View File
@@ -207,4 +207,16 @@ def list_dapps():
try:
return session.query(Dapp).order_by(Dapp.id.asc()).all()
finally:
session.close()
session.close()
def increment_dapp_counter(dapp_id: int):
from sqlalchemy import text
with SessionLocal() as session:
row = session.execute(
text("UPDATE dapps SET counter = counter + 1 WHERE id = :dapp_id RETURNING counter"),
{"dapp_id": dapp_id},
).fetchone()
session.commit()
return row[0] if row else None
+16 -2
View File
@@ -1,7 +1,9 @@
from pathlib import Path
import hmac
import mimetypes
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -12,6 +14,8 @@ from app.verify import DappVerifier, TokenVerifier
BASE_DIR = Path(__file__).resolve().parent
ICON_DIR = BASE_DIR / "static" / "icons"
SECRET = os.environ.get("IDRS_SECRET", "")
token_verifier = TokenVerifier()
dapp_verifier = DappVerifier()
@@ -111,6 +115,16 @@ def get_dapps():
return {"dappList": dapps}
@app.post("/api/increaseDappCounter")
def increase_dapp_counter(id: int = Query(...), x_api_key: str = Header(default="")):
if not SECRET or not hmac.compare_digest(SECRET.encode(), x_api_key.encode()):
raise HTTPException(status_code=401, detail="unauthorized")
new_counter = db.increment_dapp_counter(id)
if new_counter is None:
raise HTTPException(status_code=404, detail="dapp not found")
return {"id": id, "counter": new_counter}
@app.on_event("startup")
def startup():
db.init_db()