import json import os from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlmodel import select from ..payment.models import Model, get_model_by_id, list_models from ..wallet import ( fetch_all_balances, get_proofs_per_mint_and_unit, get_wallet, send_token, slow_filter_spend_proofs, ) from .db import ApiKey, ModelRow, create_session from .logging import get_logger from .settings import SettingsService, settings logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) def require_admin_api(request: Request) -> None: admin_cookie = request.cookies.get("admin_password") if not admin_cookie or admin_cookie != settings.admin_password: raise HTTPException(status_code=403, detail="Unauthorized") def is_admin_authenticated(request: Request) -> bool: admin_cookie = request.cookies.get("admin_password") return bool(admin_cookie and admin_cookie == settings.admin_password) @admin_router.get( "/partials/balances", dependencies=[Depends(require_admin_api)], response_class=HTMLResponse, ) async def partial_balances(request: Request) -> str: ( balance_details, total_wallet_balance_sats, total_user_balance_sats, owner_balance, ) = await fetch_all_balances() # Provide JSON for client usage # Embed a script tag to update balanceDetails and the UI markup rows = "".join( [ f"""
{detail["mint_url"].replace("https://", "").replace("http://", "")} • {detail["unit"].upper()}
{detail["wallet_balance"] if not detail.get("error") else "error"}
{detail["user_balance"] if not detail.get("error") else "-"}
0 else ""}">{detail["owner_balance"] if not detail.get("error") else "-"}
""" for detail in balance_details if detail.get("wallet_balance", 0) > 0 or detail.get("error") ] ) return f"""

Cashu Wallet Balance

Your Balance (Total) {owner_balance} sats
Total Wallet {total_wallet_balance_sats} sats
User Balance {total_user_balance_sats} sats

Your balance = Total wallet - User balance

Mint / Unit
Wallet
Users
Owner
{rows}
""" @admin_router.get( "/partials/apikeys", dependencies=[Depends(require_admin_api)], response_class=HTMLResponse, ) async def partial_apikeys(request: Request) -> str: async with create_session() as session: result = await session.exec(select(ApiKey)) api_keys = result.all() def fmt_time(ts: int | None) -> str: if ts is None: return "" dt = datetime.fromtimestamp(ts, tz=timezone.utc) return f"{ts} ({dt.strftime('%Y-%m-%d %H:%M:%S')} UTC)" rows = "".join( [ f"{key.hashed_key}{key.balance}{key.total_spent}{key.total_requests}{key.refund_address}{fmt_time(key.key_expiry_time)}" for key in api_keys ] ) return f"""

Temporary Balances

{rows}
Hashed Key Balance (mSats) Total Spent (mSats) Total Requests Refund Address Refund Time
""" @admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)]) async def get_balances_api(request: Request) -> list[dict[str, object]]: balance_details, _tw, _tu, _ow = await fetch_all_balances() return [dict(d) for d in balance_details] @admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)]) async def get_settings(request: Request) -> dict: data = settings.dict() if "upstream_api_key" in data: data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" if "admin_password" in data: data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" if "nsec" in data: data["nsec"] = "[REDACTED]" if data["nsec"] else "" return data class SettingsUpdate(BaseModel): __root__: dict[str, object] @admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)]) async def update_settings(request: Request, update: SettingsUpdate) -> dict: async with create_session() as session: new_settings = await SettingsService.update(update.__root__, session) data = new_settings.dict() if "upstream_api_key" in data: data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" if "admin_password" in data: data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" if "nsec" in data: data["nsec"] = "[REDACTED]" if data["nsec"] else "" return data class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None unit: str = "sat" def login_form() -> str: return """

🔐 Admin Login

""" def info(content: str) -> str: return f"""

{content}

""" def admin_auth() -> str: try: settings = SettingsService.get() admin_pw = settings.admin_password except Exception: admin_pw = os.getenv("ADMIN_PASSWORD", "") if admin_pw == "": return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.") else: return login_form() async def dashboard(request: Request) -> str: return ( f""" """ + """ """ + """

Admin Dashboard

