feat: dapp registry + /api/getDapps, /img/dapps, DappAdmin; dapp-link verifier default-on (IDRS_VERIFY_DAPPS); is_live sticky override; fix edit-without-upload (Dapp+Token)

This commit is contained in:
proitlab committed 2026-08-18 14:43:09 +07:00
1 parent 77d6cd5b92
commit 3b68820beb
9 files changed
+447 -51

No files matched your search

+129 -2
View File
@@ -1,24 +1,37 @@
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from sqladmin import Admin, ModelView
from starlette.datastructures import UploadFile
from starlette.requests import Request
from wtforms import BooleanField, FileField, Form, IntegerField, StringField
from wtforms import BooleanField, FileField, Form, IntegerField, SelectField, StringField
from wtforms.validators import DataRequired
from sqlalchemy import text
from app.auth import AdminAuthBackend, get_session_secret
from app.models import ICON_DIR, SessionLocal, Token, Version
from app.models import ICON_DIR, SessionLocal, Dapp, Token, Version
BASE_DIR = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = str(BASE_DIR / "app" / "templates")
MAX_ICON_BYTES = 512 * 1024
MAX_DAPP_IMAGE_BYTES = 2 * 1024 * 1024
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
PUBLIC_BASE_URL = os.environ.get("IDRS_PUBLIC_URL", "https://idrs.databisnis.id").rstrip("/")
DAPP_IMAGE_MAGIC = [
(b"\x89PNG\r\n\x1a\n", ".png"),
(b"\xff\xd8\xff", ".jpg"),
(b"GIF87a", ".gif"),
(b"GIF89a", ".gif"),
(b"RIFF", ".webp"),
(b"\x00\x00\x01\x00", ".ico"),
(b"BM", ".bmp"),
]
def _is_authenticated(request: Request) -> bool:
return bool(request.session.get("auth"))
@@ -46,6 +59,30 @@ def _next_token_id() -> int:
return int(session.execute(text("SELECT COALESCE(MAX(idfToken), 0) + 1 FROM tokens")).scalar())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _sanitize_dapp_image_name(name: str) -> str:
base = re.sub(r"[^A-Za-z0-9._-]+", "_", Path(name).name).strip("._") or "dapp"
return base[:80]
async def _extract_dapp_image(raw) -> bytes:
if isinstance(raw, UploadFile):
return await raw.read(MAX_DAPP_IMAGE_BYTES + 1)
stream = raw.stream if hasattr(raw, "stream") else raw
data = stream.read(MAX_DAPP_IMAGE_BYTES + 1)
return data if isinstance(data, bytes) else bytes(data)
def _sniff_image_extension(content: bytes) -> str | None:
for magic, ext in DAPP_IMAGE_MAGIC:
if content.startswith(magic):
return ext
return None
class NullableIntegerField(IntegerField):
def process_formdata(self, valuelist):
if valuelist and valuelist[0].strip() == "":
@@ -130,6 +167,95 @@ class TokenAdmin(ModelView, model=Token):
data["iconUrl"] = f"{PUBLIC_BASE_URL}/img/token/{filename}"
class DappForm(Form):
name = StringField("Name", validators=[DataRequired()])
description = StringField("Description")
link = StringField("Link")
image = StringField("Image URL")
imageFile = FileField("Image file")
is_live = BooleanField("Live")
counter = IntegerField("Counter", default=0)
category_id = NullableIntegerField("Category ID")
is_available_indonesia = SelectField(
"Available in Indonesia",
choices=[("", "—"), ("true", "Yes"), ("false", "No")],
default="",
)
class DappAdmin(ModelView, model=Dapp):
name = "Dapps"
icon = "fa-solid fa-globe"
form = DappForm
column_list = [
"name",
"link",
"image",
"is_live",
"counter",
"category_id",
"is_available_indonesia",
]
column_labels = {
"is_live": "Live",
"category_id": "Category",
"is_available_indonesia": "Avail. ID",
}
column_details_list = [
"id",
"name",
"description",
"link",
"image",
"is_live",
"counter",
"category_id",
"is_available_indonesia",
"created_at",
"updated_at",
]
column_searchable_list = ["name", "description", "link"]
column_default_sort = [("id", True)]
page_size = 25
def is_accessible(self, request: Request) -> bool:
return _is_authenticated(request)
async def on_model_change(self, data, model, is_created, request):
data.pop("id", None)
data.pop("is_live_override", None)
submitted_live = bool(data.get("is_live"))
if is_created:
data["is_live_override"] = not submitted_live
elif submitted_live != bool(model.is_live):
data["is_live_override"] = not submitted_live
if data.get("is_available_indonesia") == "":
data["is_available_indonesia"] = None
elif data.get("is_available_indonesia") in ("true", "True", "1", 1, "y", "on"):
data["is_available_indonesia"] = True
elif data.get("is_available_indonesia") in ("false", "False", "0", 0, "n", "off"):
data["is_available_indonesia"] = False
raw = data.get("imageFile")
data.pop("imageFile", None)
if raw:
content = await _extract_dapp_image(raw)
if not content:
return
if len(content) > MAX_DAPP_IMAGE_BYTES:
raise Exception(f"Image must be ≤ {MAX_DAPP_IMAGE_BYTES // 1024}KB")
ext = _sniff_image_extension(content)
if not ext:
raise Exception("Image must be a real image (PNG/JPEG/GIF/WebP/ICO/BMP)")
filename = f"{Path(_sanitize_dapp_image_name(getattr(raw, 'filename', '') or 'dapp')).stem}{ext}"
dapp_dir = ICON_DIR / "dapps"
dapp_dir.mkdir(parents=True, exist_ok=True)
(dapp_dir / filename).write_bytes(content)
data["image"] = f"{PUBLIC_BASE_URL}/img/dapps/{filename}"
if is_created:
data["created_at"] = data.get("created_at") or _now_iso()
data["updated_at"] = _now_iso()
class VersionAdmin(ModelView, model=Version):
name = "Android Version"
icon = "fa-solid fa-mobile-screen"
@@ -167,4 +293,5 @@ def create_admin(app):
)
admin.add_view(TokenAdmin)
admin.add_view(VersionAdmin)
admin.add_view(DappAdmin)
return admin
+139
View File
@@ -1,21 +1,70 @@
import json
import logging
import os
import re
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
from app.models import (
ICON_DIR,
SessionLocal,
Dapp,
Token,
Version,
Base,
engine,
)
logger = logging.getLogger("idrs.seed")
BASE_DIR = Path(__file__).resolve().parent.parent
SEED_TOKEN = BASE_DIR / "tokenList.json"
SEED_VERSION = BASE_DIR / "versionAndroid.json"
SEED_DAPPS = BASE_DIR / "dapps.json"
PUBLIC_BASE_URL = os.environ.get("IDRS_PUBLIC_URL", "https://idrs.databisnis.id").rstrip("/")
IMAGE_TIMEOUT = 8
IMAGE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
IMAGE_MAGIC = [
(b"\x89PNG\r\n\x1a\n", ".png"),
(b"\xff\xd8\xff", ".jpg"),
(b"GIF87a", ".gif"),
(b"GIF89a", ".gif"),
(b"RIFF", ".webp"),
(b"\x00\x00\x01\x00", ".ico"),
(b"BM", ".bmp"),
]
IMAGE_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
"image/svg+xml": ".svg",
"image/bmp": ".bmp",
}
def _ensure_migrations(engine):
from sqlalchemy import inspect
inspected = inspect(engine)
cols = {c["name"] for c in inspected.get_columns("dapps")} if "dapps" in inspected.get_table_names() else set()
if "is_live_override" not in cols:
from sqlalchemy import text
with engine.begin() as conn:
conn.execute(text("ALTER TABLE dapps ADD COLUMN is_live_override BOOLEAN NOT NULL DEFAULT 0"))
def init_db():
Base.metadata.create_all(engine)
_ensure_migrations(engine)
seed()
@@ -50,11 +99,93 @@ def seed():
latestVersion=int(v["latestVersion"]),
)
)
seed_dapps(session)
session.commit()
finally:
session.close()
def _image_extension(content_type: str, url: str) -> str:
ext = IMAGE_EXT.get((content_type or "").split(";")[0].strip().lower())
if ext:
return ext
m = re.search(r"\.(png|jpe?g|gif|webp|ico|bmp|svg)$", url.lower())
return "." + m.group(1) if m else ".img"
def _mirror_image(url: str, seen: dict):
if url in seen:
return seen[url]
try:
req = urllib.request.Request(
url, headers={"User-Agent": IMAGE_USER_AGENT, "Accept": "image/*,*/*"}
)
with urllib.request.urlopen(req, timeout=IMAGE_TIMEOUT) as resp:
content_type = (resp.headers.get_content_type() or "").split(";")[0].strip().lower()
data = resp.read()
except Exception as e:
logger.warning("dapp image fetch failed %r for %s", e, url)
return None
if not data:
return None
if content_type == "application/octet-stream":
for magic, ext in IMAGE_MAGIC:
if data.startswith(magic):
content_type = next((k for k, v in IMAGE_EXT.items() if v == ext), content_type)
break
else:
content_type = ""
if not content_type.startswith("image/"):
logger.warning("dapp image %s not an image (%s)", url, content_type)
return None
base = re.sub(r"[^A-Za-z0-9._-]+", "_", Path(urlparse(url).path).name).strip("._") or "dapp"
stem = Path(base).stem[:80]
ext = _image_extension(content_type, url)
dapp_dir = ICON_DIR / "dapps"
dapp_dir.mkdir(parents=True, exist_ok=True)
filename = f"{stem}{ext}"
n = 1
while (dapp_dir / filename).exists():
filename = f"{stem}-{n}{ext}"
n += 1
(dapp_dir / filename).write_bytes(data)
seen[url] = f"{PUBLIC_BASE_URL}/img/dapps/{filename}"
return seen[url]
def seed_dapps(session):
if session.query(Dapp).count() != 0 or not SEED_DAPPS.exists():
return
try:
data = json.loads(SEED_DAPPS.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("dapps.json unreadable: %r", e)
return
rows = data if isinstance(data, list) else data.get("dapps", data)
seen = {}
for rec in rows:
if not isinstance(rec, dict) or rec.get("model") != "dapps.dapps":
continue
f = rec.get("fields", {})
image = (f.get("image") or "").strip()
mirrored = _mirror_image(image, seen) if image else None
session.add(
Dapp(
id=int(rec.get("pk") or 0),
name=f.get("name") or "",
description=f.get("description"),
link=f.get("link"),
image=mirrored or image,
is_live=f.get("is_live") in (1, "1", "true", True),
counter=int(f.get("counter") or 0),
category_id=f.get("category_id"),
is_available_indonesia=f.get("is_available_indonesia"),
created_at=f.get("created_at"),
updated_at=f.get("updated_at"),
)
)
def list_tokens():
session = SessionLocal()
try:
@@ -67,5 +198,13 @@ def get_version():
session = SessionLocal()
try:
return session.query(Version).filter(Version.id == 1).first()
finally:
session.close()
def list_dapps():
session = SessionLocal()
try:
return session.query(Dapp).order_by(Dapp.id.asc()).all()
finally:
session.close()
+46 -3
View File
@@ -1,4 +1,5 @@
from pathlib import Path
import mimetypes
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
@@ -6,12 +7,13 @@ from fastapi.staticfiles import StaticFiles
from app import db
from app.admin import create_admin
from app.verify import Verifier
from app.verify import DappVerifier, TokenVerifier
BASE_DIR = Path(__file__).resolve().parent
ICON_DIR = BASE_DIR / "static" / "icons"
verifier = Verifier()
token_verifier = TokenVerifier()
dapp_verifier = DappVerifier()
app = FastAPI(title="IDRS API")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
@@ -59,6 +61,14 @@ def _serve_icon(filename: str):
return FileResponse(path, media_type="image/png")
def _serve_dapp_image(filename: str):
dapp_dir = ICON_DIR / "dapps"
path = (dapp_dir / filename).resolve()
if not path.is_file() or dapp_dir.resolve() not in path.parents:
raise HTTPException(status_code=404, detail="not found")
return FileResponse(path, media_type=mimetypes.guess_type(path.name)[0] or "application/octet-stream")
@app.get("/img/token/{filename}")
def get_icon(filename: str):
return _serve_icon(filename)
@@ -69,7 +79,40 @@ def get_public_icon(filename: str):
return _serve_icon(filename)
@app.get("/img/dapps/{filename}")
def get_dapp_image(filename: str):
return _serve_dapp_image(filename)
@app.get("/public/img/dapps/{filename}")
def get_public_dapp_image(filename: str):
return _serve_dapp_image(filename)
@app.get("/api/getDapps")
def get_dapps():
dapps = []
for d in db.list_dapps():
dapps.append(
{
"id": d.id,
"name": d.name,
"description": d.description,
"link": d.link,
"image": d.image,
"is_live": bool(d.is_live),
"counter": d.counter,
"category_id": d.category_id,
"is_available_indonesia": d.is_available_indonesia,
"created_at": d.created_at,
"updated_at": d.updated_at,
}
)
return {"dappList": dapps}
@app.on_event("startup")
def startup():
db.init_db()
verifier.start()
token_verifier.start()
dapp_verifier.start()
+20 -1
View File
@@ -39,6 +39,7 @@ class Token(Base):
iconUrl = Column("iconUrl", String, nullable=False, default="")
filename = Column("filename", String, nullable=False, default="")
is_live = Column("is_live", Boolean, nullable=False, default=True)
icon = None
class Version(Base):
@@ -48,4 +49,22 @@ class Version(Base):
idVersion = Column("idVersion", Integer, nullable=False)
appName = Column("appName", String, nullable=False)
acceptableVersion = Column("acceptableVersion", Integer, nullable=False)
latestVersion = Column("latestVersion", Integer, nullable=False)
latestVersion = Column("latestVersion", Integer, nullable=False)
class Dapp(Base):
__tablename__ = "dapps"
id = Column(Integer, primary_key=True)
name = Column("name", String, nullable=False)
description = Column("description", String, nullable=True)
link = Column("link", String, nullable=True)
image = Column("image", String, nullable=True)
is_live = Column("is_live", Boolean, nullable=False, default=True)
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)
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)
imageFile = None
+87 -31
View File
@@ -1,17 +1,16 @@
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
from app.models import ICON_DIR, SessionLocal, Dapp, Token
logger = logging.getLogger("idrs.verify")
ENABLED = os.environ.get("IDRS_VERIFY_ENABLED", "").strip().lower() in ("1", "true", "yes", "on")
TOKENS_ENABLED = os.environ.get("IDRS_VERIFY_TOKENS", "").strip().lower() in ("1", "true", "yes", "on")
DAPPS_ENABLED = os.environ.get("IDRS_VERIFY_DAPPS", "1").strip().lower() not in ("0", "false", "off", "no")
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"))
@@ -26,31 +25,25 @@ def _local_reachable(filename: str) -> bool:
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/*,*/*"})
def _http_reachable(url: str, accept: str = "image/*,*/*") -> bool:
req = urllib.request.Request(
url, headers={"User-Agent": USER_AGENT, "Accept": accept}
)
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)
logger.warning("http %s for %s", e.code, url)
return False
except Exception as e:
logger.warning("icon network error %r for %s", e, url)
logger.warning("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")
def __init__(self, name: str, enabled: bool):
super().__init__(daemon=True, name=name)
self._enabled = enabled
self._stop = threading.Event()
self._fail_counts = {}
@@ -58,8 +51,8 @@ class Verifier(threading.Thread):
self._stop.set()
def start(self):
if not ENABLED:
logger.info("verifier disabled (IDRS_VERIFY_ENABLED not set)")
if not self._enabled:
logger.info("%s disabled", self.name)
return
super().start()
@@ -68,24 +61,87 @@ class Verifier(threading.Thread):
try:
self._check_once()
except Exception:
logger.exception("verify pass failed")
logger.exception("%s pass failed", self.name)
self._stop.wait(INTERVAL)
def _check_once(self):
session = SessionLocal()
try:
for token in session.query(Token).all():
ok = reachable(token)
for item in self._items(session):
if self._skip(item):
continue
ok = self._reachable(item)
key = self._key(item)
if ok:
self._fail_counts[token.idfToken] = 0
self._fail_counts[key] = 0
live = True
else:
fails = self._fail_counts.get(token.idfToken, 0) + 1
self._fail_counts[token.idfToken] = fails
fails = self._fail_counts.get(key, 0) + 1
self._fail_counts[key] = 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)
if bool(item.is_live) != live:
item.is_live = live
logger.info("%s is_live -> %s (%s)", key, live, self._label(item))
session.commit()
finally:
session.close()
session.close()
def _items(self, session):
raise NotImplementedError
def _key(self, item):
raise NotImplementedError
def _label(self, item):
raise NotImplementedError
def _reachable(self, item) -> bool:
raise NotImplementedError
def _skip(self, item) -> bool:
return False
class TokenVerifier(Verifier):
def __init__(self):
super().__init__(name="idrs-verify-tokens", enabled=TOKENS_ENABLED)
def _items(self, session):
return session.query(Token).all()
def _key(self, item):
return item.idfToken
def _label(self, item):
return item.iconUrl or ""
def _reachable(self, item) -> bool:
url = (item.iconUrl or "").strip()
if not url:
return False
if urlparse(url).hostname == PUBLIC_HOST:
return _local_reachable(item.filename or "")
return _http_reachable(url, "image/*,*/*")
class DappVerifier(Verifier):
def __init__(self):
super().__init__(name="idrs-verify-dapps", enabled=DAPPS_ENABLED)
def _items(self, session):
return session.query(Dapp).all()
def _key(self, item):
return item.id
def _label(self, item):
return item.link or ""
def _reachable(self, item) -> bool:
url = (item.link or "").strip()
if not url:
return False
return _http_reachable(url, "text/html,application/xhtml+xml,*/*")
def _skip(self, item) -> bool:
return bool(getattr(item, "is_live_override", False))