From 77d6cd5b92675afcc74710e6a51a876ac73fb940 Mon Sep 17 00:00:00 2001 From: dsutanto Date: Mon, 17 Aug 2026 00:24:27 +0700 Subject: [PATCH] add env-gated icon-reachability verifier; admin is_live; docs --- AGENTS.md | 1 + SPEC.md | 3 ++ app/main.py | 6 ++- app/verify.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 6 +-- 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 app/verify.py diff --git a/AGENTS.md b/AGENTS.md index 5760516..16ebdd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Project conventions and operational gotchas for agents working in this repo. - `app/admin.py` — sqladmin panel: `TokenAdmin` (custom WTForms form, `on_model_change`), `VersionAdmin`, `create_admin(app)`. Upload icons saved under `app/static/icons/token/` with `iconUrl = {IDRS_PUBLIC_URL}/img/token/`. - `app/models.py` — SQLAlchemy `Token`/`Version`, engine, `SessionLocal`, `ICON_DIR`, `DB_PATH` (env `IDRS_DB_PATH`). - `app/db.py` — seed from `tokenList.json`/`versionAndroid.json` (once, empty DB only). +- `app/verify.py` — icon-reachability verifier: daemon thread started on app startup, gated by env `IDRS_VERIFY_ENABLED` (absent/0/false/off → disabled, default). When enabled, token live iff `iconUrl` reachable (local file stat under `icons/token/`, or external HTTP HEAD/GET 2xx); empty → false; flips after 2 consecutive failed passes, auto-recovers; interval env `IDRS_VERIFY_INTERVAL` (default 600s). When disabled, `is_live` is admin-owned (editable form field). External hosts must be reachable from the server or those tokens flip false while enabled. - `app/auth.py` — `AdminAuthBackend`, fail-closed creds. - `tokenList.json` / `versionAndroid.json` — seed source (read-only after seed). diff --git a/SPEC.md b/SPEC.md index d6f0f8d..c9c252a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -13,6 +13,7 @@ FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getToken - 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 `IDRS_VERIFY_ENABLED` (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 - 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 (migration-free); SQLAlchemy reads the same file - run: `./venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000` + `run.sh` wrapper @@ -46,6 +47,7 @@ V13: version table single row (`id=1`) maintained — panel ⊥ creates duplicat V14: ∀ upload → PNG verified by magic bytes `\x89PNG\r\n\x1a\n` (⊥ trust `content-type` header), extension forced `.png`, size ≤ 512KB; `/img/` served with `image/png` (⊥ `guess_type`) V15: token edit/create form upload → `on_model_change` sets `filename` + `iconUrl={IDRS_PUBLIC_URL}/img/token/` (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_ENABLED` truthy (absent/0/false/off → off): when enabled, verifier overwrites `is_live` each pass (live iff iconUrl reachable; empty → false; flips after 2 consecutive failed passes, recovers next success) ## §T — Tasks id|status|task|cites @@ -73,6 +75,7 @@ T21|x|`tokenList.json`: replace seed iconUrl domain `idrs.kriptoteknologi.io` T22|x|icon upload → `token/` subdir + `/img/token/` 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/` 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_ENABLED` (default off) — when off, `is_live` admin-editable|V17 ## §B — Bug log id|date|cause|fix diff --git a/app/main.py b/app/main.py index ce8b996..d338f9e 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,13 @@ from fastapi.staticfiles import StaticFiles from app import db from app.admin import create_admin +from app.verify import Verifier BASE_DIR = Path(__file__).resolve().parent ICON_DIR = BASE_DIR / "static" / "icons" +verifier = Verifier() + app = FastAPI(title="IDRS API") app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") @@ -68,4 +71,5 @@ def get_public_icon(filename: str): @app.on_event("startup") def startup(): - db.init_db() \ No newline at end of file + db.init_db() + verifier.start() \ No newline at end of file diff --git a/app/verify.py b/app/verify.py new file mode 100644 index 0000000..2c6b7d4 --- /dev/null +++ b/app/verify.py @@ -0,0 +1,91 @@ +import logging +import os +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + +from app.models import ICON_DIR, SessionLocal, Token + +logger = logging.getLogger("idrs.verify") + +ENABLED = os.environ.get("IDRS_VERIFY_ENABLED", "").strip().lower() in ("1", "true", "yes", "on") +PUBLIC_BASE_URL = os.environ.get("IDRS_PUBLIC_URL", "https://idrs.databisnis.id").rstrip("/") +PUBLIC_HOST = urlparse(PUBLIC_BASE_URL).hostname or "idrs.databisnis.id" +INTERVAL = int(os.environ.get("IDRS_VERIFY_INTERVAL", "600")) +GRACE_PASSES = 2 +HTTP_TIMEOUT = 3 +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" + + +def _local_reachable(filename: str) -> bool: + token_dir = ICON_DIR / "token" + path = (token_dir / filename).resolve() + return path.is_file() and token_dir.resolve() in path.parents + + +def _http_reachable(url: str) -> bool: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "image/*,*/*"}) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + return 200 <= resp.status < 300 + except urllib.error.HTTPError as e: + logger.warning("icon http %s for %s", e.code, url) + return False + except Exception as e: + logger.warning("icon network error %r for %s", e, url) + return False + + +def reachable(token) -> bool: + url = (token.iconUrl or "").strip() + if not url: + return False + if urlparse(url).hostname == PUBLIC_HOST: + return _local_reachable(token.filename or "") + return _http_reachable(url) + + +class Verifier(threading.Thread): + def __init__(self): + super().__init__(daemon=True, name="idrs-verify") + self._stop = threading.Event() + self._fail_counts = {} + + def stop(self): + self._stop.set() + + def start(self): + if not ENABLED: + logger.info("verifier disabled (IDRS_VERIFY_ENABLED not set)") + return + super().start() + + def run(self): + while not self._stop.is_set(): + try: + self._check_once() + except Exception: + logger.exception("verify pass failed") + self._stop.wait(INTERVAL) + + def _check_once(self): + session = SessionLocal() + try: + for token in session.query(Token).all(): + ok = reachable(token) + if ok: + self._fail_counts[token.idfToken] = 0 + live = True + else: + fails = self._fail_counts.get(token.idfToken, 0) + 1 + self._fail_counts[token.idfToken] = fails + live = fails < GRACE_PASSES + if bool(token.is_live) != live: + token.is_live = live + logger.info("token %s is_live -> %s (%s)", token.idfToken, live, token.iconUrl) + session.commit() + finally: + session.close() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e3503a1..412cb8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,8 @@ services: idrs-api: image: git.proit.id/proitlab/idrs-api:latest environment: - ADMIN_USERNAME: admin - ADMIN_PASSWORD: Pro4dm1n + ADMIN_USERNAME: + ADMIN_PASSWORD: IDRS_PUBLIC_URL: https://idrs.databisnis.id FORWARDED_ALLOW_IPS: "*" volumes: @@ -27,4 +27,4 @@ services: networks: traefik-net: - external: true \ No newline at end of file + external: true