Loading balances…
Withdrawal Token:

Save this token! It represents your withdrawn balance.

Temporary Balances

Loading API keys…
""" ) @admin_router.get("/", response_class=HTMLResponse) async def admin(request: Request) -> str: if is_admin_authenticated(request): return await dashboard(request) return admin_auth() @admin_router.get("/logs/{request_id}", response_class=HTMLResponse) async def view_logs(request: Request, request_id: str) -> str: if not is_admin_authenticated(request): return admin_auth() logger.info(f"Investigating logs for request_id: {request_id}") # Search for log entries with this request_id log_entries = [] logs_dir = Path("logs") if logs_dir.exists(): # Get all log files sorted by modification time (most recent first) log_files = sorted( logs_dir.glob("*.log"), key=lambda x: x.stat().st_mtime, reverse=True ) for log_file in log_files[:7]: # Check last 7 days of logs try: with open(log_file, "r") as f: for line in f: if request_id in line: try: # Parse JSON log entry log_data = json.loads(line.strip()) log_entries.append(log_data) except json.JSONDecodeError: # If not JSON, include raw line log_entries.append({"raw": line.strip()}) except Exception as e: logger.error(f"Error reading log file {log_file}: {e}") # Sort entries by timestamp if available log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=False) # Format log entries for display formatted_logs = [] for entry in log_entries: if "raw" in entry: formatted_logs.append(f'
{entry["raw"]}
') else: # Format JSON log entry timestamp = entry.get("asctime", "Unknown time") level = entry.get("levelname", "INFO") message = entry.get("message", "") pathname = entry.get("pathname", "") lineno = entry.get("lineno", "") # Extract additional fields extra_fields = { k: v for k, v in entry.items() if k not in [ "asctime", "levelname", "message", "pathname", "lineno", "name", "version", "request_id", ] } level_class = level.lower() formatted_entry = f"""
{timestamp} [{level}] {pathname}:{lineno}
{message}
""" if extra_fields: formatted_entry += '
' for key, value in extra_fields.items(): formatted_entry += f'
{key}: {json.dumps(value) if isinstance(value, (dict, list)) else value}
' formatted_entry += "
" formatted_entry += "
" formatted_logs.append(formatted_entry) return ( f""" """ + f""" ← Back to Dashboard

Log Investigation

