This commit is contained in:
Sergey Antropoff
2026-07-17 15:57:36 +03:00
commit e4240a7be5
72 changed files with 6227 additions and 0 deletions
View File
+428
View File
@@ -0,0 +1,428 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import get_db
from app.models import CaptchaProvider, DEFAULT_MIME_ALLOWLIST, PasswordMode
from app.schemas import AdminSettingsUpdate
from app.services.admin_auth import (
AdminAuth,
AdminWebAuth,
clear_admin_cookie,
get_admin_user,
set_admin_cookie,
)
from app.services.audit import (
count_audit,
list_audit,
list_audit_event_types,
purge_old_audit,
write_audit,
)
from app.services.rate_limit import rate_limiter
from app.services.settings_service import PASSWORD_MODE_HELP, get_or_create_settings
from app.services.wraps import cleanup_expired_meta, expire_due_wraps, purge_all_wraps, request_meta
router = APIRouter(prefix="/admin", include_in_schema=False)
api_router = APIRouter(prefix="/admin/api", tags=["Admin"])
templates = Jinja2Templates(directory="app/templates")
@router.get("")
async def admin_root(request: Request):
if get_admin_user(request):
return RedirectResponse("/admin/settings", status_code=302)
return RedirectResponse("/admin/login", status_code=302)
@router.get("/login")
async def login_page(request: Request):
if get_admin_user(request):
return RedirectResponse("/admin/settings", status_code=302)
return templates.TemplateResponse(
request,
"admin_login.html",
{"title": "Admin login"},
)
@router.post("/login")
async def login_submit(request: Request, db: AsyncSession = Depends(get_db)):
settings = get_settings()
app_settings = await get_or_create_settings(db)
meta = request_meta(request)
ip = meta["ip"] or "unknown"
if not rate_limiter.allow(
f"admin_login:{ip}", app_settings.rate_limit_admin_login_per_minute
):
await write_audit(
db,
event_type="admin.login",
success=False,
**meta,
details={"reason": "rate_limited"},
)
return templates.TemplateResponse(
request,
"admin_login.html",
{"title": "Admin login", "error": "Too many attempts. Try later.", "error_key": "admin.login.error.rate"},
status_code=429,
)
form = await request.form()
username = str(form.get("username") or "")
password = str(form.get("password") or "")
if username == settings.admin_username and password == settings.admin_password:
purged_audit = await purge_old_audit(db, app_settings.audit_retention_days)
expired = await expire_due_wraps(db)
cleaned = await cleanup_expired_meta(db)
await write_audit(
db,
event_type="admin.login",
success=True,
**meta,
details={
"purged_audit": purged_audit,
"expired_wraps": expired,
"cleaned_meta": cleaned,
},
)
response = RedirectResponse("/admin/settings", status_code=302)
set_admin_cookie(response, username)
return response
await write_audit(
db,
event_type="admin.login",
success=False,
**meta,
details={"reason": "bad_credentials"},
)
return templates.TemplateResponse(
request,
"admin_login.html",
{"title": "Admin login", "error": "Invalid credentials", "error_key": "admin.login.error.bad"},
status_code=401,
)
@router.post("/logout")
async def logout(request: Request, db: AsyncSession = Depends(get_db)):
meta = request_meta(request)
if get_admin_user(request):
await write_audit(db, event_type="admin.logout", success=True, **meta)
response = RedirectResponse("/admin/login", status_code=302)
clear_admin_cookie(response)
return response
@router.get("/settings")
async def settings_page(
request: Request,
db: AsyncSession = Depends(get_db),
_admin: AdminWebAuth = ...,
):
row = await get_or_create_settings(db)
env = get_settings()
return templates.TemplateResponse(
request,
"admin_settings.html",
{
"title": "Settings",
"settings": row,
"password_mode_help": PASSWORD_MODE_HELP,
"mime_examples": DEFAULT_MIME_ALLOWLIST,
"turnstile_secret_set": bool(env.turnstile_secret_key),
"hcaptcha_secret_set": bool(env.hcaptcha_secret_key),
"captcha_providers": list(CaptchaProvider),
"password_modes": list(PasswordMode),
"active": "settings",
},
)
@router.post("/settings")
async def settings_save(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminWebAuth = ...,
):
row = await get_or_create_settings(db)
form = await request.form()
meta = request_meta(request)
def as_int(name: str, default: int) -> int:
raw = form.get(name)
try:
return int(str(raw))
except Exception:
return default
old = {
"max_upload_bytes": row.max_upload_bytes,
"default_ttl_seconds": row.default_ttl_seconds,
"max_ttl_seconds": row.max_ttl_seconds,
"captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value,
}
row.max_upload_bytes = max(1, as_int("max_upload_mb", max(1, row.max_upload_bytes // 1048576))) * 1048576
row.default_ttl_seconds = max(1, as_int("default_ttl_hours", max(1, row.default_ttl_seconds // 3600))) * 3600
row.max_ttl_seconds = max(1, as_int("max_ttl_hours", max(1, row.max_ttl_seconds // 3600))) * 3600
if row.default_ttl_seconds > row.max_ttl_seconds:
row.default_ttl_seconds = row.max_ttl_seconds
row.rate_limit_create_per_minute = as_int(
"rate_limit_create_per_minute", row.rate_limit_create_per_minute
)
row.rate_limit_unwrap_per_minute = as_int(
"rate_limit_unwrap_per_minute", row.rate_limit_unwrap_per_minute
)
row.rate_limit_admin_login_per_minute = as_int(
"rate_limit_admin_login_per_minute", row.rate_limit_admin_login_per_minute
)
row.audit_retention_days = as_int("audit_retention_days", row.audit_retention_days)
row.turnstile_site_key = str(form.get("turnstile_site_key") or "").strip()
row.hcaptcha_site_key = str(form.get("hcaptcha_site_key") or "").strip()
mime_raw = str(form.get("mime_allowlist") or "")
row.mime_allowlist = [line.strip() for line in mime_raw.splitlines() if line.strip()]
captcha = str(form.get("captcha_provider") or "off")
try:
row.captcha_provider = CaptchaProvider(captcha)
except ValueError:
pass
pwd_mode = str(form.get("password_mode") or "client_only")
try:
row.password_mode = PasswordMode(pwd_mode)
except ValueError:
pass
await db.commit()
await write_audit(
db,
event_type="admin.settings_update",
success=True,
**meta,
details={"admin": admin, "old": old},
)
return RedirectResponse("/admin/settings?saved=1", status_code=302)
@router.post("/purge")
async def purge_all(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminWebAuth = ...,
):
meta = request_meta(request)
form = await request.form()
confirm = str(form.get("confirm") or "").strip().upper()
if confirm != "PURGE":
await write_audit(
db,
event_type="admin.purge",
success=False,
**meta,
details={"admin": admin, "reason": "confirm_mismatch"},
)
return RedirectResponse("/admin/danger?purge=confirm", status_code=302)
try:
stats = await purge_all_wraps(db)
await write_audit(
db,
event_type="admin.purge",
success=True,
**meta,
details={"admin": admin, **stats},
)
return RedirectResponse(
f"/admin/danger?purged=1&wraps={stats['wraps_deleted']}&objects={stats['objects_deleted']}",
status_code=302,
)
except Exception as exc:
await write_audit(
db,
event_type="admin.purge",
success=False,
**meta,
details={"admin": admin, "reason": "error", "error": str(exc)[:200]},
)
return RedirectResponse("/admin/danger?purge=error", status_code=302)
@router.get("/danger")
async def danger_page(
request: Request,
_admin: AdminWebAuth = ...,
):
return templates.TemplateResponse(
request,
"admin_danger.html",
{
"title": "Danger zone",
"active": "danger",
},
)
@router.get("/audit")
async def audit_page(
request: Request,
db: AsyncSession = Depends(get_db),
_admin: AdminWebAuth = ...,
):
event_type = request.query_params.get("event_type") or None
wrap_id = request.query_params.get("wrap_id") or None
if event_type == "":
event_type = None
per_page = 50
try:
page = max(1, int(request.query_params.get("page") or "1"))
except ValueError:
page = 1
total = await count_audit(db, event_type=event_type, wrap_id=wrap_id or None)
pages = max(1, (total + per_page - 1) // per_page) if total else 1
if page > pages:
page = pages
offset = (page - 1) * per_page
events = await list_audit(
db,
limit=per_page,
offset=offset,
event_type=event_type,
wrap_id=wrap_id or None,
)
event_types = await list_audit_event_types(db)
from_idx = offset + 1 if total else 0
to_idx = offset + len(events)
return templates.TemplateResponse(
request,
"admin_audit.html",
{
"title": "Audit",
"events": events,
"event_type": event_type or "",
"wrap_id": wrap_id or "",
"event_types": event_types,
"active": "audit",
"page": page,
"pages": pages,
"per_page": per_page,
"total": total,
"from_idx": from_idx,
"to_idx": to_idx,
"has_prev": page > 1,
"has_next": page < pages,
},
)
@api_router.get("/settings", summary="Get admin settings")
async def admin_settings_api(
db: AsyncSession = Depends(get_db),
_admin: AdminAuth = ...,
):
row = await get_or_create_settings(db)
return JSONResponse(
{
"max_upload_bytes": row.max_upload_bytes,
"default_ttl_seconds": row.default_ttl_seconds,
"max_ttl_seconds": row.max_ttl_seconds,
"mime_allowlist": row.mime_allowlist,
"captcha_provider": row.captcha_provider.value,
"password_mode": row.password_mode.value,
"audit_retention_days": row.audit_retention_days,
"rate_limit_create_per_minute": row.rate_limit_create_per_minute,
"rate_limit_unwrap_per_minute": row.rate_limit_unwrap_per_minute,
"rate_limit_admin_login_per_minute": row.rate_limit_admin_login_per_minute,
}
)
@api_router.put("/settings", summary="Update admin settings")
async def admin_settings_put(
body: AdminSettingsUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminAuth = ...,
):
row = await get_or_create_settings(db)
data = body.model_dump(exclude_unset=True)
if "captcha_provider" in data and data["captcha_provider"] is not None:
row.captcha_provider = CaptchaProvider(data.pop("captcha_provider"))
if "password_mode" in data and data["password_mode"] is not None:
row.password_mode = PasswordMode(data.pop("password_mode"))
for key, value in data.items():
if value is not None and hasattr(row, key):
setattr(row, key, value)
await db.commit()
meta = request_meta(request)
await write_audit(
db,
event_type="admin.settings_update",
success=True,
**meta,
details={"admin": admin, "via": "api"},
)
return {"ok": True}
@api_router.post("/purge", summary="Force-delete all wraps and MinIO objects")
async def admin_purge_api(
request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminAuth = ...,
confirm: str = "PURGE",
):
meta = request_meta(request)
if confirm.strip().upper() != "PURGE":
raise HTTPException(status_code=400, detail="confirm must be PURGE")
stats = await purge_all_wraps(db)
await write_audit(
db,
event_type="admin.purge",
success=True,
**meta,
details={"admin": admin, "via": "api", **stats},
)
return {"ok": True, **stats}
@api_router.get("/audit", summary="List audit events")
async def admin_audit_api(
db: AsyncSession = Depends(get_db),
_admin: AdminAuth = ...,
event_type: str | None = None,
wrap_id: str | None = None,
limit: int = 100,
offset: int = 0,
):
events = await list_audit(
db,
limit=min(limit, 500),
offset=max(0, offset),
event_type=event_type,
wrap_id=wrap_id,
)
return [
{
"id": str(e.id),
"created_at": e.created_at.isoformat() if e.created_at else None,
"event_type": e.event_type,
"wrap_id": e.wrap_id,
"success": e.success,
"ip": e.ip,
"user_agent": e.user_agent,
"details": e.details,
}
for e in events
]
+331
View File
@@ -0,0 +1,331 @@
from __future__ import annotations
import base64
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models import PasswordMode, Wrap, WrapStatus
from app.schemas import (
PublicSettingsOut,
UnwrapRequest,
UnwrapResponse,
WrapCreateRequest,
WrapCreateResponse,
)
from app.services.audit import write_audit
from app.services.captcha import verify_captcha
from app.services.password_hash import hash_password, verify_password
from app.services.rate_limit import rate_limiter
from app.services.settings_service import get_or_create_settings, mime_allowed, public_settings_payload
from app.services.storage import storage
from app.services.wraps import delete_wrap_object, expire_due_wraps, new_wrap_id, request_meta
router = APIRouter(prefix="/api/v1")
@router.get(
"/settings",
response_model=PublicSettingsOut,
tags=["Public"],
summary="Public client settings",
)
async def public_settings(db: AsyncSession = Depends(get_db)) -> PublicSettingsOut:
row = await get_or_create_settings(db)
return PublicSettingsOut(**public_settings_payload(row))
@router.post(
"/wraps",
response_model=WrapCreateResponse,
tags=["Wraps"],
summary="Create encrypted wrap",
)
async def create_wrap(
body: WrapCreateRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> WrapCreateResponse:
meta = request_meta(request)
settings = await get_or_create_settings(db)
ip = meta["ip"] or "unknown"
if not rate_limiter.allow(
f"create:{ip}", settings.rate_limit_create_per_minute
):
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": "rate_limited"},
)
raise HTTPException(status_code=429, detail="rate_limited")
ok, reason = await verify_captcha(
settings.captcha_provider,
body.captcha_token,
ip,
turnstile_site_configured=bool(settings.turnstile_site_key),
hcaptcha_site_configured=bool(settings.hcaptcha_site_key),
)
if not ok:
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": f"captcha_{reason}"},
)
raise HTTPException(status_code=400, detail="captcha_failed")
if body.ttl_seconds > settings.max_ttl_seconds:
raise HTTPException(status_code=400, detail="ttl_too_large")
try:
ciphertext = base64.b64decode(body.ciphertext_b64, validate=True)
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid_ciphertext") from exc
if len(ciphertext) > settings.max_upload_bytes:
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": "too_large", "size": len(ciphertext)},
)
raise HTTPException(status_code=413, detail="too_large")
allowlist = list(settings.mime_allowlist or [])
for ct in body.content_types:
if not mime_allowed(allowlist, ct):
await write_audit(
db,
event_type="wrap.create",
success=False,
**meta,
details={"reason": "mime_denied", "content_type": ct},
)
raise HTTPException(status_code=400, detail="mime_not_allowed")
password_hash = None
if body.has_password:
if settings.password_mode == PasswordMode.server_gate:
if not body.password:
raise HTTPException(status_code=400, detail="password_required")
password_hash = hash_password(body.password)
wrap_id = new_wrap_id()
object_key = f"wraps/{wrap_id}.bin"
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=body.ttl_seconds)
await storage.put_bytes(object_key, ciphertext)
wrap = Wrap(
id=wrap_id,
status=WrapStatus.pending,
object_key=object_key,
size_bytes=len(ciphertext),
content_types=body.content_types,
item_count=body.item_count,
has_password=body.has_password,
password_hash=password_hash,
password_mode=settings.password_mode,
expires_at=expires_at,
creator_ip=ip,
creator_ua=(meta["user_agent"] or "")[:512] or None,
)
db.add(wrap)
await db.commit()
await write_audit(
db,
event_type="wrap.create",
success=True,
wrap_id=wrap_id,
**meta,
details={
"size_bytes": len(ciphertext),
"item_count": body.item_count,
"content_types": body.content_types,
"ttl_seconds": body.ttl_seconds,
"has_password": body.has_password,
"password_mode": settings.password_mode.value,
},
)
return WrapCreateResponse(
wrap_id=wrap_id,
expires_at=expires_at,
password_mode=settings.password_mode.value,
share_path=f"/w/{wrap_id}",
)
@router.post(
"/wraps/{wrap_id}/unwrap",
response_model=UnwrapResponse,
tags=["Wraps"],
summary="One-time unwrap",
)
async def unwrap(
wrap_id: str,
body: UnwrapRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> UnwrapResponse:
meta = request_meta(request)
settings = await get_or_create_settings(db)
ip = meta["ip"] or "unknown"
if not rate_limiter.allow(
f"unwrap:{ip}", settings.rate_limit_unwrap_per_minute
):
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "rate_limited"},
)
raise HTTPException(status_code=429, detail="rate_limited")
ok, reason = await verify_captcha(
settings.captcha_provider,
body.captcha_token,
ip,
turnstile_site_configured=bool(settings.turnstile_site_key),
hcaptcha_site_configured=bool(settings.hcaptcha_site_key),
)
if not ok:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": f"captcha_{reason}"},
)
raise HTTPException(status_code=400, detail="captcha_failed")
await expire_due_wraps(db)
from sqlalchemy import select
result = await db.execute(select(Wrap).where(Wrap.id == wrap_id))
wrap = result.scalar_one_or_none()
# Uniform-ish failure for enumeration resistance
def fail(reason: str, status: int = 410) -> None:
raise HTTPException(status_code=status, detail=reason)
if not wrap or wrap.status != WrapStatus.pending:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "unavailable"},
)
fail("unavailable")
assert wrap is not None
if wrap.expires_at <= datetime.now(timezone.utc):
wrap.status = WrapStatus.expired
await delete_wrap_object(db, wrap)
await db.commit()
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "expired"},
)
fail("unavailable")
if (
wrap.has_password
and wrap.password_mode == PasswordMode.server_gate
and wrap.password_hash
):
if not body.password or not verify_password(wrap.password_hash, body.password):
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "bad_password"},
)
raise HTTPException(status_code=403, detail="bad_password")
# Atomic consume
from sqlalchemy import update
now = datetime.now(timezone.utc)
upd = await db.execute(
update(Wrap)
.where(
Wrap.id == wrap_id,
Wrap.status == WrapStatus.pending,
Wrap.expires_at > now,
)
.values(status=WrapStatus.consumed, consumed_at=now)
.returning(Wrap.id)
)
consumed = upd.scalar_one_or_none()
await db.commit()
if not consumed:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "unavailable"},
)
fail("unavailable")
try:
data = await storage.get_bytes(wrap.object_key)
except Exception:
await write_audit(
db,
event_type="wrap.unwrap",
success=False,
wrap_id=wrap_id,
**meta,
details={"reason": "storage_error"},
)
raise HTTPException(status_code=500, detail="storage_error") from None
finally:
await delete_wrap_object(db, wrap)
await write_audit(
db,
event_type="wrap.unwrap",
success=True,
wrap_id=wrap_id,
**meta,
details={
"size_bytes": wrap.size_bytes,
"item_count": wrap.item_count,
"content_types": wrap.content_types,
},
)
return UnwrapResponse(
ciphertext_b64=base64.b64encode(data).decode("ascii"),
content_types=list(wrap.content_types or []),
item_count=wrap.item_count,
has_password=wrap.has_password,
password_mode=wrap.password_mode.value,
size_bytes=wrap.size_bytes,
)