import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from sqlmodel import select
from ..payment.models import _row_to_model, list_models
from ..proxy import refresh_model_maps, reinitialize_upstreams
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, UpstreamProviderRow, create_session
from .log_manager import log_manager
from .logging import get_logger
from .settings import SettingsService, settings
logger = get_logger(__name__)
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 3600
def require_admin_api(request: Request) -> None:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
return
raise HTTPException(status_code=403, detail="Unauthorized")
def is_admin_authenticated(request: Request) -> bool:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
return True
return False
@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
{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
Hashed Key
Balance (mSats)
Total Spent (mSats)
Total Requests
Refund Address
Refund Time
{rows}
"""
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]:
async with create_session() as session:
result = await session.exec(select(ApiKey))
api_keys = result.all()
return [
{
"hashed_key": key.hashed_key,
"balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
}
for key in api_keys
]
@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]
class PasswordUpdate(BaseModel):
current_password: str
new_password: str
@admin_router.patch("/api/settings", dependencies=[Depends(require_admin_api)])
async def update_settings(request: Request, update: SettingsUpdate) -> dict:
# Remove sensitive fields from general settings update
settings_data = update.__root__.copy()
sensitive_fields = ["admin_password", "upstream_api_key", "nsec"]
for field in sensitive_fields:
if field in settings_data:
del settings_data[field]
async with create_session() as session:
new_settings = await SettingsService.update(settings_data, 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
@admin_router.patch("/api/password", dependencies=[Depends(require_admin_api)])
async def update_password(request: Request, password_update: PasswordUpdate) -> dict:
current_password = settings.admin_password
if not current_password:
raise HTTPException(status_code=500, detail="Admin password not configured")
if password_update.current_password != current_password:
raise HTTPException(status_code=401, detail="Current password is incorrect")
# Validate new password
new_password = password_update.new_password.strip()
if len(new_password) < 6:
raise HTTPException(
status_code=400, detail="New password must be at least 6 characters"
)
# Update password
async with create_session() as session:
await SettingsService.update({"admin_password": new_password}, session)
return {"ok": True, "message": "Password updated successfully"}
class SetupRequest(BaseModel):
password: str
@admin_router.post("/api/setup")
async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, object]:
if settings.admin_password:
raise HTTPException(status_code=409, detail="Admin password already set")
pw = (payload.password or "").strip()
if len(pw) < 8:
raise HTTPException(
status_code=400, detail="Password must be at least 8 characters"
)
async with create_session() as session:
await SettingsService.update({"admin_password": pw}, session)
return {"ok": True}
class AdminLoginRequest(BaseModel):
password: str
@admin_router.post("/api/login")
async def admin_login(
request: Request, payload: AdminLoginRequest
) -> dict[str, object]:
admin_pw = settings.admin_password
if not admin_pw:
raise HTTPException(status_code=500, detail="Admin password not configured")
if payload.password != admin_pw:
raise HTTPException(status_code=401, detail="Invalid password")
token = secrets.token_urlsafe(32)
expiry_timestamp = (
int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
)
admin_sessions[token] = expiry_timestamp
expired_tokens = [
t
for t, exp in admin_sessions.items()
if exp <= int(datetime.now(timezone.utc).timestamp())
]
for t in expired_tokens:
del admin_sessions[t]
return {"ok": True, "token": token, "expires_in": ADMIN_SESSION_DURATION}
@admin_router.post("/api/logout", dependencies=[Depends(require_admin_api)])
async def admin_logout(request: Request) -> dict[str, object]:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
if token in admin_sessions:
del admin_sessions[token]
return {"ok": True}
class WithdrawRequest(BaseModel):
amount: int
mint_url: str | None = None
unit: str = "sat"
def login_form() -> str:
return """
🔐 Admin Login
"""
def setup_form() -> str:
return """
🔧 Initial Admin Setup
Create a secure password for your admin dashboard.
"""
def info(content: str) -> str:
return f"""
"""
def admin_auth() -> str:
admin_pw = settings.admin_password
if admin_pw == "":
return setup_form()
else:
return login_form()
async def dashboard(request: Request) -> str:
return (
f"""
"""
+ """
"""
+ """
Admin Dashboard
💸 Withdraw Balance
🔄 Refresh
🔍 Investigate Logs
🔌 Upstream Providers
⚙️ Settings
×
Withdraw Balance
Select mint and currency:
Enter amount to withdraw:
Maximum: -
Your recommended balance: -
⚠️ Warning: Withdrawing more than your balance will use user funds!
💸 Withdraw
Cancel
×
Edit Settings (JSON)
Values shown as "[REDACTED]" will remain unchanged if left as-is.
💾 Save
Cancel
Withdrawal Token:
Copy 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) -> RedirectResponse:
return RedirectResponse("/")
@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"""
{message}
"""
if extra_fields:
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