add icon token subdir, Docker, SPEC/AGENTS, initial commit
This commit is contained in:
commit
73eeab7f63
17 files changed
+622
No files matched your search
@@ -0,0 +1,12 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.session_secret
|
||||
idrs.db
|
||||
idrs.db-wal
|
||||
idrs.db-shm
|
||||
app/static/icons/
|
||||
.git/
|
||||
README.md
|
||||
SPEC.md
|
||||
@@ -0,0 +1,5 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
# IDRS_SESSION_SECRET=auto-generated on first run (stored in .session_secret)
|
||||
# IDRS_DB_PATH=
|
||||
# IDRS_PUBLIC_URL=https://idrs.databisnis.id (base URL for uploaded icon URLs)
|
||||
@@ -0,0 +1,9 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
idrs.db
|
||||
idrs.db-wal
|
||||
idrs.db-shm
|
||||
.session_secret
|
||||
.env
|
||||
app/static/icons/*
|
||||
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md — IDRS API server
|
||||
|
||||
Project conventions and operational gotchas for agents working in this repo.
|
||||
|
||||
## Layout
|
||||
- `app/main.py` — FastAPI app: public endpoints `GET /api/getTokenInfo`, `GET /api/version/android`, `GET /img/token/<filename>`; mounts `/static`; startup calls `db.init_db()`.
|
||||
- `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/auth.py` — `AdminAuthBackend`, fail-closed creds.
|
||||
- `tokenList.json` / `versionAndroid.json` — seed source (read-only after seed).
|
||||
|
||||
## Environment / secrets
|
||||
- `.env` (gitignored) → `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `IDRS_PUBLIC_URL` (default `https://idrs.databisnis.id`). Test creds `admin`/`testpass123` — change before exposure.
|
||||
- `.session_secret` (gitignored, auto-generated 0600) if `IDRS_SESSION_SECRET` unset.
|
||||
- NEVER bake `.env` into the Docker image; pass via `--env-file` / `-e`.
|
||||
- Untracked, never commit: `.env`, `.session_secret`, `idrs.db*`, `venv/`, `app/static/icons/*`.
|
||||
|
||||
## Run (local)
|
||||
- `./run.sh` loads `.env` then `uvicorn app.main:app --host 127.0.0.1 --port 8000`.
|
||||
- Start detached: `setsid nohup ./run.sh < /dev/null > /tmp/opencode/idrs.log 2>&1 & disown` (plain `&` hangs).
|
||||
- Sanity-import: `./venv/bin/python -c "from app import main; print('IMPORT_OK')"`.
|
||||
|
||||
## Docker
|
||||
- `docker build -t idrs-api .`
|
||||
- Run with volumes: `-v idrs-data:/app/data -v idrs-icons:/app/app/static/icons`, env via `--env-file .env`.
|
||||
- MUST use a single uvicorn worker (in-memory starlette sessions → `--workers >1` breaks login).
|
||||
- DB + uploaded icons persist in the volumes across container recreate; code changes need an image rebuild.
|
||||
|
||||
## Verification
|
||||
- curl login: `POST /admin/login` with `-c/-b` cookie jar; then curl admin endpoints / uploads with the jar.
|
||||
- Assert `/img/token/<f>` returns `image/png`; HTML/oversize uploads rejected (400).
|
||||
|
||||
## Operation gotchas
|
||||
- NEVER `pkill -f uvicorn` broadly — a separate user process runs uvicorn on port 7000. Kill only the port-8000 PID (`ps -ef | grep "uvicorn app.main:app --host 127.0.0.1 --port 8000"`).
|
||||
- Local server binds 127.0.0.1:8000. Host ports 8080/8100 are taken by other services.
|
||||
|
||||
## Spec
|
||||
- Canonical invariants and task status live in `SPEC.md` (§V invariants, §T tasks, §B bug log). Keep it updated when behavior changes; check §V before modifying.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
IDRS_DB_PATH=/app/data/idrs.db
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY tokenList.json versionAndroid.json ./
|
||||
|
||||
RUN mkdir -p /app/data /app/app/static/icons \
|
||||
&& useradd --system --uid 1000 --create-home idrs \
|
||||
&& chown -R idrs:idrs /app
|
||||
|
||||
USER idrs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# IDRS API
|
||||
|
||||
Python (FastAPI + SQLite) server that mirrors the live idrs-api endpoints, with a login-protected admin panel to manage the data.
|
||||
|
||||
- `GET /api/getTokenInfo` → token list (position ASC, `decimals` int, `is_live` bool)
|
||||
- `GET /api/version/android` → version record
|
||||
- `GET /img/<filename>` → uploaded token icons
|
||||
- `/admin` → sqladmin panel (login-protected): manage tokens, version, upload icons
|
||||
|
||||
## Setup
|
||||
|
||||
```
|
||||
python3.12 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env # set ADMIN_USERNAME / ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
./run.sh # http://127.0.0.1:8000 (admin at /admin)
|
||||
```
|
||||
|
||||
`run.sh` loads `.env`. First run seeds the DB from `tokenList.json` and `versionAndroid.json` (idrs.db is created here; override path via `IDRS_DB_PATH`).
|
||||
|
||||
## Notes
|
||||
|
||||
- **Auth:** single admin, credentials from `ADMIN_USERNAME`/`ADMIN_PASSWORD` env. If they're unset, login is refused and a warning is logged (fail closed). Session secret is auto-generated on first run and stored in `.session_secret`. The public API endpoints stay unauthenticated.
|
||||
- **Version rule:** `acceptableVersion` ≤ `latestVersion` is enforced in the panel; below `acceptableVersion` the Android app force-updates, below `latestVersion` it prompts.
|
||||
- **Icons:** upload PNGs in the admin panel (Icons page); `iconUrl` auto-points to `/img/<filename>`.
|
||||
- No on-chain integration, no audit trail — data is entered manually.
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPEC — IDRS API server (Python FastAPI)
|
||||
|
||||
## §G — Goal
|
||||
FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getTokenInfo` + `GET /api/version/android` + icon files. Admin panel (sqladmin, login-protected) to enter/edit data → served as JSON API. Seed from existing `tokenList.json` + `versionAndroid.json` on first run.
|
||||
|
||||
## §C — Constraints
|
||||
- Python 3, FastAPI + SQLAlchemy + sqlite (WAL) + `sqladmin` admin panel (Bootstrap UI, built-in login) — venv `./venv`, deps: `fastapi`, `uvicorn`, `sqlalchemy`, `sqladmin`, `jinja2`, `python-multipart` (upload); install in venv, ⊥ global
|
||||
- endpoints match live paths verbatim: `/api/getTokenInfo`, `/api/version/android`, `/img/<filename>`; admin panel at `/admin` (login-gated)
|
||||
- admin CRUD via `sqladmin` ModelAdmin: token (10 fields: idfToken, position, typeBlockchain, name, symbol, contractAddr, decimals, iconUrl, filename, is_live) + version record (idVersion, appName, acceptableVersion, latestVersion); ⊖ hand-rolled admin.html + old CRUD routes
|
||||
- auth: single admin via env `ADMIN_USERNAME` + `ADMIN_PASSWORD`; session-signing secret auto-generated on first run + persisted locally (survives restart); creds unset → fail closed (∀ login refused + warning logged, ⊥ default/empty password)
|
||||
- icon upload PNG → stored under `token/` subdir → served `/img/token/<filename>`; upload sets `iconUrl` = `{IDRS_PUBLIC_URL}/img/token/<filename>`; `iconUrl` otherwise free-form (external URL allowed)
|
||||
- cleaned types OK: `decimals` int, `is_live` bool — byte drift from live API (string-typed there) accepted; shape + field names kept verbatim
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
## §I — Interfaces
|
||||
api: GET `/api/getTokenInfo` → 200 `{tokenList:[{idfToken:int, position:int, typeBlockchain:string, name:string, symbol:string, contractAddr:string, decimals:int, iconUrl:string, filename:string, is_live:bool}]}` (position ASC)
|
||||
api: GET `/api/version/android` → 200 `{idVersion:int, appName:string, acceptableVersion:int, latestVersion:int}`
|
||||
api: GET `/img/<filename>` → 200 image/png | 404 unknown
|
||||
web: GET `/admin` → sqladmin panel (login-gated; login page when unauthenticated)
|
||||
web: GET `/admin/login` → login form; POST → session cookie on success
|
||||
web: GET `/admin/logout` → end session
|
||||
web: POST `/admin/token/<id>/icon` → upload PNG (multipart) → sets iconUrl `/img/<filename>` (custom panel view/action)
|
||||
env: `ADMIN_USERNAME`, `ADMIN_PASSWORD` (login); `IDRS_SESSION_SECRET` (session signing, auto-gen + persist on first run)
|
||||
file: `tokenList.json`, `versionAndroid.json` → seed source (repo root); read-only after seed
|
||||
|
||||
## §V — Invariants
|
||||
V1: ∀ token → `idfToken` unique; `position` unique; response ordered `position` ASC
|
||||
V2: response shape fixed: `getTokenInfo` = `{tokenList:[...]}`, `version/android` = flat object; field names verbatim from live API
|
||||
V3: `decimals` int, `is_live` bool in DB + serialized (cleaned types; drift from live string-typing accepted)
|
||||
V4: `acceptableVersion` ≤ `latestVersion`; update violating rule → reject
|
||||
V5: seed runs once: empty DB → import `tokenList.json` + `versionAndroid.json`; DB non-empty → ⊥ reseed
|
||||
V6: ∀ upload → PNG, filename sanitized (basename, safe chars), written under `token/` subdir of icons dir; duplicate filename → overwrite (idempotent)
|
||||
V7: ∀ token → `iconUrl` = `{IDRS_PUBLIC_URL}/img/token/<filename>` after upload; token without upload → keep entered URL (external ok)
|
||||
V8: token delete → row removed; response excludes it on next GET
|
||||
V9: ∀ admin view → auth check before render; unauthenticated → redirect `/admin/login`; wrong creds → reject; logout ends session
|
||||
V10: `ADMIN_USERNAME`/`ADMIN_PASSWORD` unset → ∀ login refused + warning logged; ⊥ default/empty credential
|
||||
V11: public API endpoints (`/api/getTokenInfo`, `/api/version/android`, `/img/...`) ⊥ auth; response shapes unchanged (V2,V3)
|
||||
V12: same `idrs.db` file + schema used by raw-sqlite AND SQLAlchemy (no migration); empty DB → seed still runs
|
||||
V13: version table single row (`id=1`) maintained — panel ⊥ creates duplicates
|
||||
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
|
||||
|
||||
## §T — Tasks
|
||||
id|status|task|cites
|
||||
T1|x|venv + deps (`fastapi`, `uvicorn`, `jinja2`, `python-multipart`)|§C
|
||||
T2|x|sqlite schema + WAL + seed from `tokenList.json`/`versionAndroid.json`|V5
|
||||
T3|x|GET `/api/getTokenInfo` (position ASC, cleaned types)|V1,V2,V3
|
||||
T4|x|GET `/api/version/android`|V2,V3
|
||||
T5|x|icon upload + `/img/<filename>` serving|V6,V7
|
||||
T6|x|`/admin` token list + CRUD|V1,V3,V8
|
||||
T7|x|`/admin` version edit + rule check|V4
|
||||
T8|x|`run.sh` + README quickstart (seed, run, admin)|§C
|
||||
T9|x|deps `sqlalchemy` + `sqladmin`; SQLAlchemy models over existing `idrs.db`|V12
|
||||
T10|x|auth backend (env creds, fail-closed) + persisted session secret|V9,V10
|
||||
T11|x|ModelAdmin Token + Version (single-row guard)|V1,V4,V13
|
||||
T12|x|port seed to SQLAlchemy; public endpoints keep shapes on same DB|V11,V12
|
||||
T13|x|icon upload in panel (custom view/action); drop admin.html + old CRUD routes|V6,V7
|
||||
T14|x|`run.sh`/`.env` + README update (creds, secret, login)|§C,V10
|
||||
T15|x|custom WTForms form on TokenAdmin (FileField `icon`) + `on_model_change` file handling|V15
|
||||
T16|x|remove IconUploadView BaseView + upload.html + `/admin/upload`|V9
|
||||
T17|x|PNG magic-byte + extension + size validation; force `image/png` on `/img`|V14
|
||||
T18|x|`IDRS_PUBLIC_URL` env base for upload iconUrl (default domain); one-time backfill of relative `/img/...` iconUrl|V15
|
||||
T19|x|form: hide `idfToken` (auto) + `typeBlockchain` (fixed Vexanium); `position` default = next id, editable; empty position → auto-default|V16
|
||||
T20|x|Dockerfile (python:3.12-slim, uvicorn single worker, non-root) + .dockerignore; DB (`/app/data`) and icons (`/app/app/static/icons`) volumes persist|§C
|
||||
T21|x|`tokenList.json`: replace seed iconUrl domain `idrs.kriptoteknologi.io` → `idrs.databisnis.id` (14 rows; existing DBs need manual update)|V5
|
||||
T22|x|icon upload → `token/` subdir + `/img/token/<filename>` route (flat route removed); migrate `ayam-logo.png` into `token/`, backfill token1 iconUrl|V15
|
||||
|
||||
## §B — Bug log
|
||||
id|date|cause|fix
|
||||
B1|2026-08-16|seed `tokenList.json`: token CHIP `"filename":null` → `t.get("filename","")` returns None (key exists) → NOT NULL constraint failed|seed null-coalesces `t.get("filename") or ""`, `t.get("iconUrl") or ""` (V5)
|
||||
B2|2026-08-16|icon upload checks only client `content-type` header, saves any filename, `/img` serves mime via `guess_type` → uploaded `x.html` served as `text/html` on API origin (stored XSS)|V14: magic-bytes PNG check + `.png` extension + size cap + forced `image/png` content-type on `/img`
|
||||
Whitespace-only changes.
+170
@@ -0,0 +1,170 @@
|
||||
import os
|
||||
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.validators import DataRequired
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.auth import AdminAuthBackend, get_session_secret
|
||||
from app.models import ICON_DIR, SessionLocal, Token, Version
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
TEMPLATES_DIR = str(BASE_DIR / "app" / "templates")
|
||||
|
||||
MAX_ICON_BYTES = 512 * 1024
|
||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
PUBLIC_BASE_URL = os.environ.get("IDRS_PUBLIC_URL", "https://idrs.databisnis.id").rstrip("/")
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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 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")
|
||||
|
||||
|
||||
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)
|
||||
return admin
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from sqladmin.authentication import AuthenticationBackend
|
||||
from starlette.requests import Request
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_FILE = BASE_DIR / ".session_secret"
|
||||
|
||||
logger = logging.getLogger("idrs.auth")
|
||||
|
||||
|
||||
def get_admin_credentials():
|
||||
user = os.environ.get("ADMIN_USERNAME", "")
|
||||
password = os.environ.get("ADMIN_PASSWORD", "")
|
||||
return user, password
|
||||
|
||||
|
||||
def get_session_secret():
|
||||
secret = os.environ.get("IDRS_SESSION_SECRET", "")
|
||||
if not secret:
|
||||
if SECRET_FILE.exists():
|
||||
secret = SECRET_FILE.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
SECRET_FILE.write_text(secret, encoding="utf-8")
|
||||
os.chmod(SECRET_FILE, 0o600)
|
||||
return secret
|
||||
|
||||
|
||||
def credentials_configured() -> bool:
|
||||
user, password = get_admin_credentials()
|
||||
return bool(user and password)
|
||||
|
||||
|
||||
class AdminAuthBackend(AuthenticationBackend):
|
||||
async def authenticate(self, request: Request) -> bool:
|
||||
return bool(request.session.get("auth"))
|
||||
|
||||
async def login(self, request: Request) -> bool:
|
||||
user, password = get_admin_credentials()
|
||||
if not (user and password):
|
||||
logger.warning("ADMIN_USERNAME/ADMIN_PASSWORD not set — login refused (fail closed)")
|
||||
return False
|
||||
form = await request.form()
|
||||
if secrets.compare_digest(form.get("username", ""), user) and secrets.compare_digest(
|
||||
form.get("password", ""), password
|
||||
):
|
||||
request.session["auth"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
async def logout(self, request: Request) -> bool:
|
||||
request.session.clear()
|
||||
return True
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import (
|
||||
SessionLocal,
|
||||
Token,
|
||||
Version,
|
||||
Base,
|
||||
engine,
|
||||
)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SEED_TOKEN = BASE_DIR / "tokenList.json"
|
||||
SEED_VERSION = BASE_DIR / "versionAndroid.json"
|
||||
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(engine)
|
||||
seed()
|
||||
|
||||
|
||||
def seed():
|
||||
session = SessionLocal()
|
||||
try:
|
||||
if session.query(Token).count() == 0 and SEED_TOKEN.exists():
|
||||
data = json.loads(SEED_TOKEN.read_text(encoding="utf-8"))
|
||||
for t in data["tokenList"]:
|
||||
session.add(
|
||||
Token(
|
||||
idfToken=int(t["idfToken"]),
|
||||
position=int(t["position"]),
|
||||
typeBlockchain=t["typeBlockchain"],
|
||||
name=t["name"],
|
||||
symbol=t["symbol"],
|
||||
contractAddr=t["contractAddr"],
|
||||
decimals=int(t["decimals"]),
|
||||
iconUrl=t.get("iconUrl") or "",
|
||||
filename=t.get("filename") or "",
|
||||
is_live=t.get("is_live") in (1, "1", "true", True),
|
||||
)
|
||||
)
|
||||
if session.query(Version).count() == 0 and SEED_VERSION.exists():
|
||||
v = json.loads(SEED_VERSION.read_text(encoding="utf-8"))
|
||||
session.add(
|
||||
Version(
|
||||
id=1,
|
||||
idVersion=int(v["idVersion"]),
|
||||
appName=v["appName"],
|
||||
acceptableVersion=int(v["acceptableVersion"]),
|
||||
latestVersion=int(v["latestVersion"]),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def list_tokens():
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return session.query(Token).order_by(Token.position.asc()).all()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_version():
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return session.query(Version).filter(Version.id == 1).first()
|
||||
finally:
|
||||
session.close()
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import db
|
||||
from app.admin import create_admin
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ICON_DIR = BASE_DIR / "static" / "icons"
|
||||
|
||||
app = FastAPI(title="IDRS API")
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
create_admin(app)
|
||||
|
||||
|
||||
@app.get("/api/getTokenInfo")
|
||||
def get_token_info():
|
||||
tokens = []
|
||||
for t in db.list_tokens():
|
||||
tokens.append(
|
||||
{
|
||||
"idfToken": t.idfToken,
|
||||
"position": t.position,
|
||||
"typeBlockchain": t.typeBlockchain,
|
||||
"name": t.name,
|
||||
"symbol": t.symbol,
|
||||
"contractAddr": t.contractAddr,
|
||||
"decimals": t.decimals,
|
||||
"iconUrl": t.iconUrl,
|
||||
"filename": t.filename,
|
||||
"is_live": bool(t.is_live),
|
||||
}
|
||||
)
|
||||
return {"tokenList": tokens}
|
||||
|
||||
|
||||
@app.get("/api/version/android")
|
||||
def get_version_android():
|
||||
v = db.get_version()
|
||||
return {
|
||||
"idVersion": v.idVersion,
|
||||
"appName": v.appName,
|
||||
"acceptableVersion": v.acceptableVersion,
|
||||
"latestVersion": v.latestVersion,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/img/token/{filename}")
|
||||
def get_icon(filename: str):
|
||||
token_dir = ICON_DIR / "token"
|
||||
path = (token_dir / filename).resolve()
|
||||
if not path.is_file() or token_dir.resolve() not in path.parents:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
db.init_db()
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import Boolean, Column, Integer, String, create_engine, event
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = Path(os.environ.get("IDRS_DB_PATH", BASE_DIR / "idrs.db"))
|
||||
ICON_DIR = BASE_DIR / "app" / "static" / "icons"
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{DB_PATH}",
|
||||
connect_args={"check_same_thread": False},
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _set_wal(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Token(Base):
|
||||
__tablename__ = "tokens"
|
||||
|
||||
idfToken = Column("idfToken", Integer, primary_key=True)
|
||||
position = Column("position", Integer, unique=True, nullable=False)
|
||||
typeBlockchain = Column("typeBlockchain", String, nullable=False)
|
||||
name = Column("name", String, nullable=False)
|
||||
symbol = Column("symbol", String, nullable=False)
|
||||
contractAddr = Column("contractAddr", String, nullable=False)
|
||||
decimals = Column("decimals", Integer, nullable=False)
|
||||
iconUrl = Column("iconUrl", String, nullable=False, default="")
|
||||
filename = Column("filename", String, nullable=False, default="")
|
||||
is_live = Column("is_live", Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class Version(Base):
|
||||
__tablename__ = "version"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
idVersion = Column("idVersion", Integer, nullable=False)
|
||||
appName = Column("appName", String, nullable=False)
|
||||
acceptableVersion = Column("acceptableVersion", Integer, nullable=False)
|
||||
latestVersion = Column("latestVersion", Integer, nullable=False)
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
sqladmin
|
||||
jinja2
|
||||
python-multipart
|
||||
itsdangerous
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
cd "$(dirname "$0")"
|
||||
if [ -f .env ]; then
|
||||
set -a; . ./.env; set +a
|
||||
fi
|
||||
exec ./venv/bin/uvicorn app.main:app --host "${IDRS_HOST:-127.0.0.1}" --port "${IDRS_PORT:-8000}"
|
||||
@@ -0,0 +1 @@
|
||||
{"tokenList":[{"idfToken":1,"position":1,"typeBlockchain":"Vexanium","name":"IDRS","symbol":"IDRS","contractAddr":"theidrstoken","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/idrs.png","filename":"idrs.png","is_live":"1"},{"idfToken":2,"position":2,"typeBlockchain":"Vexanium","name":"VEX","symbol":"VEX","contractAddr":"vex.token","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/vex.png","filename":"vex.png","is_live":"1"},{"idfToken":3,"position":3,"typeBlockchain":"Vexanium","name":"VYN","symbol":"VYN","contractAddr":"vyndaoutoken","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/vyn.png","filename":"vyn.png","is_live":"1"},{"idfToken":4,"position":4,"typeBlockchain":"Vexanium","name":"DJV","symbol":"DJV","contractAddr":"djvtokenvexa","decimals":"10","iconUrl":"https://idrs.databisnis.id/public/img/token/djv.png","filename":"djv.png","is_live":"1"},{"idfToken":5,"position":5,"typeBlockchain":"Vexanium","name":"Labirin","symbol":"LBN","contractAddr":"tokenlabirin","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/lbn.png","filename":"lbn.png","is_live":"1"},{"idfToken":6,"position":6,"typeBlockchain":"Vexanium","name":"DICE","symbol":"DICE","contractAddr":"vexdicetoken","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/dice.png","filename":"dice.png","is_live":"1"},{"idfToken":7,"position":7,"typeBlockchain":"Vexanium","name":"FLY","symbol":"FLY","contractAddr":"flydicetoken","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/fly.png","filename":"fly.png","is_live":"1"},{"idfToken":8,"position":8,"typeBlockchain":"Vexanium","name":"IDRT","symbol":"IDRT","contractAddr":"idrtidrtidrt","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/idrt.png","filename":"idrt.png","is_live":"1"},{"idfToken":9,"position":9,"typeBlockchain":"Vexanium","name":"BASO","symbol":"BASO","contractAddr":"vexbasotoken","decimals":"10","iconUrl":"https://idrs.databisnis.id/public/img/token/baso.png","filename":"baso.png","is_live":"1"},{"idfToken":10,"position":10,"typeBlockchain":"Vexanium","name":"BATIK","symbol":"BATIK","contractAddr":"batikutokens","decimals":"10","iconUrl":"https://idrs.databisnis.id/public/img/token/batik.png","filename":"batik.png","is_live":"1"},{"idfToken":11,"position":11,"typeBlockchain":"Vexanium","name":"KUJANG","symbol":"KUJANG","contractAddr":"kujangtokenn","decimals":"4","iconUrl":"https://idrs.databisnis.id/public/img/token/kujang.png","filename":"kujang.png","is_live":"1"},{"idfToken":12,"position":12,"typeBlockchain":"Vexanium","name":"BNKRI","symbol":"BNKRI","contractAddr":"bnkriiotoken","decimals":"10","iconUrl":"https://idrs.databisnis.id/public/img/token/bnkri.png","filename":"bnkri.png","is_live":"1"},{"idfToken":13,"position":13,"typeBlockchain":"Vexanium","name":"TRAFI","symbol":"TRAFI","contractAddr":"trafiiotoken","decimals":"10","iconUrl":"https://idrs.databisnis.id/public/img/token/trafi.png","filename":"trafi.png","is_live":"1"},{"idfToken":14,"position":14,"typeBlockchain":"Vexanium","name":"SATE","symbol":"SATE","contractAddr":"satevextoken","decimals":"8","iconUrl":"https://idrs.databisnis.id/public/img/token/sate.png","filename":"sate.png","is_live":"1"},{"idfToken":15,"position":15,"typeBlockchain":"Vexanium","name":"SALAD","symbol":"SALAD","contractAddr":"saladswaptkn","decimals":"8","iconUrl":"https://pbs.twimg.com/profile_images/1410076131105988610/R-qUSgDA.jpg","filename":"R-qUSgDA.jpg","is_live":"1"},{"idfToken":16,"position":16,"typeBlockchain":"Vexanium","name":"VOLKS","symbol":"VOLKS","contractAddr":"volksvolksus","decimals":"10","iconUrl":"https://i.ibb.co/S3cfHG4/volks.jpg","filename":"volks.png","is_live":"1"},{"idfToken":17,"position":17,"typeBlockchain":"Vexanium","name":"BTV","symbol":"BTV","contractAddr":"bitvexatoken","decimals":"8","iconUrl":"https://i.ibb.co/xfj6tLm/bitvexatoken.png","filename":"bitvexatoken.png","is_live":"1"},{"idfToken":18,"position":18,"typeBlockchain":"Vexanium","name":"VX Stable","symbol":"VX","contractAddr":"vexwrap.exy","decimals":"4","iconUrl":"https://i.ibb.co/V9qDf1R/vexwrap-exy.png","filename":"vexwrap-exy.png","is_live":"1"},{"idfToken":19,"position":19,"typeBlockchain":"Vexanium","name":"ECASH","symbol":"ECASH","contractAddr":"ecash.token","decimals":"8","iconUrl":"http://dapp.vexjabar.org/ecash.png","filename":"ecash.png","is_live":"1"},{"idfToken":20,"position":20,"typeBlockchain":"Vexanium","name":"CHIP","symbol":"CHIP","contractAddr":"bitchip","decimals":"2","iconUrl":"https://i.ibb.co/rxfKdR0/20221127-000316.png","filename":null,"is_live":"1"},{"idfToken":21,"position":21,"typeBlockchain":"Vexanium","name":"HASH","symbol":"HASH","contractAddr":"bithash","decimals":"2","iconUrl":"https://i.ibb.co/QbMzbwV/bithash.png","filename":"bithash.png","is_live":"1"},{"idfToken":22,"position":22,"typeBlockchain":"Vexanium","name":"MDR","symbol":"MDR","contractAddr":"maduratokens","decimals":"5","iconLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"idVersion":1,"appName":"idrs","acceptableVersion":143,"latestVersion":143}
|
||||
Reference in new issue
Block a user