2026-09-16 20:34:08 +02:00
|
|
|
"""Instance administration (SPEC §6.1).
|
|
|
|
|
|
2026-10-01 20:16:27 +02:00
|
|
|
The backend seam for the admin panel: the registration gate — the first account
|
|
|
|
|
on a fresh install becomes the administrator and closes signup behind itself,
|
|
|
|
|
and this is how that gets reopened — the default quota, and managing users
|
|
|
|
|
(search, per-user quota, block, delete). An administrator cannot block or delete
|
|
|
|
|
their own account, so the instance can never lock out its last admin that way.
|
2026-09-16 20:34:08 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import datetime as dt
|
2026-10-01 20:16:27 +02:00
|
|
|
import uuid
|
2026-09-16 20:34:08 +02:00
|
|
|
from typing import Annotated
|
|
|
|
|
|
2026-10-01 20:16:27 +02:00
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
|
from sqlalchemy import func, select
|
2026-09-16 20:34:08 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-10-01 20:16:27 +02:00
|
|
|
from app.api.errors import raise_api_error
|
2026-09-16 20:34:08 +02:00
|
|
|
from app.db import get_session
|
|
|
|
|
from app.identity.deps import CurrentAdmin, require_csrf
|
2026-10-01 21:02:44 +02:00
|
|
|
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
|
2026-10-01 20:16:27 +02:00
|
|
|
from app.identity.schemas import (
|
|
|
|
|
AdminMailIn,
|
|
|
|
|
AdminMailOut,
|
|
|
|
|
AdminMailTestIn,
|
|
|
|
|
AdminSettingsIn,
|
|
|
|
|
AdminSettingsOut,
|
|
|
|
|
AdminUserOut,
|
|
|
|
|
AdminUserPatch,
|
|
|
|
|
MessageOut,
|
|
|
|
|
)
|
2026-09-16 20:34:08 +02:00
|
|
|
from app.identity.service import app_settings
|
2026-10-01 20:16:27 +02:00
|
|
|
from app.identity.user_admin import delete_user, search_users, set_blocked
|
|
|
|
|
from app.models import Account, AppSettings, Message, User
|
|
|
|
|
from app.security.crypto import encrypt_secret
|
2026-09-16 20:34:08 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _out(row: AppSettings) -> AdminSettingsOut:
|
|
|
|
|
return AdminSettingsOut(
|
|
|
|
|
registration_open=row.registration_open,
|
|
|
|
|
default_quota_bytes=row.default_quota_bytes,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/settings", response_model=AdminSettingsOut)
|
|
|
|
|
async def read_settings(admin: CurrentAdmin, db: DbSession) -> AdminSettingsOut:
|
|
|
|
|
return _out(await app_settings(db))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/settings", response_model=AdminSettingsOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def write_settings(
|
|
|
|
|
body: AdminSettingsIn, admin: CurrentAdmin, db: DbSession
|
|
|
|
|
) -> AdminSettingsOut:
|
|
|
|
|
"""Merge the supplied fields into the settings row.
|
|
|
|
|
|
|
|
|
|
Changing ``default_quota_bytes`` affects users created afterwards only —
|
|
|
|
|
nothing here rewrites an existing ``users.quota_bytes``.
|
|
|
|
|
"""
|
|
|
|
|
row = await app_settings(db, for_update=True)
|
|
|
|
|
if body.registration_open is not None:
|
|
|
|
|
row.registration_open = body.registration_open
|
|
|
|
|
if body.default_quota_bytes is not None:
|
|
|
|
|
row.default_quota_bytes = body.default_quota_bytes
|
|
|
|
|
row.updated_at = dt.datetime.now(dt.UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
return _out(row)
|
2026-10-01 20:16:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Outgoing system mail -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mail_out(row: AppSettings) -> AdminMailOut:
|
|
|
|
|
return AdminMailOut(
|
|
|
|
|
smtp_host=row.smtp_host,
|
|
|
|
|
smtp_port=row.smtp_port,
|
|
|
|
|
smtp_security=row.smtp_security,
|
|
|
|
|
smtp_username=row.smtp_username,
|
|
|
|
|
has_password=row.smtp_secret_ct is not None,
|
|
|
|
|
smtp_from=row.smtp_from,
|
|
|
|
|
public_url=row.public_url,
|
|
|
|
|
configured=bool(row.smtp_host and row.smtp_from and row.public_url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/mail", response_model=AdminMailOut)
|
|
|
|
|
async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
|
|
|
|
return _mail_out(await app_settings(db))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
|
|
|
|
"""Merge the supplied SMTP fields. The password is stored vault-encrypted."""
|
|
|
|
|
row = await app_settings(db, for_update=True)
|
|
|
|
|
for name in ("smtp_host", "smtp_username", "smtp_from"):
|
|
|
|
|
value = getattr(body, name)
|
|
|
|
|
if value is not None:
|
|
|
|
|
setattr(row, name, value.strip())
|
|
|
|
|
if body.smtp_port is not None:
|
|
|
|
|
row.smtp_port = body.smtp_port
|
|
|
|
|
if body.smtp_security is not None:
|
|
|
|
|
row.smtp_security = body.smtp_security
|
|
|
|
|
if body.smtp_password is not None:
|
|
|
|
|
row.smtp_secret_ct = (
|
|
|
|
|
encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None
|
|
|
|
|
)
|
|
|
|
|
if body.public_url is not None:
|
|
|
|
|
url = body.public_url.strip().rstrip("/")
|
|
|
|
|
if url and not url.startswith(("http://", "https://")):
|
|
|
|
|
raise_api_error(400, "invalid_url", "the address must start with http:// or https://")
|
|
|
|
|
row.public_url = url
|
|
|
|
|
row.updated_at = dt.datetime.now(dt.UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
return _mail_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
|
|
|
|
"""Send a test message with the saved settings, reporting the server's error."""
|
|
|
|
|
cfg = mailer_config(await app_settings(db))
|
|
|
|
|
if cfg is None:
|
|
|
|
|
raise_api_error(
|
|
|
|
|
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
|
|
|
|
)
|
|
|
|
|
text = (
|
|
|
|
|
"This is a test message from Ultimo Email.\n\n"
|
|
|
|
|
"Outgoing system mail works: password-reset links will be sent from this account.\n"
|
|
|
|
|
)
|
2026-10-01 21:02:44 +02:00
|
|
|
# Same logo header as the reset mail, so the test shows what users will get.
|
|
|
|
|
html = LOGO_HTML + f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
|
2026-10-01 20:16:27 +02:00
|
|
|
try:
|
|
|
|
|
await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
|
|
|
|
|
except MailerError as exc:
|
|
|
|
|
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
|
|
|
|
return MessageOut()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Users --------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
|
|
|
|
|
return AdminUserOut(
|
|
|
|
|
id=user.id,
|
|
|
|
|
handle=user.handle,
|
|
|
|
|
is_admin=user.is_admin,
|
|
|
|
|
blocked_at=user.blocked_at.isoformat() if user.blocked_at else None,
|
|
|
|
|
quota_bytes=user.quota_bytes,
|
|
|
|
|
used_bytes=used_bytes,
|
|
|
|
|
created_at=user.created_at.isoformat(),
|
|
|
|
|
accounts=accounts,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User:
|
|
|
|
|
"""The user an admin action is aimed at; never the admin themselves."""
|
|
|
|
|
user = await db.scalar(select(User).where(User.id == user_id))
|
|
|
|
|
if user is None:
|
|
|
|
|
raise_api_error(404, "not_found", "user not found")
|
|
|
|
|
if user.id == admin.id:
|
|
|
|
|
raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/users", response_model=list[AdminUserOut])
|
|
|
|
|
async def list_users(
|
|
|
|
|
admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200)
|
|
|
|
|
) -> list[AdminUserOut]:
|
|
|
|
|
"""Search users by login, or by a mail account's address or display name."""
|
|
|
|
|
return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def patch_user(
|
|
|
|
|
user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
|
|
|
|
|
) -> AdminUserOut:
|
|
|
|
|
"""Change a user's quota and/or block or unblock them."""
|
|
|
|
|
user = await _target(db, admin, user_id)
|
|
|
|
|
if body.quota_bytes is not None:
|
|
|
|
|
user.quota_bytes = body.quota_bytes
|
|
|
|
|
if body.blocked is not None:
|
|
|
|
|
await set_blocked(db, user, body.blocked)
|
|
|
|
|
await db.commit()
|
|
|
|
|
used = await db.scalar(
|
|
|
|
|
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
|
|
|
|
|
)
|
|
|
|
|
emails = list(
|
|
|
|
|
await db.scalars(
|
|
|
|
|
select(Account.email).where(Account.user_id == user.id).order_by(Account.email)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return _user_out(user, int(used or 0), emails)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)])
|
|
|
|
|
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
|
|
|
|
|
"""Delete a user and all of their data. Cannot be undone."""
|
|
|
|
|
user = await _target(db, admin, user_id)
|
|
|
|
|
await delete_user(db, user)
|
|
|
|
|
return {"status": "deleted"}
|