Files

332 lines
11 KiB
Python

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, 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, Dapp, Token, Version, Wallet
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"))
def _sanitize_png_name(name: str) -> str:
base = Path(name).name
if base in ("", ".", ".."):
return "icon.png"
if not base.lower().endswith(".png"):
base = f"{Path(base).stem or 'icon'}.png"
return base
async def _extract_icon(raw) -> bytes:
if isinstance(raw, UploadFile):
return await raw.read(MAX_ICON_BYTES + 1)
stream = raw.stream if hasattr(raw, "stream") else raw
data = stream.read(MAX_ICON_BYTES + 1)
return data if isinstance(data, bytes) else bytes(data)
def _next_token_id() -> int:
with SessionLocal() as session:
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() == "":
self.data = None
return
super().process_formdata(valuelist)
class TokenForm(Form):
position = NullableIntegerField("Position", default=_next_token_id)
name = StringField("Name", validators=[DataRequired()])
symbol = StringField("Symbol", validators=[DataRequired()])
contractAddr = StringField("Contract", validators=[DataRequired()])
decimals = IntegerField("Decimals", validators=[DataRequired()])
iconUrl = StringField("Icon URL")
icon = FileField("Icon PNG")
is_live = BooleanField("Live")
class TokenAdmin(ModelView, model=Token):
name = "Tokens"
icon = "fa-solid fa-coins"
form = TokenForm
column_list = [
"position",
"name",
"symbol",
"typeBlockchain",
"contractAddr",
"decimals",
"is_live",
"iconUrl",
]
column_labels = {
"idfToken": "ID",
"position": "Position",
"typeBlockchain": "Blockchain",
"contractAddr": "Contract",
"decimals": "Decimals",
"is_live": "Live",
}
column_details_list = [
"idfToken",
"position",
"typeBlockchain",
"name",
"symbol",
"contractAddr",
"decimals",
"iconUrl",
"filename",
"is_live",
]
column_searchable_list = ["name", "symbol", "contractAddr"]
column_default_sort = [("position", 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("idfToken", None)
data["typeBlockchain"] = "Vexanium"
if data.get("position") in (None, ""):
data["position"] = _next_token_id()
raw = data.get("icon")
data.pop("icon", None)
if not raw:
return
content = await _extract_icon(raw)
if not content:
return
if len(content) > MAX_ICON_BYTES:
raise Exception(f"Icon must be PNG ≤ {MAX_ICON_BYTES // 1024}KB")
if not content.startswith(PNG_MAGIC):
raise Exception("Icon must be a real PNG file")
filename = _sanitize_png_name(getattr(raw, "filename", "") or "icon.png")
token_dir = ICON_DIR / "token"
token_dir.mkdir(parents=True, exist_ok=True)
(token_dir / filename).write_bytes(content)
data["filename"] = filename
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="",
)
is_new_version = BooleanField("New Version")
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",
"is_new_version",
]
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",
"is_new_version",
"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"
can_create = False
can_delete = False
column_list = ["idVersion", "appName", "acceptableVersion", "latestVersion"]
column_labels = {
"idVersion": "ID Version",
"appName": "App",
"acceptableVersion": "Acceptable",
"latestVersion": "Latest",
}
column_details_list = ["idVersion", "appName", "acceptableVersion", "latestVersion"]
form_columns = ["idVersion", "appName", "acceptableVersion", "latestVersion"]
def is_accessible(self, request: Request) -> bool:
return _is_authenticated(request)
async def on_model_change(self, data, model, is_created, request):
acceptable = int(data.get("acceptableVersion", model.acceptableVersion))
latest = int(data.get("latestVersion", model.latestVersion))
if acceptable > latest:
raise Exception("acceptableVersion must be <= latestVersion")
class WalletForm(Form):
account = StringField("Account", validators=[DataRequired()])
class WalletAdmin(ModelView, model=Wallet):
name = "Whitelisted Wallets"
icon = "fa-solid fa-wallet"
form = WalletForm
column_list = ["account", "created_at"]
column_details_list = ["id", "account", "created_at"]
column_searchable_list = ["account"]
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)
account = (data.get("account") or "").strip()
if not account:
raise Exception("Account is required")
with SessionLocal() as session:
exists = session.query(Wallet).filter(Wallet.account == account).first()
if exists and (is_created or exists.id != model.id):
raise Exception(f"'{account}' is already whitelisted")
data["account"] = account
if is_created:
data["created_at"] = _now_iso()
def create_admin(app):
auth_backend = AdminAuthBackend(get_session_secret())
admin = Admin(
app,
session_maker=SessionLocal,
title="IDRS Admin",
base_url="/admin",
templates_dir=TEMPLATES_DIR,
authentication_backend=auth_backend,
)
admin.add_view(TokenAdmin)
admin.add_view(VersionAdmin)
admin.add_view(DappAdmin)
admin.add_view(WalletAdmin)
return admin