feat: POST /api/increaseDappCounter (X-Api-Key = IDRS_SECRET, atomic +1, 200/401/404)
This commit is contained in:
1 parent
3b68820beb
commit
01ee7ec733
4 files changed
+35
-5
No files matched your search
@@ -3,7 +3,7 @@
|
|||||||
Project conventions and operational gotchas for agents working in this repo.
|
Project conventions and operational gotchas for agents working in this repo.
|
||||||
|
|
||||||
## Layout
|
## 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/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/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).
|
- `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).
|
- `tokenList.json` / `versionAndroid.json` / `dapps.json` — seed source (read-only after seed).
|
||||||
|
|
||||||
## Environment / secrets
|
## 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.
|
- `.session_secret` (gitignored, auto-generated 0600) if `IDRS_SESSION_SECRET` unset.
|
||||||
- NEVER bake `.env` into the Docker image; pass via `--env-file` / `-e`.
|
- NEVER bake `.env` into the Docker image; pass via `--env-file` / `-e`.
|
||||||
- Untracked, never commit: `.env`, `.session_secret`, `idrs.db*`, `venv/`, `app/static/icons/*`.
|
- Untracked, never commit: `.env`, `.session_secret`, `idrs.db*`, `venv/`, `app/static/icons/*`.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getToken
|
|||||||
- version rule: `acceptableVersion` ≤ `latestVersion` ! enforced (else reject)
|
- 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
|
- `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
|
- 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
|
- 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
|
- 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
|
- 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/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/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, 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/dapps/<filename>` + `/public/img/dapps/<filename>` → 200 image (mime from file) | 404 unknown (traversal-guarded)
|
||||||
api: GET `/img/<filename>` → 200 image/png | 404 unknown
|
api: GET `/img/<filename>` → 200 image/png | 404 unknown
|
||||||
web: GET `/admin` → sqladmin panel (login-gated; login page when unauthenticated)
|
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/login` → login form; POST → session cookie on success
|
||||||
web: GET `/admin/logout` → end session
|
web: GET `/admin/logout` → end session
|
||||||
web: POST `/admin/token/<id>/icon` → upload PNG (multipart) → sets iconUrl `/img/<filename>` (custom panel view/action)
|
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
|
file: `tokenList.json`, `versionAndroid.json`, `dapps.json` → seed source (repo root); read-only after seed
|
||||||
|
|
||||||
## §V — Invariants
|
## §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)
|
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
|
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)**
|
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
|
## §T — Tasks
|
||||||
id|status|task|cites
|
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
|
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
|
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
|
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
|
## §B — Bug log
|
||||||
id|date|cause|fix
|
id|date|cause|fix
|
||||||
|
|||||||
@@ -208,3 +208,15 @@ def list_dapps():
|
|||||||
return session.query(Dapp).order_by(Dapp.id.asc()).all()
|
return session.query(Dapp).order_by(Dapp.id.asc()).all()
|
||||||
finally:
|
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
@@ -1,7 +1,9 @@
|
|||||||
from pathlib import Path
|
import hmac
|
||||||
import mimetypes
|
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.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ from app.verify import DappVerifier, TokenVerifier
|
|||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
ICON_DIR = BASE_DIR / "static" / "icons"
|
ICON_DIR = BASE_DIR / "static" / "icons"
|
||||||
|
|
||||||
|
SECRET = os.environ.get("IDRS_SECRET", "")
|
||||||
|
|
||||||
token_verifier = TokenVerifier()
|
token_verifier = TokenVerifier()
|
||||||
dapp_verifier = DappVerifier()
|
dapp_verifier = DappVerifier()
|
||||||
|
|
||||||
@@ -111,6 +115,16 @@ def get_dapps():
|
|||||||
return {"dappList": 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")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|||||||
Reference in new issue
Block a user