add env-gated icon-reachability verifier; admin is_live; docs
This commit is contained in:
1 parent
bc8021825a
commit
77d6cd5b92
5 files changed
+103
-4
No files matched your search
@@ -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/<filename>`.
|
||||
- `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).
|
||||
|
||||
|
||||
@@ -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/<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_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/<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_ENABLED` (default off) — when off, `is_live` admin-editable|V17
|
||||
|
||||
## §B — Bug log
|
||||
id|date|cause|fix
|
||||
|
||||
+5
-1
@@ -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()
|
||||
db.init_db()
|
||||
verifier.start()
|
||||
@@ -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()
|
||||
+3
-3
@@ -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
|
||||
external: true
|
||||
Reference in new issue
Block a user