- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the container healthcheck; the scheduler beats on every tick and draw-wait iteration - GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation) - GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters - registration writes user_registered in its transaction - the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access log shows the client's IP Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
588 lines
23 KiB
Python
588 lines
23 KiB
Python
import json
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query, Request, status
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.errors import http_error
|
|
from app.api.timeutil import isoformat_utc
|
|
from app.audit.log import write_audit_log
|
|
from app.auth.security import hash_password_async
|
|
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.rounds.pause import pause_by_operator
|
|
from app.rounds.service import get_active_round, payout_config_problem
|
|
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 http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "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 http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "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",
|
|
"deposit_confirmations", # B-82 (DAT-62)
|
|
"draw_confirmations", # B-73 (DAT-62)
|
|
"max_round_pool_sats", # B-95 (DAT-62)
|
|
)
|
|
|
|
|
|
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
|
|
deposit_confirmations: int # B-82 (DAT-62)
|
|
draw_confirmations: int # B-73 (DAT-62)
|
|
max_round_pool_sats: int # B-95 (DAT-62)
|
|
paused: bool
|
|
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
|
|
|
|
|
|
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)
|
|
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
|
|
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
|
|
max_round_pool_sats: int | None = Field(default=None, ge=0, le=4_000_000_000_000_000) # B-95 (DAT-62)
|
|
|
|
@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
|
|
fields["paused_reason"] = config.paused_reason
|
|
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)
|
|
|
|
# B-79 (RND-40): a bet amount or fee rate under which a round could not be paid out is
|
|
# refused before anything is written — the payout reads fee_rate_sat_vb at build time, so
|
|
# a rate raised mid-round could otherwise make the round in progress unpayable.
|
|
if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes:
|
|
amounts = {config.bet_amount_sats}
|
|
if "fee_rate_sat_vb" in changes:
|
|
# B-95: the round in progress is paid with the bet amount it opened with, which may
|
|
# differ from the one configured now — a higher fee rate has to leave *it* payable too.
|
|
active = await get_active_round(session)
|
|
if active is not None:
|
|
amounts.add(active.bet_amount_sats)
|
|
problem = next(
|
|
(p for p in (payout_config_problem(a, config.fee_rate_sat_vb) for a in sorted(amounts)) if p), None
|
|
)
|
|
if problem is not None:
|
|
await session.rollback()
|
|
raise http_error(
|
|
422,
|
|
"config_not_payable",
|
|
"this bet amount and fee rate would make a round impossible to pay out "
|
|
f"({problem}); a larger bet amount or a lower fee rate is needed",
|
|
reason=problem,
|
|
)
|
|
|
|
# B-95 (DAT-62): a cap below the price of one bet would refuse every bet of every round.
|
|
if (
|
|
("max_round_pool_sats" in changes or "bet_amount_sats" in changes)
|
|
and config.max_round_pool_sats
|
|
and config.max_round_pool_sats < config.bet_amount_sats
|
|
):
|
|
offending = {"max_round_pool_sats": config.max_round_pool_sats, "bet_amount_sats": config.bet_amount_sats}
|
|
await session.rollback()
|
|
raise http_error(
|
|
422,
|
|
"max_round_pool_below_bet_amount",
|
|
"max_round_pool_sats must be 0 (no cap) or at least the bet amount",
|
|
**offending,
|
|
)
|
|
|
|
if changes:
|
|
await write_audit_log(session, "config_updated", changes)
|
|
await session.commit()
|
|
return _config_response(config)
|
|
|
|
|
|
class PauseRequest(BaseModel):
|
|
note: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
|
async def pause_lottery(
|
|
body: PauseRequest | None = None, 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).
|
|
|
|
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
|
|
already in force stays — it is the one that needs a look. The audit row always
|
|
says `operator` for this call."""
|
|
config = await pause_by_operator(session, body.note if body is not None else None)
|
|
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)
|
|
# B-78 (AUD-12): the reason the resume took away, so the log says what the operator
|
|
# agreed to resume (None when the lottery was not paused).
|
|
lifted = config.paused_reason if config.paused else None
|
|
config.paused = False
|
|
config.paused_reason = None
|
|
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
|
|
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
|
|
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
|
|
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
|
|
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(
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
after_id: int | None = Query(default=None, ge=0),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> list[AdminUserResponse]:
|
|
"""B-94 (API-33): by ascending id, a page at a time; the next page asks for `after_id` equal
|
|
to the last id received, and a list shorter than `limit` is the last one."""
|
|
query = select(User).order_by(User.id).limit(limit)
|
|
if after_id is not None:
|
|
query = query.where(User.id > after_id)
|
|
users = (await session.scalars(query)).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 http_error(status.HTTP_404_NOT_FOUND, "user_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 http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
|
|
|
|
new_password = secrets.token_urlsafe(12)
|
|
user.password_hash = await hash_password_async(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
|
|
pool_address: str | None # B-80
|
|
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
|
|
draw_height: int | None # B-73
|
|
bump_reserve_sats: int | None # B-79 (API-36)
|
|
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
|
|
|
|
|
|
@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,
|
|
pool_address=r.pool_address or None,
|
|
server_seed_hash=r.server_seed_hash,
|
|
draw_height=r.draw_height,
|
|
bump_reserve_sats=r.bump_reserve_sats,
|
|
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
|
|
)
|
|
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
|
|
|
|
|
|
def _parse_instant(name: str, value: str | None) -> datetime | None:
|
|
"""An ISO 8601 instant with an offset, as UTC; anything else is a 422 (API-37)."""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
parsed = None
|
|
if parsed is None or parsed.tzinfo is None:
|
|
raise http_error(
|
|
status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
"invalid_request",
|
|
"invalid request",
|
|
errors=[{"loc": ["query", name], "msg": "an ISO 8601 instant with an offset is required", "type": "value_error"}],
|
|
)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
@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),
|
|
before_id: int | None = Query(default=None),
|
|
event_type: list[str] | None = Query(default=None),
|
|
round_id: int | None = Query(default=None),
|
|
user_id: int | None = Query(default=None),
|
|
from_: str | None = Query(default=None, alias="from"),
|
|
to: str | None = Query(default=None),
|
|
) -> list[AdminAuditLogResponse]:
|
|
"""B-92 (API-37): newest first, a page at a time, every filter optional and combined with
|
|
"and". The next page asks for `before_id` equal to the last id received."""
|
|
query = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit)
|
|
if before_id is not None:
|
|
query = query.where(AuditLog.id < before_id)
|
|
if event_type:
|
|
query = query.where(AuditLog.event_type.in_(event_type))
|
|
if round_id is not None:
|
|
query = query.where(AuditLog.round_id == round_id)
|
|
if user_id is not None:
|
|
query = query.where(AuditLog.user_id == user_id)
|
|
start, end = _parse_instant("from", from_), _parse_instant("to", to)
|
|
if start is not None:
|
|
query = query.where(AuditLog.created_at >= start)
|
|
if end is not None:
|
|
query = query.where(AuditLog.created_at < end)
|
|
entries = (await session.scalars(query)).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 http_error(status.HTTP_404_NOT_FOUND, "bug_report_not_found", "bug report not found")
|
|
|
|
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
|
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
|
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
|
|
# only accountability there is. Before/after like config_updated, and nothing
|
|
# written when the status doesn't actually change — re-clicking the status a
|
|
# report already has isn't an event.
|
|
if report.status != body.status:
|
|
await write_audit_log(
|
|
session,
|
|
"bug_report_status_changed",
|
|
{"report_id": report_id, "from": report.status, "to": body.status},
|
|
user_id=report.user_id,
|
|
)
|
|
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)
|
|
|
|
|
|
class AdminElectrumResponse(BaseModel):
|
|
connected: bool
|
|
endpoint: str | None
|
|
tip_height: int
|
|
last_header_age_seconds: int | None
|
|
fallback_count: int
|
|
quorum_enforced: bool
|
|
last_rotation: dict | None
|
|
|
|
|
|
@router.get("/electrum", response_model=AdminElectrumResponse, dependencies=[Depends(require_admin)])
|
|
async def electrum_status(request: Request) -> AdminElectrumResponse:
|
|
"""B-92 (API-47, ELC-29): the connection to the network, for the panel's indicator."""
|
|
listener = request.app.state.electrum_listener
|
|
connected = listener.client is not None
|
|
return AdminElectrumResponse(
|
|
connected=connected,
|
|
endpoint=str(listener.current_endpoint) if connected and listener.current_endpoint else None,
|
|
tip_height=listener.tip_height,
|
|
last_header_age_seconds=listener.last_header_age_seconds(),
|
|
fallback_count=listener.fallback_count,
|
|
quorum_enforced=listener.quorum_enforced,
|
|
last_rotation=listener.last_rotation,
|
|
)
|