diff --git a/pyproject.toml b/pyproject.toml index cfd2102..c79cc0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,11 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "aiofiles>=25.1.0", + "bcrypt>=5.0.0", "fal-client>=1.0.0", "fastapi>=0.139.0", + "itsdangerous>=2.2.0", + "psutil>=7.2.2", "python-multipart>=0.0.32", "uvicorn[standard]>=0.51.0", ] diff --git a/scripts/users.py b/scripts/users.py new file mode 100644 index 0000000..1e7c54b --- /dev/null +++ b/scripts/users.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +"""Manage MODELBEAST users directly against the DB — for bootstrapping or when +you're locked out of the UI. Run from the repo root: uv run python scripts/users.py ... +""" +import argparse +import getpass +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from server import auth, db # noqa: E402 + + +def main(): + con = db.connect() + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + a = sub.add_parser("add") + a.add_argument("username") + a.add_argument("--owner", action="store_true") + a.add_argument("--max-jobs", type=int, default=4) + sub.add_parser("list") + p = sub.add_parser("passwd") + p.add_argument("username") + d = sub.add_parser("delete") + d.add_argument("username") + args = ap.parse_args() + + if args.cmd == "add": + pw = getpass.getpass("password: ") + u = auth.create_user(con, args.username, pw, + role="owner" if args.owner else "guest", + max_active_jobs=args.max_jobs) + print(f"created {u['username']} ({u['role']})") + elif args.cmd == "list": + for u in auth.list_users(con): + print(f"{u['username']:<20} {u['role']:<7} max_jobs={u['max_active_jobs']}") + elif args.cmd == "passwd": + u = auth.get_user_by_name(con, args.username) + if not u: + sys.exit("no such user") + auth.set_password(con, u["id"], getpass.getpass("new password: ")) + print("updated") + elif args.cmd == "delete": + u = auth.get_user_by_name(con, args.username) + if not u: + sys.exit("no such user") + auth.delete_user(con, u["id"]) + print("deleted") + + +if __name__ == "__main__": + main() diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..7fffc40 --- /dev/null +++ b/server/auth.py @@ -0,0 +1,188 @@ +"""Authentication: bcrypt passwords, signed-cookie sessions, bearer API tokens, +and the FastAPI dependency that resolves the current user. + +Design premise (owner's hard rule): guests are LOCAL-ONLY. Any operator with a +non-empty requires_env is owner-only and enforced server-side (see main.py) — this +module just answers "who is this request" and "is the password right". +""" +import hashlib +import re +import secrets +import time + +import bcrypt +from fastapi import HTTPException, Request +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from . import db + +SESSION_COOKIE = "mb_session" +SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days +USERNAME_RE = re.compile(r"^[a-z0-9_-]{2,24}$") +MIN_PW_LEN = 6 + +# in-memory login rate limit: ip -> [failure epoch, ...] +_login_fails: dict[str, list[float]] = {} +_RATE_WINDOW = 60.0 +_RATE_MAX = 5 + + +# -- passwords ---------------------------------------------------------------- +def hash_pw(pw: str) -> str: + # bcrypt caps at 72 bytes; truncate deterministically (standard practice) + return bcrypt.hashpw(pw.encode()[:72], bcrypt.gensalt()).decode() + + +def verify_pw(pw: str, pw_hash: str) -> bool: + try: + return bcrypt.checkpw(pw.encode()[:72], pw_hash.encode()) + except (ValueError, TypeError): + return False + + +# -- auth secret (stored hidden in settings; never exposed via the API) ------- +def ensure_auth_secret(con) -> str: + row = con.execute("SELECT value FROM settings WHERE key='auth_secret'").fetchone() + if row and row["value"]: + return row["value"] + secret = secrets.token_hex(32) + con.execute("INSERT OR REPLACE INTO settings (key, value) VALUES ('auth_secret', ?)", (secret,)) + con.commit() + return secret + + +def _serializer(con) -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(ensure_auth_secret(con), salt="mb-session") + + +def make_session(con, user_id: str) -> str: + return _serializer(con).dumps({"uid": user_id}) + + +def read_session(con, token: str) -> str | None: + try: + return _serializer(con).loads(token, max_age=SESSION_MAX_AGE).get("uid") + except (BadSignature, SignatureExpired, Exception): + return None + + +# -- API tokens ---------------------------------------------------------------- +def _token_hash(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +def create_token(con, user_id: str, name: str = "") -> str: + raw = "mbt_" + secrets.token_hex(20) + con.execute( + "INSERT INTO api_tokens (token_hash, user_id, name, created_at) VALUES (?, ?, ?, ?)", + (_token_hash(raw), user_id, name, db.now())) + con.commit() + return raw + + +def user_by_token(con, raw: str) -> dict | None: + row = con.execute( + "SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?", + (_token_hash(raw),)).fetchone() + return dict(row) if row else None + + +# -- users --------------------------------------------------------------------- +def get_user(con, user_id: str) -> dict | None: + row = con.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def get_user_by_name(con, username: str) -> dict | None: + row = con.execute("SELECT * FROM users WHERE username = ?", (username.lower(),)).fetchone() + return dict(row) if row else None + + +def create_user(con, username: str, password: str, role: str = "guest", + max_active_jobs: int = 4) -> dict: + username = username.lower() + if not USERNAME_RE.match(username): + raise ValueError("username must be 2-24 chars of a-z 0-9 _ -") + if len(password) < MIN_PW_LEN: + raise ValueError(f"password must be >= {MIN_PW_LEN} characters") + if get_user_by_name(con, username): + raise ValueError("username already exists") + if role not in ("owner", "guest"): + raise ValueError("role must be owner or guest") + uid = db.new_id() + con.execute( + "INSERT INTO users (id, username, pw_hash, role, max_active_jobs, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (uid, username, hash_pw(password), role, max_active_jobs, db.now())) + con.commit() + return get_user(con, uid) + + +def set_password(con, user_id: str, password: str) -> None: + if len(password) < MIN_PW_LEN: + raise ValueError(f"password must be >= {MIN_PW_LEN} characters") + con.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (hash_pw(password), user_id)) + con.commit() + + +def list_users(con) -> list[dict]: + rows = con.execute( + "SELECT id, username, role, max_active_jobs, created_at FROM users ORDER BY created_at" + ).fetchall() + return [dict(r) for r in rows] + + +def delete_user(con, user_id: str) -> None: + con.execute("DELETE FROM api_tokens WHERE user_id = ?", (user_id,)) + con.execute("DELETE FROM users WHERE id = ?", (user_id,)) + con.commit() + + +def public_user(u: dict) -> dict: + return {"username": u["username"], "role": u["role"], + "is_owner": u["role"] == "owner", "max_active_jobs": u["max_active_jobs"]} + + +# -- rate limit ---------------------------------------------------------------- +def rate_ok(ip: str) -> bool: + now = time.time() + fails = [t for t in _login_fails.get(ip, []) if now - t < _RATE_WINDOW] + _login_fails[ip] = fails + return len(fails) < _RATE_MAX + + +def note_fail(ip: str) -> None: + _login_fails.setdefault(ip, []).append(time.time()) + + +# -- FastAPI dependency -------------------------------------------------------- +def resolve_user(request: Request) -> dict | None: + """Cookie session first, then bearer token. Returns user dict or None.""" + con = request.app.state.con + tok = request.cookies.get(SESSION_COOKIE) + if tok: + uid = read_session(con, tok) + if uid: + u = get_user(con, uid) + if u: + return u + authz = request.headers.get("authorization", "") + if authz.startswith("Bearer "): + u = user_by_token(con, authz[7:].strip()) + if u: + return u + return None + + +def current_user(request: Request) -> dict: + u = resolve_user(request) + if not u: + raise HTTPException(401, "authentication required") + return u + + +def require_owner(request: Request) -> dict: + u = current_user(request) + if u["role"] != "owner": + raise HTTPException(403, "owner only") + return u diff --git a/server/db.py b/server/db.py index b0b2be5..9566677 100644 --- a/server/db.py +++ b/server/db.py @@ -39,12 +39,28 @@ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ); +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + pw_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'guest', -- 'owner' | 'guest'; guests are LOCAL-ONLY + max_active_jobs INTEGER NOT NULL DEFAULT 4, + created_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS api_tokens ( + token_hash TEXT PRIMARY KEY, -- sha256 hex of the raw token + user_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL +); """ # Columns added after the original Phase 0 schema; applied idempotently so an # existing data/modelbeast.db upgrades in place. MIGRATIONS = [ ("jobs", "asset_ids", "TEXT NOT NULL DEFAULT '[]'"), + ("jobs", "user_id", "TEXT"), # nullable; legacy rows = owner-era + ("assets", "user_id", "TEXT"), ] diff --git a/server/main.py b/server/main.py index 585ee5a..85257dc 100644 --- a/server/main.py +++ b/server/main.py @@ -1,17 +1,20 @@ import asyncio import json +import secrets from pathlib import Path -from fastapi import FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect +from fastapi import (Depends, FastAPI, File, HTTPException, Request, Response, + UploadFile, WebSocket, WebSocketDisconnect) from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from . import db, registry, settings as settings_mod, store +from . import auth, db, registry, settings as settings_mod, store, sysinfo from .runner import runner app = FastAPI(title="MODELBEAST") WEB_DIST = db.ROOT / "web" / "dist" INBOX = db.DATA / "inbox" +MAX_UPLOAD = 1024 * 1024 * 1024 # 1 GB @app.on_event("startup") @@ -20,62 +23,196 @@ async def startup(): con = db.connect() app.state.con = con runner.get_settings = lambda: settings_mod.get_all(con) + auth.ensure_auth_secret(con) + _bootstrap_owner(con) asyncio.create_task(runner.worker()) asyncio.create_task(watch_inbox()) -# -- operators ---------------------------------------------------------------- +def _bootstrap_owner(con): + if con.execute("SELECT 1 FROM users LIMIT 1").fetchone(): + return + pw = secrets.token_urlsafe(12) + auth.create_user(con, "monster", pw, role="owner", max_active_jobs=999) + banner = "=" * 62 + print(f"\n{banner}\n MODELBEAST owner account created (first run):\n" + f" username: monster\n password: {pw}\n" + f" change it: MB_TOKEN=... mb user passwd monster (or the UI)\n{banner}\n", + flush=True) + + +def _con(request: Request): + return request.app.state.con + + +def _with_usernames(con, rows: list[dict]) -> list[dict]: + """Attach a 'username' field to job/asset dicts for display.""" + umap = {u["id"]: u["username"] for u in auth.list_users(con)} + for r in rows: + r["username"] = umap.get(r.get("user_id")) or ("—" if r.get("user_id") else "owner") + return rows + + +# -- auth / session ------------------------------------------------------------ +@app.post("/api/login") +async def login(payload: dict, request: Request, response: Response): + ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() \ + or (request.client.host if request.client else "?") + if not auth.rate_ok(ip): + raise HTTPException(429, "too many attempts — wait a minute") + con = _con(request) + username = (payload.get("username") or "").strip().lower() + password = payload.get("password") or "" + u = auth.get_user_by_name(con, username) + if not u or not auth.verify_pw(password, u["pw_hash"]): + auth.note_fail(ip) + raise HTTPException(401, "invalid username or password") + token = auth.make_session(con, u["id"]) + secure = request.headers.get("x-forwarded-proto", "http") == "https" + response.set_cookie(auth.SESSION_COOKIE, token, max_age=auth.SESSION_MAX_AGE, + httponly=True, samesite="lax", secure=secure, path="/") + return auth.public_user(u) + + +@app.post("/api/logout") +async def logout(response: Response): + response.delete_cookie(auth.SESSION_COOKIE, path="/") + return {"ok": True} + + +@app.get("/api/me") +def me(user=Depends(auth.current_user)): + return auth.public_user(user) + + +# -- users (owner only) -------------------------------------------------------- +@app.get("/api/users") +def get_users(request: Request, owner=Depends(auth.require_owner)): + return auth.list_users(_con(request)) + + +@app.post("/api/users") +def add_user(payload: dict, request: Request, owner=Depends(auth.require_owner)): + try: + u = auth.create_user(_con(request), payload.get("username", ""), + payload.get("password", ""), role=payload.get("role", "guest"), + max_active_jobs=int(payload.get("max_active_jobs", 4))) + except ValueError as e: + raise HTTPException(400, str(e)) + return auth.public_user(u) + + +@app.patch("/api/users/{username}") +def patch_user(username: str, payload: dict, request: Request, owner=Depends(auth.require_owner)): + con = _con(request) + u = auth.get_user_by_name(con, username) + if not u: + raise HTTPException(404, "no such user") + if payload.get("password"): + try: + auth.set_password(con, u["id"], payload["password"]) + except ValueError as e: + raise HTTPException(400, str(e)) + if "max_active_jobs" in payload: + con.execute("UPDATE users SET max_active_jobs = ? WHERE id = ?", + (int(payload["max_active_jobs"]), u["id"])) + con.commit() + return auth.public_user(auth.get_user(con, u["id"])) + + +@app.delete("/api/users/{username}") +def del_user(username: str, request: Request, owner=Depends(auth.require_owner)): + con = _con(request) + u = auth.get_user_by_name(con, username) + if not u: + raise HTTPException(404, "no such user") + if u["role"] == "owner": + raise HTTPException(400, "cannot delete an owner") + auth.delete_user(con, u["id"]) + return {"ok": True} + + +@app.post("/api/tokens") +def make_token(payload: dict, request: Request, owner=Depends(auth.require_owner)): + # owner mints a token for a user (default: self) + con = _con(request) + target = payload.get("username") + u = auth.get_user_by_name(con, target) if target else owner + if not u: + raise HTTPException(404, "no such user") + raw = auth.create_token(con, u["id"], payload.get("name", "")) + return {"token": raw, "username": u["username"]} # shown ONCE + + +# -- operators ----------------------------------------------------------------- @app.get("/api/operators") -def list_operators(): - return [{k: v for k, v in op.items() if k != "dir"} for op in runner.operators.values()] +def list_operators(user=Depends(auth.current_user)): + is_owner = user["role"] == "owner" + out = [] + for op in runner.operators.values(): + # guests are LOCAL-ONLY: cloud operators are filtered out entirely + if not is_owner and op.get("requires_env"): + continue + out.append({k: v for k, v in op.items() if k != "dir"}) + return out -# -- settings ----------------------------------------------------------------- +# -- settings (owner only) ----------------------------------------------------- @app.get("/api/settings") -def get_settings(): - s = settings_mod.get_all(app.state.con) +def get_settings(request: Request, owner=Depends(auth.require_owner)): + s = settings_mod.get_all(_con(request)) view = settings_mod.public_view(s) view["_secret_keys"] = sorted(settings_mod.SECRET_KEYS) view["_env_keys"] = sorted(settings_mod.ENV_MAP.keys()) - # env var names currently satisfied — lets the UI gate operators view["_env_set"] = sorted(env for key, env in settings_mod.ENV_MAP.items() if s.get(key)) return view @app.put("/api/settings") -def put_settings(payload: dict): - # ignore masked secret placeholders so re-saving the form doesn't wipe a key - # (an empty string is a deliberate clear, not a placeholder) +def put_settings(payload: dict, request: Request, owner=Depends(auth.require_owner)): clean = {k: v for k, v in payload.items() if not (k in settings_mod.SECRET_KEYS and v and set(str(v)) <= {"•"})} - settings_mod.set_many(app.state.con, clean) - return get_settings() + settings_mod.set_many(_con(request), clean) + return get_settings(request, owner) -# -- assets --------------------------------------------------------------------- +# -- assets -------------------------------------------------------------------- @app.get("/api/assets") -def list_assets(): - return store.list_assets(app.state.con) +def list_assets(user=Depends(auth.current_user), request: Request = None): + con = _con(request) + return _with_usernames(con, store.list_assets(con)) @app.post("/api/assets") -async def upload_asset(file: UploadFile = File(...)): +async def upload_asset(request: Request, file: UploadFile = File(...), + user=Depends(auth.current_user)): + cl = request.headers.get("content-length") + if cl and int(cl) > MAX_UPLOAD: + raise HTTPException(413, "file too large (1 GB max)") data = await file.read() if not data: raise HTTPException(400, "empty file") - return store.register_upload(app.state.con, file.filename or "unnamed", data) + if len(data) > MAX_UPLOAD: + raise HTTPException(413, "file too large (1 GB max)") + return store.register_upload(_con(request), file.filename or "unnamed", data, user_id=user["id"]) @app.delete("/api/assets/{asset_id}") -def delete_asset(asset_id: str): - if not store.delete_asset(app.state.con, asset_id): +def delete_asset(asset_id: str, user=Depends(auth.current_user), request: Request = None): + con = _con(request) + asset = store.get_asset(con, asset_id) + if not asset: raise HTTPException(404, "no such asset") + if user["role"] != "owner" and asset.get("user_id") != user["id"]: + raise HTTPException(403, "not your asset") + store.delete_asset(con, asset_id) return {"ok": True} @app.get("/api/assets/{asset_id}/file") -def asset_file(asset_id: str, member: str | None = None): - asset = store.get_asset(app.state.con, asset_id) +def asset_file(asset_id: str, member: str | None = None, + user=Depends(auth.current_user), request: Request = None): + asset = store.get_asset(_con(request), asset_id) if not asset: raise HTTPException(404, "no such asset") path = Path(asset["path"]) @@ -90,66 +227,99 @@ def asset_file(asset_id: str, member: str | None = None): return FileResponse(path, filename=asset["name"]) -# -- jobs ----------------------------------------------------------------------- +# -- jobs ---------------------------------------------------------------------- @app.get("/api/jobs") -def list_jobs(): - return runner.list_jobs(app.state.con) +def list_jobs(user=Depends(auth.current_user), request: Request = None): + con = _con(request) + return _with_usernames(con, runner.list_jobs(con)) + + +@app.get("/api/jobs/recent") +def recent_jobs(user=Depends(auth.current_user), request: Request = None): + con = _con(request) + return sysinfo.recent_jobs(con, runner) @app.post("/api/jobs") -async def create_job(payload: dict): +async def create_job(payload: dict, user=Depends(auth.current_user), request: Request = None): + con = _con(request) operator = payload.get("operator") if operator not in runner.operators: raise HTTPException(400, f"unknown operator: {operator}") + op = runner.operators[operator] + # HARD RULE: guests run local models only — enforced here regardless of UI + if op.get("requires_env") and user["role"] != "owner": + raise HTTPException(403, "guests run local models only") + if user["role"] != "owner" and runner.active_job_count(con, user["id"]) >= user["max_active_jobs"]: + raise HTTPException(429, "too many active jobs — wait for some to finish") asset_ids = payload.get("asset_ids") if not asset_ids: asset_ids = [payload["asset_id"]] if payload.get("asset_id") else [] for aid in asset_ids: - if not store.get_asset(app.state.con, aid): + if not store.get_asset(con, aid): raise HTTPException(400, f"unknown asset: {aid}") params = payload.get("params") or {} - job = runner.create_job(app.state.con, operator, asset_ids, params) + job = runner.create_job(con, operator, asset_ids, params, user_id=user["id"]) await runner.broadcast({"type": "job", "job": job}) return job @app.get("/api/jobs/{job_id}") -def get_job(job_id: str): - job = runner.get_job(app.state.con, job_id) +def get_job(job_id: str, user=Depends(auth.current_user), request: Request = None): + job = runner.get_job(_con(request), job_id) if not job: raise HTTPException(404, "no such job") return job +def _own_job_or_owner(con, job_id, user): + job = runner.get_job(con, job_id) + if not job: + raise HTTPException(404, "no such job") + if user["role"] != "owner" and job.get("user_id") != user["id"]: + raise HTTPException(403, "not your job") + return job + + @app.post("/api/jobs/{job_id}/cancel") -async def cancel_job(job_id: str): - if not await runner.cancel(app.state.con, job_id): +async def cancel_job(job_id: str, user=Depends(auth.current_user), request: Request = None): + con = _con(request) + _own_job_or_owner(con, job_id, user) + if not await runner.cancel(con, job_id): raise HTTPException(400, "job not cancellable") return {"ok": True} @app.post("/api/jobs/{job_id}/retry") -async def retry_job(job_id: str): - job = runner.get_job(app.state.con, job_id) - if not job: - raise HTTPException(404, "no such job") - new = runner.create_job(app.state.con, job["operator"], - job.get("asset_ids") or [], job["params"]) +async def retry_job(job_id: str, user=Depends(auth.current_user), request: Request = None): + con = _con(request) + job = _own_job_or_owner(con, job_id, user) + op = runner.operators.get(job["operator"], {}) + if op.get("requires_env") and user["role"] != "owner": + raise HTTPException(403, "guests run local models only") + new = runner.create_job(con, job["operator"], job.get("asset_ids") or [], + job["params"], user_id=user["id"]) await runner.broadcast({"type": "job", "job": new}) return new @app.delete("/api/jobs/{job_id}") -def delete_job(job_id: str): - if not runner.delete_job(app.state.con, job_id): +def delete_job(job_id: str, user=Depends(auth.current_user), request: Request = None): + con = _con(request) + _own_job_or_owner(con, job_id, user) + if not runner.delete_job(con, job_id): raise HTTPException(400, "job running or missing") return {"ok": True} -# -- inbox watch folder --------------------------------------------------------- +# -- system / dashboard -------------------------------------------------------- +@app.get("/api/system") +def system(user=Depends(auth.current_user), request: Request = None): + return sysinfo.snapshot(_con(request), runner) + + +# -- inbox watch folder -------------------------------------------------------- async def watch_inbox(): - """Ingest files dropped into data/inbox/ once their size is stable (avoids - grabbing partial copies). Lets UE/Blender/rsync drop outputs in for pickup.""" INBOX.mkdir(parents=True, exist_ok=True) seen: dict[str, int] = {} while True: @@ -171,9 +341,23 @@ async def watch_inbox(): print(f"[inbox] {e}") -# -- websocket --------------------------------------------------------------- +# -- websocket (auth: session cookie or ?token=) ------------------------------- @app.websocket("/ws") async def ws(websocket: WebSocket): + con = websocket.app.state.con + user = None + tok = websocket.cookies.get(auth.SESSION_COOKIE) + if tok: + uid = auth.read_session(con, tok) + if uid: + user = auth.get_user(con, uid) + if not user: + qtok = websocket.query_params.get("token") + if qtok: + user = auth.user_by_token(con, qtok) + if not user: + await websocket.close(code=1008) # policy violation + return await websocket.accept() runner.subscribers.add(websocket) try: @@ -183,6 +367,6 @@ async def ws(websocket: WebSocket): runner.subscribers.discard(websocket) -# -- frontend (built) ----------------------------------------------------------- +# -- frontend (built) — public so the SPA can load and show the login screen ---- if WEB_DIST.exists(): app.mount("/", StaticFiles(directory=WEB_DIST, html=True), name="web") diff --git a/server/runner.py b/server/runner.py index 8ad345f..62f00ab 100644 --- a/server/runner.py +++ b/server/runner.py @@ -52,21 +52,28 @@ class Runner: return lane if lane in LANE_LIMITS else "cpu" # -- job lifecycle -------------------------------------------------------- - def create_job(self, con, operator: str, asset_ids: list[str], params: dict) -> dict: + def create_job(self, con, operator: str, asset_ids: list[str], params: dict, + user_id: str | None = None) -> dict: job_id = db.new_id() outdir = JOBS_DIR / job_id outdir.mkdir(parents=True, exist_ok=True) primary = asset_ids[0] if asset_ids else None con.execute( - "INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at) " - "VALUES (?, ?, 'queued', ?, ?, ?, ?, ?)", + "INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at, user_id) " + "VALUES (?, ?, 'queued', ?, ?, ?, ?, ?, ?)", (job_id, operator, primary, json.dumps(asset_ids), - json.dumps(params), str(outdir), db.now()), + json.dumps(params), str(outdir), db.now(), user_id), ) con.commit() self.queue.put_nowait(job_id) return self.get_job(con, job_id) + def active_job_count(self, con, user_id: str) -> int: + row = con.execute( + "SELECT COUNT(*) c FROM jobs WHERE user_id = ? AND status IN ('queued','running')", + (user_id,)).fetchone() + return row["c"] + def get_job(self, con, job_id: str) -> dict | None: row = con.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() return db.row_to_dict(row) if row else None diff --git a/server/settings.py b/server/settings.py index 7bb4e06..ea9a714 100644 --- a/server/settings.py +++ b/server/settings.py @@ -17,6 +17,9 @@ ENV_MAP = { # keys whose values are secret: rendered as password fields, redacted from logs SECRET_KEYS = {"fal_key", "tripo_key", "meshy_key", "replicate_token", "openrouter_key", "hf_token"} +# internal keys never exposed to the settings API or env injection (auth signing key) +HIDDEN_KEYS = {"auth_secret"} + DEFAULTS = { "archive_host": "m3ultra@100.69.21.128", "archive_path": "~/modelbeast-archive", @@ -27,12 +30,16 @@ def get_all(con) -> dict: rows = con.execute("SELECT key, value FROM settings").fetchall() out = dict(DEFAULTS) for r in rows: + if r["key"] in HIDDEN_KEYS: + continue # auth_secret etc. never reach the UI or env injection out[r["key"]] = r["value"] return out def set_many(con, updates: dict) -> None: for key, value in updates.items(): + if key in HIDDEN_KEYS: + continue # never settable through the settings API con.execute( "INSERT INTO settings (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", diff --git a/server/store.py b/server/store.py index b57f42d..be5f74a 100644 --- a/server/store.py +++ b/server/store.py @@ -24,7 +24,7 @@ def kind_of(name: str) -> str: def register_file(con, src: Path, name: str | None = None, parent_job: str | None = None, - move: bool = False, meta: dict | None = None) -> dict: + move: bool = False, meta: dict | None = None, user_id: str | None = None) -> dict: """Copy/move a file (or directory) into the asset store and record it.""" name = name or src.name asset_id = db.new_id() @@ -50,24 +50,24 @@ def register_file(con, src: Path, name: str | None = None, parent_job: str | Non # (e.g. a splat .ply vs a mesh .ply, or a colmap_dataset folder) kind = meta.pop("kind", kind) con.execute( - "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (asset_id, name, kind, str(dest), size, json.dumps(meta or {}), parent_job, db.now()), + "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at, user_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (asset_id, name, kind, str(dest), size, json.dumps(meta or {}), parent_job, db.now(), user_id), ) con.commit() return get_asset(con, asset_id) -def register_upload(con, name: str, data: bytes) -> dict: +def register_upload(con, name: str, data: bytes, user_id: str | None = None) -> dict: asset_id = db.new_id() dest_dir = ASSETS_DIR / asset_id dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / name dest.write_bytes(data) con.execute( - "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at) " - "VALUES (?, ?, ?, ?, ?, '{}', NULL, ?)", - (asset_id, name, kind_of(name), str(dest), len(data), db.now()), + "INSERT INTO assets (id, name, kind, path, size, meta, parent_job, created_at, user_id) " + "VALUES (?, ?, ?, ?, ?, '{}', NULL, ?, ?)", + (asset_id, name, kind_of(name), str(dest), len(data), db.now(), user_id), ) con.commit() return get_asset(con, asset_id) diff --git a/server/sysinfo.py b/server/sysinfo.py new file mode 100644 index 0000000..27537ad --- /dev/null +++ b/server/sysinfo.py @@ -0,0 +1,128 @@ +"""System snapshot for the dashboard — CPU/RAM/disk (psutil), Apple GPU (macmon, +no sudo), queue/lane occupancy, recent jobs. Everything cached so refresh-spam is +cheap; the slow bits (du of data/ and HF cache) refresh at most every 5 min.""" +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import psutil + +from . import db, store + +_cache: dict = {} + + +def _cached(key: str, ttl: float, fn): + now = time.time() + hit = _cache.get(key) + if hit and now - hit[0] < ttl: + return hit[1] + val = fn() + _cache[key] = (now, val) + return val + + +def _macmon_gpu(): + def fetch(): + try: + out = subprocess.run(["macmon", "pipe", "-s", "1"], capture_output=True, + text=True, timeout=6).stdout.strip().splitlines() + sample = json.loads(out[-1]) + gpu = sample.get("gpu_usage") or [0, 0.0] + return {"util_percent": round(gpu[1] * 100, 1), + "power_w": round(sample.get("gpu_power", 0.0), 1), + "temp_c": round(sample.get("temp", {}).get("gpu_temp_avg", 0.0), 1), + "source": "macmon"} + except FileNotFoundError: + return {"source": "unavailable", "hint": "brew install macmon for GPU stats"} + except Exception as e: + return {"source": "unavailable", "hint": str(e)[:80]} + return _cached("gpu", 5.0, fetch) + + +def _du_gb(path: Path): + try: + out = subprocess.run(["du", "-sk", str(path)], capture_output=True, + text=True, timeout=60).stdout.split() + return round(int(out[0]) / (1024 * 1024), 1) + except Exception: + return None + + +def _lanes(con, runner): + limits = getattr(runner, "LANE_LIMITS", None) or {"gpu": 1, "cpu": 3, "net": 6} + lanes = {k: {"limit": v, "running": 0, "queued": 0} for k, v in limits.items()} + rows = con.execute("SELECT operator, status FROM jobs WHERE status IN ('queued','running')").fetchall() + for r in rows: + lane = runner.lane_of(r["operator"]) + if lane not in lanes: + lane = "cpu" + lanes[lane]["running" if r["status"] == "running" else "queued"] += 1 + return lanes + + +def _jobs_24h(con): + cutoff = time.time() - 86400 + rows = con.execute( + "SELECT operator, status FROM jobs WHERE finished_at >= ?", (cutoff,)).fetchall() + out = {"done": 0, "error": 0, "cancelled": 0, "by_operator": {}} + for r in rows: + out[r["status"]] = out.get(r["status"], 0) + 1 + out["by_operator"][r["operator"]] = out["by_operator"].get(r["operator"], 0) + 1 + return out + + +def snapshot(con, runner) -> dict: + def stats(): + per_core = psutil.cpu_percent(interval=0.15, percpu=True) + vm = psutil.virtual_memory() + du = shutil.disk_usage(str(db.DATA)) + try: + load1 = os.getloadavg()[0] + except OSError: + load1 = 0.0 + return { + "cpu": {"percent": round(sum(per_core) / len(per_core), 1), + "cores": len(per_core), "per_core": [round(c, 0) for c in per_core], + "load1": round(load1, 2)}, + "ram": {"used_gb": round((vm.total - vm.available) / 1e9, 1), + "total_gb": round(vm.total / 1e9, 1), "percent": vm.percent}, + "disk": {"free_gb": round(du.free / 1e9, 1), "total_gb": round(du.total / 1e9, 1)}, + "uptime_s": round(time.time() - psutil.boot_time()), + } + base = _cached("stats", 5.0, stats) + hf = Path.home() / ".cache" / "huggingface" + return { + **base, + "gpu": _macmon_gpu(), + "lanes": _lanes(con, runner), + "jobs_24h": _jobs_24h(con), + "data_dir_gb": _cached("data_du", 300.0, lambda: _du_gb(db.DATA)), + "hf_cache_gb": _cached("hf_du", 300.0, lambda: _du_gb(hf) if hf.exists() else None), + "operators": len(runner.operators), + } + + +def recent_jobs(con, runner, limit: int = 20) -> list[dict]: + cutoff = time.time() - 86400 + rows = con.execute( + "SELECT * FROM jobs WHERE status IN ('done','error','cancelled') " + "AND finished_at >= ? ORDER BY finished_at DESC LIMIT ?", (cutoff, limit)).fetchall() + umap = {u_id: name for u_id, name in + con.execute("SELECT id, username FROM users").fetchall()} + out = [] + for r in rows: + j = db.row_to_dict(r) + outs = con.execute( + "SELECT id, kind, name FROM assets WHERE parent_job = ?", (j["id"],)).fetchall() + dur = (j["finished_at"] - j["started_at"]) if j.get("started_at") and j.get("finished_at") else None + out.append({ + "id": j["id"], "operator": j["operator"], "status": j["status"], + "username": umap.get(j.get("user_id")) or ("—" if j.get("user_id") else "owner"), + "duration_s": round(dur, 1) if dur else None, + "outputs": [{"id": o["id"], "kind": o["kind"], "name": o["name"]} for o in outs], + }) + return out diff --git a/tests/smoke.sh b/tests/smoke.sh index a7f0d6a..538d190 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # End-to-end smoke test against a throwaway data dir on a scratch port. -# Validates: operators load, settings CRUD, upload, ffprobe/ffmpeg_frames/ -# blender_convert run to completion, outputs register, cancel + retry work. +# Validates: auth (401/login/token), operators/settings, the 3 core operators, +# retry/delete, and the GUEST security rules (local-only, owner-only settings, +# per-user cap, own-asset). Never touches the production DB/server. set -euo pipefail cd "$(dirname "$0")/.." @@ -9,11 +10,13 @@ PORT=${PORT:-8799} TMP=$(mktemp -d) BLENDER="/Applications/Blender.app/Contents/MacOS/Blender" UV=/opt/homebrew/bin/uv +OJAR="$TMP/owner.cj"; GJAR="$TMP/guest.cj"; CJAR="$TMP/capped.cj" pass=0; fail=0 say() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; } ok() { printf " \033[32mPASS\033[0m %s\n" "$1"; pass=$((pass+1)); } bad() { printf " \033[31mFAIL\033[0m %s\n" "$1"; fail=$((fail+1)); } j() { python3 -c "import json,sys;d=json.load(sys.stdin);print($1)"; } +code(){ curl -s -o /dev/null -w "%{http_code}" "$@"; } # print HTTP status cleanup() { kill "${SRV:-0}" 2>/dev/null || true; rm -rf "$TMP"; } trap cleanup EXIT @@ -31,47 +34,80 @@ ok "test glb" say "start server (MODELBEAST_DATA=$TMP/data, port $PORT)" MODELBEAST_DATA="$TMP/data" "$UV" run uvicorn server.main:app --host 127.0.0.1 --port "$PORT" >"$TMP/server.log" 2>&1 & SRV=$! -for i in $(seq 1 40); do curl -sf "http://127.0.0.1:$PORT/api/operators" >/dev/null 2>&1 && break; sleep 0.5; done -B="http://127.0.0.1:$PORT" - -say "operators + settings" -NOPS=$(curl -s "$B/api/operators" | j "len(d)") -[ "$NOPS" -ge 3 ] && ok "operators loaded ($NOPS)" || bad "operators ($NOPS)" -curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"archive_path":"/tmp/xyz"}' >/dev/null -AP=$(curl -s "$B/api/settings" | j "d['archive_path']") -[ "$AP" = "/tmp/xyz" ] && ok "settings persist" || bad "settings ($AP)" -# secret masking -curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"fal_key":"sk-secret-123456"}' >/dev/null -FK=$(curl -s "$B/api/settings" | j "d['fal_key']") -[ "$FK" = "••••••••" ] && ok "secret masked in GET" || bad "secret leak ($FK)" - -say "upload + ffprobe" -VID=$(curl -s -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']") -GLB=$(curl -s -F "file=@$TMP/mesh.glb" "$B/api/assets" | j "d['id']") -JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"ffprobe\",\"asset_id\":\"$VID\"}" | j "d['id']") -for i in $(seq 1 30); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done -[ "$S" = done ] && ok "ffprobe done" || bad "ffprobe ($S)" - -say "ffmpeg_frames" -JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"ffmpeg_frames\",\"asset_id\":\"$VID\",\"params\":{\"fps\":4,\"max_frames\":8}}" | j "d['id']") -for i in $(seq 1 40); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done -[ "$S" = done ] && ok "ffmpeg_frames done" || bad "ffmpeg_frames ($S)" -NFR=$(curl -s "$B/api/assets" | j "sum(1 for a in d if a['kind']=='frames')") -[ "$NFR" -ge 1 ] && ok "frames asset registered" || bad "no frames asset" - -say "blender_convert glb->fbx (bool param regression)" -JOB=$(curl -s -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "{\"operator\":\"blender_convert\",\"asset_id\":\"$GLB\",\"params\":{\"target\":\"fbx\",\"apply_transforms\":true}}" | j "d['id']") -for i in $(seq 1 60); do S=$(curl -s "$B/api/jobs/$JOB" | j "d['status']"); [ "$S" = done ] || [ "$S" = error ] && break; sleep 0.5; done -[ "$S" = done ] && ok "blender_convert done" || bad "blender_convert ($S)" -NFBX=$(curl -s "$B/api/assets" | j "sum(1 for a in d if a['name'].endswith('.fbx'))") -[ "$NFBX" -ge 1 ] && ok "fbx registered" || bad "no fbx" - -say "retry + delete" -NEW=$(curl -s -X POST "$B/api/jobs/$JOB/retry" | j "d['id']") -[ -n "$NEW" ] && [ "$NEW" != "$JOB" ] && ok "retry spawns new job" || bad "retry" +for i in $(seq 1 40); do curl -s "http://127.0.0.1:$PORT/api/me" >/dev/null 2>&1 && break; sleep 0.5; done sleep 1 -DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$B/api/jobs/$JOB") -[ "$DEL" = 200 ] && ok "delete job" || bad "delete ($DEL)" +B="http://127.0.0.1:$PORT" +OWNERPW=$(grep "password:" "$TMP/server.log" | head -1 | awk '{print $2}') + +say "auth" +[ "$(code $B/api/operators)" = 401 ] && ok "unauth API → 401" || bad "unauth not 401" +LOGIN=$(code -c "$OJAR" -X POST $B/api/login -H 'Content-Type: application/json' -d "{\"username\":\"monster\",\"password\":\"$OWNERPW\"}") +[ "$LOGIN" = 200 ] && ok "owner login" || bad "owner login ($LOGIN)" +[ "$(curl -s -b "$OJAR" $B/api/me | j "d['role']")" = owner ] && ok "/api/me = owner" || bad "me" +[ "$(code -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"monster","password":"wrong"}')" = 401 ] && ok "bad password → 401" || bad "bad pw" +# token flow +TOK=$(curl -s -b "$OJAR" -X POST $B/api/tokens -H 'Content-Type: application/json' -d '{"name":"test"}' | j "d['token']") +[ "$(code -H "Authorization: Bearer $TOK" $B/api/operators)" = 200 ] && ok "bearer token works" || bad "token" +[ "$(code -H "Authorization: Bearer mbt_bogus" $B/api/operators)" = 401 ] && ok "bogus token → 401" || bad "bogus token" + +say "operators + settings (owner)" +NOPS=$(curl -s -b "$OJAR" "$B/api/operators" | j "len(d)") +[ "$NOPS" -ge 3 ] && ok "operators loaded ($NOPS)" || bad "operators ($NOPS)" +curl -s -b "$OJAR" -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"archive_path":"/tmp/xyz"}' >/dev/null +[ "$(curl -s -b "$OJAR" "$B/api/settings" | j "d['archive_path']")" = "/tmp/xyz" ] && ok "settings persist" || bad "settings" +curl -s -b "$OJAR" -X PUT "$B/api/settings" -H 'Content-Type: application/json' -d '{"fal_key":"sk-secret-123456"}' >/dev/null +[ "$(curl -s -b "$OJAR" "$B/api/settings" | j "d['fal_key']")" = "••••••••" ] && ok "secret masked" || bad "secret leak" +[ "$(curl -s -b "$OJAR" "$B/api/settings" | j "'auth_secret' in d")" = False ] && ok "auth_secret hidden" || bad "auth_secret LEAKED" + +say "core operators (owner)" +VID=$(curl -s -b "$OJAR" -F "file=@$TMP/clip.mp4" "$B/api/assets" | j "d['id']") +GLB=$(curl -s -b "$OJAR" -F "file=@$TMP/mesh.glb" "$B/api/assets" | j "d['id']") +post_job() { curl -s -b "$OJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$1" | j "d['id']"; } +wait_job() { local s=""; for i in $(seq 1 60); do s=$(curl -s -b "$OJAR" "$B/api/jobs/$1" | j "d.get('status','?')"); { [ "$s" = done ] || [ "$s" = error ]; } && break; sleep 0.5; done; echo "$s"; } +JID=$(post_job '{"operator":"ffprobe","asset_id":"'"$VID"'"}'); S=$(wait_job "$JID") +[ "$S" = done ] && ok "ffprobe" || bad "ffprobe ($S)" +JID=$(post_job '{"operator":"ffmpeg_frames","asset_id":"'"$VID"'","params":{"fps":4,"max_frames":8}}'); S=$(wait_job "$JID") +[ "$S" = done ] && ok "ffmpeg_frames" || bad "ffmpeg_frames ($S)" +LASTFBX=$(post_job '{"operator":"blender_convert","asset_id":"'"$GLB"'","params":{"target":"fbx","apply_transforms":true}}'); S=$(wait_job "$LASTFBX") +[ "$S" = done ] && ok "blender_convert" || bad "blender_convert ($S)" +NFBX=$(curl -s -b "$OJAR" "$B/api/assets" | j "sum(1 for a in d if a['name'].endswith('.fbx'))") +[ "$NFBX" -ge 1 ] && ok "fbx registered" || bad "no fbx" +NEW=$(curl -s -b "$OJAR" -X POST "$B/api/jobs/$LASTFBX/retry" | j "d['id']") +{ [ -n "$NEW" ] && [ "$NEW" != "$LASTFBX" ]; } && ok "retry" || bad "retry" +DC=$(code -b "$OJAR" -X DELETE "$B/api/jobs/$LASTFBX") +[ "$DC" = 200 ] && ok "delete job" || bad "delete ($DC)" + +say "GUEST security (local-only hard rule)" +curl -s -b "$OJAR" -X POST $B/api/users -H 'Content-Type: application/json' -d '{"username":"testmate","password":"guestpw1"}' >/dev/null +curl -s -c "$GJAR" -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"testmate","password":"guestpw1"}' >/dev/null +GOPS=$(curl -s -b "$GJAR" "$B/api/operators" | j "len(d)") +[ "$GOPS" -lt "$NOPS" ] && ok "guest sees fewer operators ($GOPS < $NOPS)" || bad "guest op count ($GOPS)" +SEES=$(curl -s -b "$GJAR" "$B/api/operators" | j "any(o['id']=='fal_trellis' for o in d)") +[ "$SEES" = False ] && ok "guest cannot see fal_trellis" || bad "guest sees cloud op" +# the load-bearing test: direct POST of a cloud operator by a guest must 403 +FALPAY='{"operator":"fal_trellis","asset_id":"'"$VID"'"}' +c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FALPAY") +[ "$c" = 403 ] && ok "guest fal_trellis POST → 403 (server-side)" || bad "guest cloud NOT blocked ($c)" +c=$(code -b "$GJAR" "$B/api/settings"); [ "$c" = 403 ] && ok "guest settings → 403" || bad "guest settings ($c)" +c=$(code -b "$GJAR" -X POST "$B/api/users" -H 'Content-Type: application/json' -d '{"username":"x","password":"yyyyyy"}') +[ "$c" = 403 ] && ok "guest cannot create users → 403" || bad "guest user-create ($c)" +# guest CAN run a local operator +FFPAY='{"operator":"ffprobe","asset_id":"'"$VID"'"}' +c=$(code -b "$GJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY") +[ "$c" = 200 ] && ok "guest can run local ffprobe" || bad "guest local blocked ($c)" +# guest cannot delete owner's asset +c=$(code -b "$GJAR" -X DELETE "$B/api/assets/$GLB"); [ "$c" = 403 ] && ok "guest cannot delete owner asset → 403" || bad "guest deleted owner asset ($c)" +# per-user cap: capped user with max_active_jobs=0 → any job 429 +curl -s -b "$OJAR" -X POST "$B/api/users" -H 'Content-Type: application/json' -d '{"username":"capped","password":"cappedpw"}' >/dev/null +curl -s -b "$OJAR" -X PATCH "$B/api/users/capped" -H 'Content-Type: application/json' -d '{"max_active_jobs":0}' >/dev/null +curl -s -c "$CJAR" -X POST "$B/api/login" -H 'Content-Type: application/json' -d '{"username":"capped","password":"cappedpw"}' >/dev/null +c=$(code -b "$CJAR" -X POST "$B/api/jobs" -H 'Content-Type: application/json' -d "$FFPAY") +[ "$c" = 429 ] && ok "per-user cap → 429" || bad "cap not enforced ($c)" + +say "dashboard" +SHAPE=$(curl -s -b "$OJAR" "$B/api/system" | j "'gpu' in d and 'lanes' in d") +[ "$SHAPE" = True ] && ok "/api/system shape" || bad "system shape" +c=$(code -b "$GJAR" "$B/api/system"); [ "$c" = 200 ] && ok "guest can view dashboard" || bad "guest dashboard ($c)" printf "\n\033[1mResult: %d passed, %d failed\033[0m\n" "$pass" "$fail" [ "$fail" -eq 0 ] diff --git a/uv.lock b/uv.lock index e01f98c..3829dc1 100644 --- a/uv.lock +++ b/uv.lock @@ -51,6 +51,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/50/463443651ddb0ba66c289d5649369099ab72b4665888b337ed25ea622550/asyncstdlib-3.14.0-py3-none-any.whl", hash = "sha256:cad90c590ce357da6e70c56f0e2ad9e9baaf146a8fc2ede189879da57eb8bd87", size = 44255, upload-time = "2026-03-20T21:06:45.033Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -205,14 +271,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "modelbeast" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, + { name = "bcrypt" }, { name = "fal-client" }, { name = "fastapi" }, + { name = "itsdangerous" }, + { name = "psutil" }, { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -220,8 +298,11 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=25.1.0" }, + { name = "bcrypt", specifier = ">=5.0.0" }, { name = "fal-client", specifier = ">=1.0.0" }, { name = "fastapi", specifier = ">=0.139.0" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "psutil", specifier = ">=7.2.2" }, { name = "python-multipart", specifier = ">=0.0.32" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.51.0" }, ] @@ -278,6 +359,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" diff --git a/web/src/App.jsx b/web/src/App.jsx index f40a793..c72e6cd 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings, - listAssets, listJobs, listOperators, retryJob, runJob, uploadFile, + api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getMe, getSettings, + listAssets, listJobs, listOperators, logout, retryJob, runJob, uploadFile, } from "./api"; import Viewer from "./Viewer"; import SplatViewer from "./SplatViewer"; import Settings from "./Settings"; +import Login from "./Login"; +import Dashboard from "./Dashboard"; const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"]; const fmtSize = (n) => @@ -97,6 +99,8 @@ function CompareGrid({ assets }) { } export default function App() { + const [user, setUser] = useState(undefined); // undefined=loading, null=logged out + const [view, setView] = useState("workspace"); // workspace | dashboard const [assets, setAssets] = useState([]); const [operators, setOperators] = useState([]); const [jobs, setJobs] = useState([]); @@ -109,26 +113,42 @@ export default function App() { const [compareMode, setCompareMode] = useState(false); const [compareSel, setCompareSel] = useState([]); const fileInput = useRef(null); + const wsRef = useRef(null); - const refreshAssets = useCallback(() => listAssets().then(setAssets), []); - const refreshJobs = useCallback(() => listJobs().then(setJobs), []); - const refreshSettings = useCallback(() => getSettings().then(setSettings), []); + const refreshAssets = useCallback(() => listAssets().then(setAssets).catch(() => {}), []); + const refreshJobs = useCallback(() => listJobs().then(setJobs).catch(() => {}), []); + const refreshSettings = useCallback(() => getSettings().then(setSettings).catch(() => {}), []); + // auth bootstrap useEffect(() => { - refreshAssets(); refreshJobs(); refreshSettings(); - listOperators().then(setOperators); - connectWS((msg) => { - if (msg.type === "job") { - setJobs((js) => { - const i = js.findIndex((j) => j.id === msg.job.id); - if (i === -1) return [msg.job, ...js]; - const copy = [...js]; copy[i] = msg.job; return copy; - }); - } - if (msg.type === "assets_changed") refreshAssets(); - }); + getMe().then(setUser).catch(() => setUser(null)); + const onUnauth = () => setUser(null); + window.addEventListener("mb-unauth", onUnauth); + return () => window.removeEventListener("mb-unauth", onUnauth); }, []); + // load workspace data + connect the live socket once, after login + useEffect(() => { + if (!user) return; + refreshAssets(); refreshJobs(); + if (user.is_owner) refreshSettings(); + listOperators().then(setOperators).catch(() => {}); + if (!wsRef.current) { + wsRef.current = connectWS((msg) => { + if (msg.type === "job") { + setJobs((js) => { + const i = js.findIndex((j) => j.id === msg.job.id); + if (i === -1) return [msg.job, ...js]; + const copy = [...js]; copy[i] = msg.job; return copy; + }); + } + if (msg.type === "assets_changed") refreshAssets(); + }); + } + }, [user?.username]); + + const doLogout = async () => { try { await logout(); } catch { /* ignore */ } window.location.reload(); }; + useEffect(() => { const drop = async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files || []) await uploadFile(f); refreshAssets(); }; const paste = async (e) => { for (const it of e.clipboardData?.items || []) { const f = it.getAsFile?.(); if (f) await uploadFile(f); } refreshAssets(); }; @@ -180,17 +200,26 @@ export default function App() { }; const compareAssets = compareSel.map((id) => assets.find((a) => a.id === id)).filter(Boolean); + if (user === undefined) return
…
| {u.username} | +{u.role} | +cap {u.max_active_jobs} | +
+ |
+
{msg}
} +