import json import secrets from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.timeutil import isoformat_utc from app.audit.log import write_audit_log from app.auth.security import hash_password from app.config import settings from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config from app.wallet.address import is_valid_plm_address from app.wallet.hd import derive_user_wif from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB router = APIRouter(prefix="/admin", tags=["admin"]) async def require_admin(x_admin_token: str = Header(default="")) -> None: # An unset ADMIN_TOKEN denies everything — checked first, since compare_digest # on two empty strings returns True and would otherwise open the panel to # anyone on an instance that never configured a token. if not settings.admin_token: raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") # compare_digest raises TypeError on a str containing non-ASCII characters # (B-46) -- comparing the UTF-8 bytes instead accepts any input safely. if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()): raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") # `paused` is deliberately NOT here: it has its own audit-logged endpoints # (/admin/pause, /admin/resume), and accepting it on PUT /config as well gave the # operator an unlogged way to stop the lottery (B-10). It stays in the response # model, so the dashboard still reads its current value from here. _CONFIG_FIELDS = ( "fee_address", "bet_amount_sats", "round_duration_seconds", "round_cooldown_seconds", "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", ) class RoundConfigResponse(BaseModel): fee_address: str bet_amount_sats: int round_duration_seconds: int round_cooldown_seconds: int fee_rate_sat_vb: int rbf_timeout_seconds: int draw_animation_seconds: int paused: bool class RoundConfigUpdate(BaseModel): """Bounds are enforced here rather than trusting the operator: a value like fee_rate_sat_vb=0 produces transactions no node will relay (stalling every bet, payout and withdrawal), and round_duration_seconds=0 expires a round the instant it opens. `paused` is not accepted — see _CONFIG_FIELDS.""" # An unvalidated fee_address was the worst of the lot: a malformed one wedged the # payout with an unhandled EmbitError, and a well-formed *foreign* one (bc1...) # parses fine as a witness program, so every round's 30 % commission would be # broadcast to a script nobody holds the key for (B-05). fee_address: str | None = None bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000) round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600) round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600) fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB) rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600) draw_animation_seconds: int | None = Field(default=None, ge=0, le=600) @field_validator("fee_address") @classmethod def _validate_fee_address(cls, value: str | None) -> str | None: if value is None: return None value = value.strip() if not is_valid_plm_address(value): raise ValueError( "fee_address must be a valid PLM bech32 address (plm1...) — an address from " "another chain would send every round's commission somewhere unspendable" ) return value def _config_response(config) -> RoundConfigResponse: fields = {field: getattr(config, field) for field in _CONFIG_FIELDS} fields["paused"] = config.paused return RoundConfigResponse(**fields) @router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: config = await get_round_config(session) await session.commit() return _config_response(config) @router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) async def update_config( body: RoundConfigUpdate, session: AsyncSession = Depends(get_session) ) -> RoundConfigResponse: config = await get_round_config(session) # Diff computed before assignment so the audit entry records both sides. Without # it, the most sensitive setting in the system (fee_address — where 30 % of every # pool goes) could be changed without leaving any trace at all (B-10). changes: dict[str, dict] = {} for field in _CONFIG_FIELDS: value = getattr(body, field) if value is None: continue previous = getattr(config, field) if previous == value: continue changes[field] = {"from": previous, "to": value} setattr(config, field, value) if changes: await write_audit_log(session, "config_updated", changes) await session.commit() return _config_response(config) @router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: """Maintenance switch: the round in progress (if any) still closes, draws, and pays out its winner normally — only opening the *next* round is suppressed until /admin/resume is called (rounds/service.py).""" config = await get_round_config(session) config.paused = True await write_audit_log(session, "lottery_paused", {}) await session.commit() return _config_response(config) @router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse: config = await get_round_config(session) config.paused = False await write_audit_log(session, "lottery_resumed", {}) await session.commit() return _config_response(config) class AdminUserResponse(BaseModel): id: int username: str address: str balance_sats: int created_at: str @router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)]) async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]: users = (await session.scalars(select(User).order_by(User.id))).all() return [ AdminUserResponse( id=u.id, username=u.username, address=u.address, balance_sats=u.cached_balance_sats, created_at=isoformat_utc(u.created_at), ) for u in users ] class AdminPrivkeyResponse(BaseModel): address: str wif: str @router.get( "/users/{user_id}/privkey", response_model=AdminPrivkeyResponse, dependencies=[Depends(require_admin)] ) async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session)) -> AdminPrivkeyResponse: """Exports a user's raw private key for manual intervention (e.g. sweeping funds back if something's stuck). Every access is audit-logged since this is the most sensitive data the platform holds.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") wif = derive_user_wif(user.derivation_index) await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id) await session.commit() return AdminPrivkeyResponse(address=user.address, wif=wif) class AdminPasswordResetResponse(BaseModel): username: str new_password: str @router.post( "/users/{user_id}/reset-password", response_model=AdminPasswordResetResponse, dependencies=[Depends(require_admin)], ) async def reset_user_password( user_id: int, session: AsyncSession = Depends(get_session) ) -> AdminPasswordResetResponse: """Admin-only password reset for a user who's locked out: passwords are Argon2-hashed (one-way), so an existing password can never be recovered or displayed — this generates and sets a brand new one instead, shown once so the admin can relay it to the user. There is no user-facing self-service reset; only an admin (via /admin, token-gated) can trigger this.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") new_password = secrets.token_urlsafe(12) user.password_hash = hash_password(new_password) # B-34: this endpoint exists precisely for the "account compromised" case — # without bumping token_version, whoever was already logged in (the # attacker, if that's who prompted the reset) stayed logged in on their # existing token until it naturally expired, unaffected by the reset. user.token_version += 1 await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id) await session.commit() return AdminPasswordResetResponse(username=user.username, new_password=new_password) class AdminRoundResponse(BaseModel): id: int status: str opened_at: str closed_at: str | None draw_block_height: int | None draw_block_hash: str | None winner_user_id: int | None winner_username: str | None pool_amount_sats: int | None winner_amount_sats: int | None fee_amount_sats: int | None payout_txid: str | None @router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) async def list_rounds( session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500) ) -> list[AdminRoundResponse]: rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all() winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None} winners = {} if winner_ids: users = (await session.scalars(select(User).where(User.id.in_(winner_ids)))).all() winners = {u.id: u.username for u in users} return [ AdminRoundResponse( id=r.id, status=r.status, opened_at=isoformat_utc(r.opened_at), closed_at=isoformat_utc(r.closed_at), draw_block_height=r.draw_block_height, draw_block_hash=r.draw_block_hash, winner_user_id=r.winner_user_id, winner_username=winners.get(r.winner_user_id) if r.winner_user_id is not None else None, pool_amount_sats=r.pool_amount_sats, winner_amount_sats=r.winner_amount_sats, fee_amount_sats=r.fee_amount_sats, payout_txid=r.payout_txid, ) for r in rounds ] class AdminAuditLogResponse(BaseModel): id: int event_type: str payload: dict user_id: int | None round_id: int | None created_at: str @router.get( "/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)] ) async def list_audit_log( session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500) ) -> list[AdminAuditLogResponse]: entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all() return [ AdminAuditLogResponse( id=e.id, event_type=e.event_type, payload=json.loads(e.payload_json), user_id=e.user_id, round_id=e.round_id, created_at=isoformat_utc(e.created_at), ) for e in entries ] class AdminPendingTransactionResponse(BaseModel): id: int kind: str status: str round_id: int | None withdrawal_id: int | None user_id: int | None current_txid: str fee_rate_sat_vb: int attempt_count: int broadcast_at: str replaced_by_txid: str | None @router.get( "/pending-transactions", response_model=list[AdminPendingTransactionResponse], dependencies=[Depends(require_admin)], ) async def list_pending_transactions( session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500), status_filter: str | None = Query(default=None, alias="status"), ) -> list[AdminPendingTransactionResponse]: query = select(PendingTransaction).order_by(PendingTransaction.id.desc()) if status_filter is not None: query = query.where(PendingTransaction.status == status_filter) entries = (await session.scalars(query.limit(limit))).all() return [ AdminPendingTransactionResponse( id=p.id, kind=p.kind, status=p.status, round_id=p.round_id, withdrawal_id=p.withdrawal_id, user_id=p.user_id, current_txid=p.current_txid, fee_rate_sat_vb=p.fee_rate_sat_vb, attempt_count=p.attempt_count, broadcast_at=isoformat_utc(p.broadcast_at), replaced_by_txid=p.replaced_by_txid, ) for p in entries ] _BUG_REPORT_STATUSES = ("open", "read", "resolved") class AdminBugReportResponse(BaseModel): id: int description: str contact: str | None user_id: int | None username: str | None status: str created_at: str def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse: return AdminBugReportResponse( id=report.id, description=report.description, contact=report.contact, user_id=report.user_id, username=username, status=report.status, created_at=isoformat_utc(report.created_at), ) @router.get( "/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)] ) async def list_bug_reports( session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500) ) -> list[AdminBugReportResponse]: reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all() user_ids = {r.user_id for r in reports if r.user_id is not None} usernames = {} if user_ids: users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all() usernames = {u.id: u.username for u in users} return [ _bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None) for r in reports ] class BugReportStatusUpdate(BaseModel): status: str = Field(pattern="^(open|read|resolved)$") @router.post( "/bug-reports/{report_id}/status", response_model=AdminBugReportResponse, dependencies=[Depends(require_admin)], ) async def update_bug_report_status( report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session) ) -> AdminBugReportResponse: report = await session.get(BugReport, report_id) if report is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found") report.status = body.status await session.commit() username = None if report.user_id is not None: user = await session.get(User, report.user_id) username = user.username if user is not None else None return _bug_report_response(report, username)