"""Instance administration (SPEC ยง6.1). 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. """ from __future__ import annotations import base64 import datetime as dt import re import uuid from typing import Annotated from fastapi import APIRouter, Depends, Path, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app import translation from app.api.errors import raise_api_error from app.config import get_settings from app.db import get_session from app.hosted.admin import MailServerError from app.identity import invitations from app.identity.deps import CurrentAdmin, require_csrf from app.identity.mailer import ( LOGO_CID, LOGO_DATA_URL, LOGO_HTML, MailerError, mailer_config, send_mail, ) from app.identity.schemas import ( AdminInvitePreviewIn, AdminInvitePreviewOut, AdminInviteTemplateIn, AdminInviteTemplateOut, AdminMailIn, AdminMailOut, AdminMailTestIn, AdminSettingsIn, AdminSettingsOut, AdminUserOut, AdminUserPatch, MessageOut, ) from app.identity.service import app_settings 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 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, invitations_enabled=row.invitations_enabled, ) @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 if body.invitations_enabled is not None: row.invitations_enabled = body.invitations_enabled row.updated_at = dt.datetime.now(dt.UTC) await db.commit() return _out(row) # --- 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" ) # Same logo header as the reset mail, so the test shows what users will get. html = LOGO_HTML + f"
{text.replace(chr(10) * 2, '
').strip()}
" 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() # --- Invitation email ------------------------------------------------------------ _EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+") _SAMPLE = {"invitee_name": "Anna Nowak", "inviter_name": "Jan Kowalski"} def _template_out(row: AppSettings) -> AdminInviteTemplateOut: return AdminInviteTemplateOut( subject=row.invite_subject, html=row.invite_template, from_address=row.invite_from, default_subject=invitations.DEFAULT_SUBJECT, default_html=invitations.DEFAULT_TEMPLATE, default_from=row.smtp_from, placeholders=list(invitations.PLACEHOLDERS), ) @router.get("/invitations/template", response_model=AdminInviteTemplateOut) async def read_invite_template(admin: CurrentAdmin, db: DbSession) -> AdminInviteTemplateOut: return _template_out(await app_settings(db)) @router.put( "/invitations/template", response_model=AdminInviteTemplateOut, dependencies=[Depends(require_csrf)], ) async def write_invite_template( body: AdminInviteTemplateIn, admin: CurrentAdmin, db: DbSession ) -> AdminInviteTemplateOut: """Upload the invitation email's HTML template, subject and sender.""" row = await app_settings(db, for_update=True) if body.html is not None: html = body.html.strip() if html and (problem := invitations.template_problem(html)): raise_api_error(400, "invalid_template", problem) row.invite_template = html if body.subject is not None: row.invite_subject = " ".join(body.subject.split()) if body.from_address is not None: sender = body.from_address.strip() if sender and not _EMAIL_RE.fullmatch(sender): raise_api_error(400, "invalid_email", "the sender must be an email address") row.invite_from = sender row.updated_at = dt.datetime.now(dt.UTC) await db.commit() return _template_out(row) def _sample(row: AppSettings, subject: str, html: str) -> invitations.RenderedMail: """The invitation as a friend would get it, with sample names.""" if html.strip() and (problem := invitations.template_problem(html)): raise_api_error(400, "invalid_template", problem) draft = AppSettings(invite_subject=subject, invite_template=html.strip()) base = row.public_url.rstrip("/") or f"https://{get_settings().app_domain}" return invitations.render(draft, link=f"{base}/register#invite=sample", **_SAMPLE) @router.post( "/invitations/preview", response_model=AdminInvitePreviewOut, dependencies=[Depends(require_csrf)], ) async def preview_invite( body: AdminInvitePreviewIn, admin: CurrentAdmin, db: DbSession ) -> AdminInvitePreviewOut: """Render a template (saved or not) with sample names, for the panel to show.""" mail = _sample(await app_settings(db), body.subject, body.html) html = mail.html # The preview frame has no message parts: show the images it would carry. for part in mail.inline: data = base64.b64encode(part.data).decode() html = html.replace(f"cid:{part.cid}", f"data:{part.mime_type};base64,{data}") html = html.replace(f"cid:{LOGO_CID}", LOGO_DATA_URL) return AdminInvitePreviewOut(subject=mail.subject, html=html) @router.post("/invitations/test", response_model=MessageOut, dependencies=[Depends(require_csrf)]) async def test_invite(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut: """Send the saved invitation email, with sample names and a dummy link.""" row = await app_settings(db) cfg = mailer_config(row) if cfg is None: raise_api_error( 409, "mail_not_configured", "set the SMTP server, From address and public address first" ) mail = _sample(row, row.invite_subject, row.invite_template) try: await send_mail( cfg, body.to.strip(), mail.subject, mail.text, mail.html, sender=invitations.sender_address(row), inline=mail.inline, ) 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: try: await set_blocked(db, user, body.blocked) except MailServerError as exc: raise_api_error( 502, "mail_server_unavailable", f"the mail server did not answer: {exc}" ) 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) try: await delete_user(db, user) except MailServerError as exc: raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}") return {"status": "deleted"} # --- email translation languages (translator service) ------------------------------ class InstallProgressOut(BaseModel): done: int total: int class AdminTranslationOut(BaseModel): enabled: bool # TRANSLATOR_URL is set reachable: bool installed: list[str] = [] installing: dict[str, InstallProgressOut] = {} available: list[str] = [] # what Mozilla has models for (empty: offline) errors: dict[str, str] = {} # why a language's last install failed _LANG = Path(pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$") @router.get("/translation", response_model=AdminTranslationOut) async def translation_status(admin: CurrentAdmin) -> AdminTranslationOut: if not translation.enabled(): return AdminTranslationOut(enabled=False, reachable=False) try: data = await translation.status() except translation.TranslationError: return AdminTranslationOut(enabled=True, reachable=False) return AdminTranslationOut(enabled=True, reachable=True, **data) @router.post( "/translation/languages/{code}", response_model=AdminTranslationOut, dependencies=[Depends(require_csrf)], ) async def install_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut: """Download a language's models (to and from English) in the background.""" try: await translation.install(code) except translation.TranslationError as exc: raise_api_error(502, "translator_failed", str(exc)) return await translation_status(admin) @router.delete( "/translation/languages/{code}", response_model=AdminTranslationOut, dependencies=[Depends(require_csrf)], ) async def remove_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut: try: await translation.remove(code) except translation.TranslationError as exc: raise_api_error(502, "translator_failed", str(exc)) return await translation_status(admin)