Request ID: {request_id}
{"".join(formatted_logs) if formatted_logs else '
No log entries found for this Request ID
'}

Found {len(log_entries)} log entries • Searched last 7 days of logs

""" ) @admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)]) async def withdraw( request: Request, withdraw_request: WithdrawRequest ) -> dict[str, str]: # Get wallet and check balance from .settings import settings as global_settings wallet = await get_wallet( withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit ) proofs = get_proofs_per_mint_and_unit( wallet, withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit, not_reserved=True, ) proofs = await slow_filter_spend_proofs(proofs, wallet) current_balance = sum(proof.amount for proof in proofs) if withdraw_request.amount <= 0: raise HTTPException( status_code=400, detail="Withdrawal amount must be positive" ) if withdraw_request.amount > current_balance: raise HTTPException(status_code=400, detail="Insufficient wallet balance") token = await send_token( withdraw_request.amount, withdraw_request.unit, withdraw_request.mint_url ) return {"token": token} DASHBOARD_MODELS_JS: str = """ """ def models_page() -> str: return ( f""" {DASHBOARD_MODELS_JS} """ + """ ← Back to Dashboard

Models

Models Table

ID
Loading…
""" ) @admin_router.get("/models", response_class=HTMLResponse) async def admin_models(request: Request) -> str: if is_admin_authenticated(request): return models_page() return admin_auth() @admin_router.get("/api/models", dependencies=[Depends(require_admin_api)]) async def get_models_admin_api(request: Request) -> list[dict[str, object]]: items = await list_models() return [m.dict() for m in items] # type: ignore @admin_router.post("/api/models", dependencies=[Depends(require_admin_api)]) async def create_model_admin_api(payload: Model) -> dict[str, object]: async with create_session() as session: exists = await session.get(ModelRow, payload.id) if exists: raise HTTPException( status_code=409, detail="Model with this ID already exists" ) pricing_dict = payload.pricing.dict() for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): pricing_dict.pop(k, None) row = ModelRow( id=payload.id, name=payload.name, description=payload.description, created=int(payload.created), context_length=int(payload.context_length), architecture=json.dumps(payload.architecture.dict()), pricing=json.dumps(pricing_dict), sats_pricing=None, per_request_limits=( json.dumps(payload.per_request_limits) if payload.per_request_limits is not None else None ), top_provider=( json.dumps(payload.top_provider.dict()) if payload.top_provider else None ), ) session.add(row) await session.commit() created_model = await get_model_by_id(payload.id) return created_model.dict() if created_model else {"id": payload.id} # type: ignore @admin_router.post("/api/models/batch", dependencies=[Depends(require_admin_api)]) async def batch_create_models(payload: dict[str, object]) -> dict[str, int]: models = payload.get("models") if not isinstance(models, list) or not models: raise HTTPException( status_code=400, detail="Payload must include non-empty 'models' array" ) created = 0 skipped = 0 async with create_session() as session: for m in models: try: model = Model(**m) # type: ignore[arg-type] except Exception: skipped += 1 continue exists = await session.get(ModelRow, model.id) if exists: skipped += 1 continue pricing_dict = model.pricing.dict() for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): pricing_dict.pop(k, None) row = ModelRow( id=model.id, name=model.name, description=model.description, created=int(model.created), context_length=int(model.context_length), architecture=json.dumps(model.architecture.dict()), pricing=json.dumps(pricing_dict), sats_pricing=None, per_request_limits=( json.dumps(model.per_request_limits) if model.per_request_limits is not None else None ), top_provider=( json.dumps(model.top_provider.dict()) if model.top_provider else None ), ) session.add(row) created += 1 if created: await session.commit() return {"created": created, "skipped": skipped} @admin_router.get( "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] ) async def get_model_admin_api(model_id: str) -> dict[str, object]: model = await get_model_by_id(model_id) if not model: raise HTTPException(status_code=404, detail="Model not found") return model.dict() # type: ignore @admin_router.patch( "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] ) async def update_model_admin_api(model_id: str, payload: Model) -> dict[str, object]: if payload.id != model_id: raise HTTPException(status_code=400, detail="Path id does not match payload id") async with create_session() as session: row = await session.get(ModelRow, model_id) if not row: raise HTTPException(status_code=404, detail="Model not found") row.name = payload.name row.description = payload.description row.created = int(payload.created) row.context_length = int(payload.context_length) row.architecture = json.dumps(payload.architecture.dict()) pricing_dict = payload.pricing.dict() for k in ("max_prompt_cost", "max_completion_cost", "max_cost"): pricing_dict.pop(k, None) row.pricing = json.dumps(pricing_dict) row.sats_pricing = None row.per_request_limits = ( json.dumps(payload.per_request_limits) if payload.per_request_limits is not None else None ) row.top_provider = ( json.dumps(payload.top_provider.dict()) if payload.top_provider else None ) session.add(row) await session.commit() updated = await get_model_by_id(model_id) if not updated: raise HTTPException(status_code=404, detail="Model not found after update") return updated.dict() # type: ignore @admin_router.delete( "/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)] ) async def delete_model_admin_api(model_id: str) -> dict[str, object]: async with create_session() as session: row = await session.get(ModelRow, model_id) if not row: raise HTTPException(status_code=404, detail="Model not found") await session.delete(row) await session.commit() return {"ok": True, "deleted_id": model_id} @admin_router.delete("/api/models", dependencies=[Depends(require_admin_api)]) async def delete_all_models_admin_api() -> dict[str, object]: async with create_session() as session: result = await session.exec(select(ModelRow)) # type: ignore rows = result.all() for row in rows: await session.delete(row) # type: ignore await session.commit() return {"ok": True, "deleted": "all"} DASHBOARD_CSS: str = """ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #2c3e50; line-height: 1.6; padding: 2rem; } h1, h2 { margin-bottom: 1rem; color: #1a202c; } h1 { font-size: 2rem; } h2 { font-size: 1.5rem; margin-top: 2rem; } p { margin-bottom: 0.5rem; color: #4a5568; } table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 1rem; } th { background: #4a5568; color: white; font-weight: 600; padding: 12px; text-align: left; } td { padding: 12px; border-bottom: 1px solid #e2e8f0; } tr:hover { background: #f7fafc; } button { padding: 10px 20px; cursor: pointer; background: #4299e1; color: white; border: none; border-radius: 6px; font-weight: 600; margin-right: 10px; transition: all 0.2s; } button:hover { background: #3182ce; transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); } button:disabled { background: #a0aec0; cursor: not-allowed; transform: none; } .refresh-btn { background: #48bb78; } .refresh-btn:hover { background: #38a169; } .investigate-btn { background: #4299e1; } .balance-card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; } .balance-item { display: flex; justify-content: space-between; margin-bottom: 1rem; } .balance-label { color: #718096; } .balance-value { font-size: 1.5rem; font-weight: 700; color: #2d3748; } .balance-primary { color: #48bb78; } .currency-grid { margin-top: 1rem; font-size: 0.9rem; } .currency-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; align-items: center; } .currency-row:last-child { border-bottom: none; } .currency-header { font-weight: 600; color: #4a5568; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; } .mint-name { color: #2d3748; font-size: 0.85rem; word-break: break-all; } .balance-num { text-align: right; font-family: monospace; } .owner-positive { color: #22c55e; } .error-row { color: #dc2626; font-style: italic; } #token-result { margin-top: 20px; padding: 20px; background: #e6fffa; border: 1px solid #38b2ac; border-radius: 8px; display: none; } #token-text { font-family: 'Monaco', monospace; font-size: 13px; background: #2d3748; color: #68d391; padding: 15px; border-radius: 6px; margin: 10px 0; word-break: break-all; } .copy-btn { background: #38a169; padding: 6px 12px; font-size: 14px; } .copy-btn:hover { background: #2f855a; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); } .modal-content { background: white; margin: 5% auto; padding: 0.75rem 1rem 2.25rem; width: 90%; max-width: 720px; max-height: 85vh; overflow-y: auto; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); animation: slideIn 0.3s ease; } @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .close { color: #a0aec0; float: right; font-size: 28px; font-weight: bold; cursor: pointer; margin: -10px -10px 0 0; } .close:hover { color: #2d3748; } input[type="number"], input[type="text"], select { width: 100%; padding: 10px; margin: 10px 0; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 16px; transition: border 0.2s; } input[type="number"]:focus, input[type="text"]:focus, select:focus { outline: none; border-color: #4299e1; } .warning { color: #e53e3e; font-weight: 600; margin: 10px 0; padding: 10px; background: #fff5f5; border-radius: 6px; } """ LOGS_CSS: str = """ body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; } h1 { color: #333; } .back-btn { padding: 8px 16px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; margin-bottom: 20px; } .back-btn:hover { background-color: #0056b3; } .log-container { background-color: white; border: 1px solid #ddd; border-radius: 8px; padding: 20px; max-height: 80vh; overflow-y: auto; } .log-entry { margin-bottom: 15px; padding: 10px; border: 1px solid #e0e0e0; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 12px; background-color: #f9f9f9; } .log-entry.log-error { background-color: #fee; border-color: #fcc; } .log-entry.log-warning { background-color: #ffc; border-color: #ff9; } .log-entry.log-debug, .log-entry.log-trace { background-color: #f0f0f0; border-color: #ccc; } .log-header { margin-bottom: 5px; color: #666; } .log-timestamp { color: #0066cc; } .log-level { font-weight: bold; } .log-message { margin: 5px 0; color: #333; } .log-extra { margin-top: 5px; padding-top: 5px; border-top: 1px solid #e0e0e0; } .log-field { margin: 2px 0; color: #666; word-break: break-all; } .no-logs { text-align: center; color: #666; padding: 40px; } .request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; } """