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:
1 parent
77d6cd5b92
commit
3b68820beb
9 files changed
+447
-51
No files matched your search
+129
-2
@@ -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
|
||||
Reference in new issue
Block a user