import json import secrets from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config from app.wallet.hd import derive_user_wif router = APIRouter(prefix="/admin", tags=["admin"]) async def require_admin(x_admin_token: str = Header(default="")) -> None: if not settings.admin_token or x_admin_token != settings.admin_token: raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") _CONFIG_FIELDS = ( "fee_address", "bet_amount_sats", "round_duration_seconds", "round_cooldown_seconds", "min_amount_sats", "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", "paused", ) class RoundConfigResponse(BaseModel): fee_address: str bet_amount_sats: int round_duration_seconds: int round_cooldown_seconds: int min_amount_sats: int fee_rate_sat_vb: int rbf_timeout_seconds: int draw_animation_seconds: int paused: bool class RoundConfigUpdate(BaseModel): fee_address: str | None = None bet_amount_sats: int | None = None round_duration_seconds: int | None = None round_cooldown_seconds: int | None = None min_amount_sats: int | None = None fee_rate_sat_vb: int | None = None rbf_timeout_seconds: int | None = None draw_animation_seconds: int | None = None paused: bool | None = None def _config_response(config) -> RoundConfigResponse: return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_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) for field in _CONFIG_FIELDS: value = getattr(body, field) if value is not None: setattr(config, field, value) 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=u.created_at.isoformat(), ) 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) 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 = 50) -> 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=r.opened_at.isoformat(), closed_at=r.closed_at.isoformat() if r.closed_at else None, 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 = 200 ) -> 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=e.created_at.isoformat(), ) 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), ) -> list[AdminPendingTransactionResponse]: entries = ( await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc())) ).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=p.broadcast_at.isoformat(), replaced_by_txid=p.replaced_by_txid, ) for p in entries ]