From fd6671cf7e5ac2710ff23c917e4d4590195aebd8 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Fri, 18 Sep 2026 17:23:03 +1200 Subject: [PATCH] feat: add backup recovery, setup wizard and user-view guards --- .env.example | 7 +- README.md | 21 +- backend/app/config.py | 1 + backend/app/db.py | 4 + backend/app/main.py | 68 +- backend/app/request_limits.py | 50 ++ backend/app/routers/backups.py | 85 +++ backend/app/routers/setup.py | 80 +++ backend/app/secret_storage.py | 2 +- backend/app/services/backups.py | 631 ++++++++++++++++++ backend/app/services/setup.py | 197 ++++++ backend/tests/test_backups.py | 310 +++++++++ backend/tests/test_installation_http.py | 175 +++++ backend/tests/test_installation_lifecycle.py | 162 +++++ backend/tests/test_setup.py | 267 ++++++++ docs/installation-and-recovery.md | 63 ++ frontend/app/MyRequests.tsx | 16 +- frontend/app/admin/backups/backups.module.css | 133 ++++ frontend/app/admin/backups/page.tsx | 395 +++++++++++ frontend/app/admin/configNavigation.ts | 6 + frontend/app/insights/page.tsx | 6 +- frontend/app/insights/reports/page.tsx | 8 +- frontend/app/layout.tsx | 10 +- frontend/app/lib/user-view-policy.test.ts | 46 ++ frontend/app/lib/user-view-policy.ts | 16 + frontend/app/lib/viewMode.ts | 64 +- frontend/app/login/page.tsx | 2 + frontend/app/portal/PortalClient.tsx | 72 +- frontend/app/profile/invites/page.tsx | 15 +- frontend/app/profile/page.tsx | 15 +- frontend/app/requests/[id]/page.tsx | 61 +- frontend/app/setup/page.tsx | 584 ++++++++++++++++ frontend/app/setup/setup-model.test.ts | 85 +++ frontend/app/setup/setup-model.ts | 263 ++++++++ frontend/app/setup/setup.module.css | 49 ++ frontend/app/ui/AdminViewGate.tsx | 33 + frontend/app/ui/ApplicationChrome.tsx | 1 + frontend/app/ui/FeatureGate.tsx | 18 +- frontend/app/ui/HeaderIdentity.tsx | 5 +- frontend/app/ui/SetupGate.tsx | 42 ++ frontend/app/ui/UserViewBanner.tsx | 4 +- frontend/next.config.js | 4 +- scripts/review_installation_ui.cjs | 474 +++++++++++++ scripts/review_user_view_ui.cjs | 214 ++++++ 44 files changed, 4650 insertions(+), 114 deletions(-) create mode 100644 backend/app/request_limits.py create mode 100644 backend/app/routers/backups.py create mode 100644 backend/app/routers/setup.py create mode 100644 backend/app/services/backups.py create mode 100644 backend/app/services/setup.py create mode 100644 backend/tests/test_backups.py create mode 100644 backend/tests/test_installation_http.py create mode 100644 backend/tests/test_installation_lifecycle.py create mode 100644 backend/tests/test_setup.py create mode 100644 docs/installation-and-recovery.md create mode 100644 frontend/app/admin/backups/backups.module.css create mode 100644 frontend/app/admin/backups/page.tsx create mode 100644 frontend/app/lib/user-view-policy.test.ts create mode 100644 frontend/app/lib/user-view-policy.ts create mode 100644 frontend/app/setup/page.tsx create mode 100644 frontend/app/setup/setup-model.test.ts create mode 100644 frontend/app/setup/setup-model.ts create mode 100644 frontend/app/setup/setup.module.css create mode 100644 frontend/app/ui/AdminViewGate.tsx create mode 100644 frontend/app/ui/SetupGate.tsx create mode 100644 scripts/review_installation_ui.cjs create mode 100644 scripts/review_user_view_ui.cjs diff --git a/.env.example b/.env.example index 0ffc1d1..2a6b789 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,12 @@ LOG_FORMAT=text JWT_SECRET=replace-with-at-least-32-random-characters SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key ADMIN_USERNAME=admin -ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password +# Recommended fresh install: generate a separate random setup token. Open /setup +# to create the administrator and connect your apps; remove this after finishing. +SETUP_TOKEN=replace-with-a-separate-random-setup-token +# Alternatively pre-create the first admin with a unique password (12+ chars). +# Leave blank to create the account using the setup wizard and SETUP_TOKEN. +ADMIN_PASSWORD= AUTH_COOKIE_SECURE=false AUTH_COOKIE_SAMESITE=strict diff --git a/README.md b/README.md index 50106b0..dffc4dd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s - Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md). - Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md). - Docker-first deployment for easy hosting. +- Guided, resumable first-install setup with app connection tests. +- Encrypted backups of configuration, database and optional artwork cache, with restart-only restore. ## Quick start (Docker - primary) @@ -42,10 +44,17 @@ Then open: ### Docker setup steps -1) Create `.env` with your service URLs and API keys. -2) Run `docker compose up --build`. -3) Log in at http://localhost:3000. -4) Visit Settings to confirm service health. +1) Copy `.env.example` to `.env`. Generate independent `JWT_SECRET`, `SETTINGS_ENCRYPTION_KEY` and `SETUP_TOKEN` values as described below. Do not use the example placeholders. +2) Set `CORS_ALLOW_ORIGIN` and `MAGENT_APPLICATION_URL` to your browser-facing origin. For public deployments, use HTTPS and `AUTH_COOKIE_SECURE=true`. +3) Run `docker compose up --build`. +4) Open http://localhost:3000. A fresh database opens the setup wizard automatically. Use your `SETUP_TOKEN` to create a local administrator, then connect and test each app you use. +5) Choose site, sign-in, request-sync and email preferences, review the connections, and finish setup. Remove `SETUP_TOKEN` from the deployment environment afterwards. + +Apps may be skipped and configured later. Progress is saved in SQLite. Background imports and automation remain paused until setup is complete; `BACKGROUND_TASKS_ENABLED=false` still takes precedence. Existing installations are automatically treated as configured and are not forced through the wizard. Administrators can reopen it at **Settings → Advanced tools → Setup wizard**. + +If you prefer to seed an administrator through deployment configuration, set a unique `ADMIN_USERNAME` and `ADMIN_PASSWORD` instead of `SETUP_TOKEN`. The wizard then asks you to sign in with that account. Environment credentials create only the first administrator; they do not add another account to a restored installation. Service URLs and API keys can still be supplied through the environment, and the wizard preloads these settings without exposing saved secrets. + +See [installation and recovery](docs/installation-and-recovery.md) for migration, backup limits and restore instructions. ### Docker environment variables (sample) @@ -192,11 +201,13 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d ``` - `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes. +- `SETUP_TOKEN` is a separate random value of at least 32 characters, generated using the first command above a second time. It only authorizes first-admin creation on an unfinished, fresh installation. Never put it in a URL or share it with ordinary users. After completion the public bootstrap endpoint remains disabled even if the token is retained. - `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended. - Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link. - Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account. - `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history. - Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy. +- **View as user** is a per-tab interface preview: it hides configuration, user-management pages, diagnostics and moderation tools, including direct admin-page URLs. **Exit user view** restores the administrator interface. It does not impersonate another account or change backend permissions; the displayed data still belongs to the signed-in account. Test real permission boundaries with a separate non-admin account. ## History endpoints @@ -207,7 +218,7 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d ### Login fails -- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`. +- For a fresh installation, open `/setup` and use `SETUP_TOKEN`, or sign in with the environment-seeded administrator. Existing installations use the accounts already in the database; changing `ADMIN_PASSWORD` does not reset an existing account. - Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs). ### Services show as down diff --git a/backend/app/config.py b/backend/app/config.py index 838c3e6..16ca779 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): ) admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD")) + setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN")) auth_cookie_name: str = Field( default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME") ) diff --git a/backend/app/db.py b/backend/app/db.py index 6cec65a..a71203a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -929,6 +929,10 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str def ensure_admin_user() -> None: if not settings.admin_username or not _has_secure_bootstrap_admin_credentials(): return + # Environment credentials bootstrap only the first administrator. In + # particular, do not inject a destination host's account into a restored DB. + if has_admin_user(): + return existing = get_user_by_username(settings.admin_username) if existing: return diff --git a/backend/app/main.py b/backend/app/main.py index 2334b61..c2926c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,6 +6,8 @@ import uuid from typing import Awaitable, Callable from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.exception_handlers import request_validation_exception_handler from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -33,6 +35,10 @@ from .routers.insights import router as insights_router from .routers.identities import router as identities_router from .routers.recaps import router as recaps_router from .routers.newsletters import router as newsletters_router +from .routers.backups import router as backups_router +from .routers.setup import router as setup_router +from .services.backups import apply_pending_restore +from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.issue_resolution import run_issue_confirmation_loop from .services.email_recaps import run_email_recap_loop @@ -52,10 +58,12 @@ from .logging_config import ( ) from .runtime import get_runtime_settings from .metrics import record_api, start_metrics +from .request_limits import InstallationBodyLimitMiddleware from .secret_storage import validate_secret_storage_configuration logger = logging.getLogger(__name__) _background_tasks: list[asyncio.Task[None]] = [] +_background_started = False app = FastAPI( title=settings.app_name, @@ -71,6 +79,23 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +app.add_middleware(InstallationBodyLimitMiddleware) + + +@app.exception_handler(RequestValidationError) +async def installation_validation_error(request: Request, exc: RequestValidationError): + if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"): + # Pydantic SecretStr masks parsed values, but FastAPI's default 422 body + # includes rejected raw input. Never echo tokens/passwords/passphrases. + return JSONResponse( + status_code=422, + content={"detail": [ + {key: error[key] for key in ("type", "loc", "msg") if key in error} + for error in exc.errors() + ]}, + headers={"Cache-Control": "no-store"}, + ) + return await request_validation_exception_handler(request, exc) @app.middleware("http") @@ -221,9 +246,9 @@ def _log_security_configuration_warnings() -> None: "security configuration warning: JWT_SECRET is missing, short, or still set to the default value" ) admin_password = str(settings.admin_password or "") - if not admin_password or admin_password == "adminadmin": + if admin_password == "adminadmin": logger.warning( - "security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default" + "security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default" ) if bool(settings.api_docs_enabled): logger.warning( @@ -244,8 +269,11 @@ def _enforce_secure_startup_configuration() -> None: _enforce_secret_configuration() admin_password = str(settings.admin_password or "") if not has_admin_user() and (not admin_password or admin_password == "adminadmin"): + if is_setup_required() and setup_token_configured(): + return raise RuntimeError( - "A secure ADMIN_PASSWORD is required on first startup until an admin account exists." + "First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, " + "or a secure ADMIN_PASSWORD, until an admin account exists." ) @@ -264,6 +292,9 @@ async def startup() -> None: logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) _log_security_configuration_warnings() _enforce_secret_configuration() + # Restore offline, before any schema migration, database reader or worker. + apply_pending_restore() + initialize_setup_state() init_db() _enforce_secure_startup_configuration() runtime = get_runtime_settings() @@ -286,9 +317,22 @@ async def startup() -> None: runtime.log_background_sync_level, runtime.requests_data_source, ) - if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false": - logger.info("Background imports and automation paused for initial setup") + app.state.on_setup_complete = _start_background_tasks + await _start_background_tasks() + logger.info("startup complete") + + +async def _start_background_tasks() -> None: + global _background_started + if _background_started: return + if is_setup_required(): + logger.info("Background imports and automation paused until setup is complete") + return + if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false": + logger.info("Background imports and automation disabled by configuration") + return + _background_started = True _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync) _launch_background_task("requests-warmup", startup_warmup_requests_cache) _launch_background_task("request-local-stages", run_local_request_stage_loop) @@ -298,7 +342,17 @@ async def startup() -> None: _launch_background_task("issue-confirmation", run_issue_confirmation_loop) _launch_background_task("email-recaps", run_email_recap_loop) _launch_background_task("newsletters", run_newsletter_loop) - logger.info("startup complete") + + +@app.on_event("shutdown") +async def shutdown() -> None: + global _background_started + for task in _background_tasks: + task.cancel() + if _background_tasks: + await asyncio.gather(*_background_tasks, return_exceptions=True) + _background_tasks.clear() + _background_started = False app.include_router(requests_router) @@ -317,3 +371,5 @@ app.include_router(insights_router) app.include_router(identities_router) app.include_router(recaps_router) app.include_router(newsletters_router) +app.include_router(backups_router) +app.include_router(setup_router) diff --git a/backend/app/request_limits.py b/backend/app/request_limits.py new file mode 100644 index 0000000..d154c18 --- /dev/null +++ b/backend/app/request_limits.py @@ -0,0 +1,50 @@ +"""Bound security-sensitive request bodies before JSON/multipart parsing.""" + +from starlette.exceptions import HTTPException +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart +# envelope; count streamed chunks as well as checking the untrusted header. +RESTORE_BODY_LIMIT = 34 * 1024 * 1024 +BOOTSTRAP_BODY_LIMIT = 16 * 1024 + + +class InstallationBodyLimitMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") != "POST": + await self.app(scope, receive, send) + return + path = scope.get("path", "").rstrip("/") + limit = { + "/admin/backups/restore": RESTORE_BODY_LIMIT, + "/admin/backups/export": BOOTSTRAP_BODY_LIMIT, + "/setup/bootstrap": BOOTSTRAP_BODY_LIMIT, + }.get(path) + if limit is None: + await self.app(scope, receive, send) + return + headers = dict(scope.get("headers", [])) + try: + length = int(headers.get(b"content-length", b"0")) + except ValueError: + length = -1 + if length < 0 or length > limit: + await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send) + return + received = 0 + + async def bounded_receive() -> Message: + nonlocal received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > limit: + raise HTTPException(status_code=413, detail="Request body is too large.") + return message + + await self.app(scope, bounded_receive, send) diff --git a/backend/app/routers/backups.py b/backend/app/routers/backups.py new file mode 100644 index 0000000..929a9a6 --- /dev/null +++ b/backend/app/routers/backups.py @@ -0,0 +1,85 @@ +"""Administrator-only encrypted backup downloads and staged restores.""" + +from typing import Literal + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import Response +from pydantic import BaseModel, ConfigDict, Field, SecretStr +from starlette.concurrency import run_in_threadpool + +from ..auth import require_admin +from ..db import get_rate_limit_status, record_rate_limit_event +from ..services import backups + +def _no_store(response: Response) -> None: + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + + +router = APIRouter( + prefix="/admin/backups", tags=["backups"], + dependencies=[Depends(require_admin), Depends(_no_store)], +) + + +class ExportRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + passphrase: SecretStr = Field(min_length=12, max_length=1024) + include_cache: bool = False + + +def _rate_limit(user: dict) -> None: + key = str(user["username"]) + exceeded, retry = get_rate_limit_status("backups", key, 300, 3) + if exceeded: + raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)}) + record_rate_limit_event("backups", key) + + +@router.get("") +def status() -> dict: + return backups.backup_status() + + +@router.post("/export") +def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response: + _rate_limit(user) + try: + content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache) + except backups.BackupError as exc: + raise HTTPException(400, str(exc)) from exc + return Response(content, media_type="application/octet-stream", headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", "Pragma": "no-cache", + }) + + +@router.post("/restore", status_code=202) +async def restore( + file: UploadFile = File(...), + passphrase: str = Form(..., min_length=12, max_length=1024), + confirmation: Literal["RESTORE"] = Form(...), + user: dict = Depends(require_admin), +) -> dict: + _rate_limit(user) + try: + if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES: + raise HTTPException(413, "Backup exceeds the 32 MiB upload limit") + metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase) + except backups.BackupError as exc: + raise HTTPException(400, str(exc)) from exc + finally: + await file.close() + return { + "status": "staged", "restart_required": True, "backup": metadata, + "message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.", + } + + +@router.delete("/restore") +def cancel() -> dict: + try: + backups.cancel_restore() + except backups.BackupError as exc: + raise HTTPException(409, str(exc)) from exc + return {"status": "cancelled"} diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py new file mode 100644 index 0000000..26358d9 --- /dev/null +++ b/backend/app/routers/setup.py @@ -0,0 +1,80 @@ +"""Initial install bootstrap and authenticated setup wizard endpoints.""" + +from inspect import isawaitable + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import Field, SecretStr + +from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest +from ..auth import _extract_client_ip, require_admin +from ..services import setup as setup_service + + +router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES) + + +class BootstrapRequest(StrictRequest): + setup_token: SecretStr = Field(min_length=1, max_length=1024) + username: str = Field(min_length=1, max_length=100) + password: SecretStr = Field(min_length=1, max_length=1024) + + +class SetupProgress(StrictRequest): + step: setup_service.SetupStep + + +@router.get("/status") +def public_status(response: Response) -> dict: + response.headers["Cache-Control"] = "no-store" + return setup_service.get_public_setup_status() + + +@router.post("/bootstrap", status_code=201) +def bootstrap(payload: BootstrapRequest, request: Request) -> dict: + status = setup_service.get_public_setup_status() + if not status["needs_admin"]: + raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.") + retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request)) + if retry_after is not None: + raise HTTPException( + status_code=429, + detail="Too many setup attempts. Try again later.", + headers={"Retry-After": str(retry_after)}, + ) + try: + setup_service.bootstrap_administrator( + payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value() + ) + except setup_service.InvalidSetupTokenError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except setup_service.SetupUnavailableError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"status": "created", "username": payload.username.strip()} + + +@router.get("/state", dependencies=[Depends(require_admin)]) +def get_state() -> dict: + return setup_service.get_setup_state() + + +@router.put("/state", dependencies=[Depends(require_admin)]) +def update_state(payload: SetupProgress) -> dict: + return setup_service.update_setup_step(payload.step) + + +@router.post("/complete", dependencies=[Depends(require_admin)]) +async def finish_setup(request: Request) -> dict: + try: + state = setup_service.complete_setup() + except setup_service.SetupUnavailableError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + # Startup owns worker lifecycle. Its callback must be idempotent so retries + # after a network interruption cannot start duplicate import/automation jobs. + callback = getattr(request.app.state, "on_setup_complete", None) + if callback is not None: + result = callback() + if isawaitable(result): + await result + return state diff --git a/backend/app/secret_storage.py b/backend/app/secret_storage.py index 74ac830..f7cf856 100644 --- a/backend/app/secret_storage.py +++ b/backend/app/secret_storage.py @@ -15,7 +15,7 @@ SENSITIVE_SETTING_KEYS = frozenset( "magent_notify_telegram_bot_token", "magent_notify_push_token", "magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key", "jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key", - "prowlarr_api_key", "qbittorrent_password", + "prowlarr_api_key", "qbittorrent_password", "discord_webhook_url", } ) diff --git a/backend/app/services/backups.py b/backend/app/services/backups.py new file mode 100644 index 0000000..b4963d6 --- /dev/null +++ b/backend/app/services/backups.py @@ -0,0 +1,631 @@ +"""Encrypted, portable backups and restart-only SQLite restores. + +Restore is deliberately a two-step operation: the authenticated request validates +and stages it, then a single backend process applies it before opening the DB. +A durable journal and a private rollback copy protect interrupted installations. +""" + +from __future__ import annotations + +from contextlib import closing, contextmanager +from datetime import datetime, timezone +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import secrets +import shutil +import sqlite3 +import stat +import tempfile +import threading +import time +from typing import Any, BinaryIO, Iterator +import uuid +import zipfile +import zlib + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt +from pydantic import TypeAdapter + +from ..config import Settings, settings +from ..db import _db_path +from ..schema_migrations import MIGRATIONS +from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value + +FORMAT_VERSION = 1 +MAX_UPLOAD_BYTES = 32 * 1024 * 1024 +MAX_EXPANDED_BYTES = 128 * 1024 * 1024 +MAX_ENTRIES = 20_000 +MAGIC = b"MAGENT-BACKUP\x00\x01" +_LOCK = threading.Lock() +_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$") +_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"} +# Host identity, process controls and local file locations belong to the target. +_LOCAL_FIELDS = { + "sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key", + "admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin", + "auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain", + "auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled", + "log_file", "magent_application_port", "magent_api_port", "magent_bind_host", + "magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers", + "magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path", + "magent_ssl_certificate_pem", "magent_ssl_private_key_pem", + "site_build_number", "site_changelog", "magent_allow_private_notification_targets", +} + + +class BackupError(ValueError): + """A safe-to-display backup validation or state error.""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _assets_root() -> Path: + # Matches the image and branding routers, independently of SQLITE_PATH. + return Path.cwd() / "data" + + +def _control_root() -> Path: + return Path(_db_path()).absolute().parent / "backups" + + +def _private_dir(path: Path) -> None: + if path.is_symlink(): + raise BackupError("Backup directories must not be symbolic links") + path.mkdir(parents=True, exist_ok=True, mode=0o700) + path.chmod(0o700) + + +def _write_private(path: Path, content: bytes) -> None: + with path.open("xb") as handle: + path.chmod(0o600) + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + + +def _write_json(path: Path, data: dict) -> None: + temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex) + try: + _write_private(temporary, json.dumps(data, separators=(",", ":")).encode()) + os.replace(temporary, path) + _sync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) + + +def _sync_directory(path: Path) -> None: + if os.name != "nt": + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _sync_tree(path: Path) -> None: + for parent, _directories, files in os.walk(path, topdown=False): + for filename in files: + with (Path(parent) / filename).open("r+b") as handle: + os.fsync(handle.fileno()) + _sync_directory(Path(parent)) + + +@contextmanager +def _exclusive_operation() -> Iterator[None]: + if not _LOCK.acquire(blocking=False): + raise BackupError("Another backup or restore operation is in progress") + handle = None + locked = False + try: + root = _control_root() + _private_dir(root) + handle = (root / "operation.lock").open("a+b") + os.chmod(handle.name, 0o600) + # OS locks are released even if a process crashes; support the dev host too. + if os.name == "nt": + import msvcrt + handle.seek(0) + if not handle.read(1): + handle.write(b"0") + handle.flush() + handle.seek(0) + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + raise BackupError("Another backup or restore operation is in progress") from exc + else: + import fcntl + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise BackupError("Another backup or restore operation is in progress") from exc + locked = True + yield + finally: + if handle is not None: + if locked: + if os.name == "nt": + import msvcrt + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + handle.close() + _LOCK.release() + + +def validate_passphrase(passphrase: str) -> None: + if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024: + raise BackupError("Use a backup passphrase between 12 and 1024 characters") + + +def _key(passphrase: str, salt: bytes) -> bytes: + validate_passphrase(passphrase) + return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8")) + + +def _encrypt(content: bytes, passphrase: str) -> bytes: + salt, nonce = os.urandom(16), os.urandom(12) + header = MAGIC + salt + nonce + return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header) + + +def _decrypt(content: bytes, passphrase: str) -> bytes: + header_size = len(MAGIC) + 28 + if len(content) > MAX_UPLOAD_BYTES: + raise BackupError("Backup exceeds the 32 MiB upload limit") + if len(content) < header_size + 16 or not content.startswith(MAGIC): + raise BackupError("This is not a supported encrypted Magent backup") + salt = content[len(MAGIC):len(MAGIC) + 16] + nonce = content[len(MAGIC) + 16:header_size] + try: + return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size]) + except InvalidTag as exc: + raise BackupError("Incorrect passphrase or damaged backup") from exc + + +def _database_copy(source: Path, destination: Path) -> None: + if not source.is_file() or source.is_symlink(): + raise BackupError("The configured database is unavailable or is a symbolic link") + deadline = time.monotonic() + 60 + + def progress(_status: int, _remaining: int, _total: int) -> None: + if time.monotonic() > deadline: + raise BackupError("Database is too busy to back up; try again shortly") + + with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src: + with closing(sqlite3.connect(destination)) as dst: + destination.chmod(0o600) + src.backup(dst, pages=256, progress=progress, sleep=0.05) + dst.execute("PRAGMA journal_mode=DELETE") + + +def _portable_database(path: Path) -> None: + """Materialize env-backed settings and remove source-specific encryption.""" + with closing(sqlite3.connect(path)) as conn, conn: + conn.execute("PRAGMA secure_delete=ON") + # init_db recreates application-owned triggers after restoration; never + # distribute executable schema objects in a data backup. + for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall(): + quoted = str(trigger).replace('"', '""') + conn.execute(f'DROP TRIGGER "{quoted}"') + overrides = dict(conn.execute("SELECT key, value FROM settings")) + for key, default in settings.model_dump().items(): + if key in _LOCAL_FIELDS: + continue + value = overrides.get(key) + value = default if value is None else decrypt_setting_value(key, value) + conn.execute( + "INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", + (key, "" if value is None else str(value), _now()), + ) + for key in _LOCAL_FIELDS: + conn.execute("DELETE FROM settings WHERE key=?", (key,)) + # Future secret keys may not yet be exposed through Settings. + for key, value in conn.execute("SELECT key,value FROM settings").fetchall(): + if key in SENSITIVE_SETTING_KEYS: + conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key)) + conn.commit() + conn.execute("VACUUM") + + +def _asset_allowed(name: str, include_cache: bool) -> bool: + parts = PurePosixPath(name).parts + if name in {"files/branding/logo.png", "files/branding/favicon.ico"}: + return True + return bool( + include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb") + and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4]) + and parts[4] not in {".", ".."} + ) + + +def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]: + root = _assets_root() + for directory in ("branding", "artwork") if include_cache else ("branding",): + base = root / directory + if not base.exists(): + continue + if base.is_symlink() or root.is_symlink(): + raise BackupError("Asset directories must not be symbolic links") + for parent, directories, files in os.walk(base, followlinks=False): + if any((Path(parent) / name).is_symlink() for name in directories + files): + raise BackupError("Symbolic links are not supported in backup assets") + for filename in files: + path = Path(parent) / filename + archive_name = "files/" + path.relative_to(root).as_posix() + if _asset_allowed(archive_name, include_cache): + yield path, archive_name + + +def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]: + validate_passphrase(passphrase) + with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary: + directory = Path(temporary) + directory.chmod(0o700) + database = directory / "database.sqlite3" + _database_copy(Path(_db_path()).absolute(), database) + _portable_database(database) + files = [(database, "database.sqlite3"), *_asset_files(include_cache)] + if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES: + raise BackupError("Backup is too large; retry without the artwork cache") + archive_path = directory / "payload.zip" + manifest = { + "format_version": FORMAT_VERSION, "created_at": _now(), + "build": str(settings.site_build_number or "unknown"), "include_cache": include_cache, + "files": {}, + } + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + archive_path.chmod(0o600) + total = 0 + for path, name in files: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as source, archive.open(name, "w") as destination: + while chunk := source.read(1024 * 1024): + total += len(chunk) + size += len(chunk) + if total > MAX_EXPANDED_BYTES: + raise BackupError("Backup is too large; retry without the artwork cache") + digest.update(chunk) + destination.write(chunk) + manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()} + archive.writestr("manifest.json", json.dumps(manifest)) + if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128: + raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache") + encrypted = _encrypt(archive_path.read_bytes(), passphrase) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return encrypted, f"magent-backup-{stamp}.magent-backup" + + +def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None: + try: + with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn: + conn.execute("PRAGMA trusted_schema=OFF") + deadline = time.monotonic() + 30 + conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000) + if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]: + raise BackupError("Backup database failed its integrity check") + schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall() + if len(schema) > 500 or any( + kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper() + for kind, _name, sql in schema + ): + raise BackupError("Backup contains an unsupported database schema") + if conn.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise BackupError("Backup database contains broken references") + required = { + "settings": {"key", "value", "updated_at"}, + "users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"}, + "signup_invites": {"id", "code", "enabled"}, + "requests_cache": {"request_id", "payload_json"}, + "schema_migrations": {"version", "name", "applied_at"}, + "password_reset_tokens": {"id", "token_hash"}, + } + for table, fields in required.items(): + columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')} + if not fields <= columns: + raise BackupError("Backup does not contain a compatible Magent database") + optional = { + "installation_setup": {"id", "completed", "step", "completed_at"}, + "installation_setup_attempts": {"scope", "key_hash", "occurred_at"}, + } + table_names = {name for kind, name, _sql in schema if kind == "table"} + for table, fields in optional.items(): + if table in table_names: + columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')} + if not fields <= columns: + raise BackupError("Backup setup state has an incompatible schema") + # An admin can stage a restore only after target initialization. Its + # schema is a trusted reference for *all* runtime columns, including + # versioned migrations that init_db will not rerun on a restored DB. + target = Path(_db_path()).absolute() + if target.is_file() and target != path: + with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference: + tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")] + for table in tables: + if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}: + continue + quoted = str(table).replace('"', '""') + expected = { + row[1]: (row[2].upper(), bool(row[3]), row[5]) + for row in reference.execute(f'PRAGMA table_info("{quoted}")') + } + actual = { + row[1]: (row[2].upper(), bool(row[3]), row[5]) + for row in conn.execute(f'PRAGMA table_info("{quoted}")') + } + if expected != actual: + raise BackupError("Backup is missing database columns required by this installation") + versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")} + if versions != {migration.version for migration in MIGRATIONS}: + raise BackupError("Backup schema is incompatible; restore using the same Magent version") + if not conn.execute( + "SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1" + ).fetchone(): + raise BackupError("Backup must contain an active administrator account") + values = dict(conn.execute("SELECT key,value FROM settings")) + if _LOCAL_FIELDS.intersection(values): + raise BackupError("Backup contains host-specific configuration") + # Pydantic checks the types of portable settings without reading env values. + for key, value in values.items(): + if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS: + value = decrypt_setting_value(key, value) + if key in Settings.model_fields and value not in {None, ""}: + field = Settings.model_fields[key] + TypeAdapter(field.rebuild_annotation()).validate_python(value) + except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc: + if isinstance(exc, BackupError): + raise + raise BackupError("Backup database or configuration is invalid") from exc + + +def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]: + try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + entries = archive.infolist() + if not entries or len(entries) > MAX_ENTRIES: + raise BackupError("Backup contains too many files") + names = [entry.filename for entry in entries] + if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names: + raise BackupError("Backup manifest is missing or contains duplicate files") + if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: + raise BackupError("Expanded backup exceeds the 128 MiB limit") + for entry in entries: + parts = PurePosixPath(entry.filename).parts + mode = entry.external_attr >> 16 + if ( + entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename + or str(PurePosixPath(entry.filename)) != entry.filename + or ":" in entry.filename or any(part in {".", ".."} for part in parts) + or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1 + or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED} + ): + raise BackupError("Backup contains an unsafe archive entry") + if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024: + raise BackupError("Backup manifest is too large") + manifest = json.loads(archive.read("manifest.json")) + if ( + not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION + or not isinstance(manifest.get("include_cache"), bool) + or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64 + or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100 + or not isinstance(manifest.get("files"), dict) + or set(manifest["files"]) != set(names) - {"manifest.json"} + ): + raise BackupError("Backup manifest is invalid or unsupported") + extracted_bytes = 0 + for entry in entries: + name = entry.filename + if name == "manifest.json": + continue + if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]): + raise BackupError("Backup contains an unsupported file") + expected = manifest["files"][name] + if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size: + raise BackupError("Backup file does not match its manifest") + target = directory.joinpath(*PurePosixPath(name).parts) + _private_dir(target.parent) + digest = hashlib.sha256() + with archive.open(entry) as source, target.open("xb") as destination: + target.chmod(0o600) + while chunk := source.read(1024 * 1024): + extracted_bytes += len(chunk) + if extracted_bytes > MAX_EXPANDED_BYTES: + raise BackupError("Expanded backup exceeds the 128 MiB limit") + digest.update(chunk) + destination.write(chunk) + destination.flush() + os.fsync(destination.fileno()) + if digest.hexdigest() != expected.get("sha256"): + raise BackupError("Backup file failed its checksum") + _validate_database(directory / "database.sqlite3") + return manifest + except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc: + if isinstance(exc, BackupError): + raise + raise BackupError("Backup archive is invalid or damaged") from exc + + +def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]: + validate_passphrase(passphrase) + with _exclusive_operation(): + root = _control_root() + pending = root / "pending" + if pending.exists(): + raise BackupError("A restore is already staged; cancel it before uploading another") + payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase) + with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary: + stage = Path(temporary) + stage.chmod(0o700) + manifest = _extract_archive(payload, stage) + with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn: + conn.execute("PRAGMA secure_delete=ON") + for key, value in conn.execute("SELECT key,value FROM settings").fetchall(): + if key in SENSITIVE_SETTING_KEYS: + if value and str(value).startswith("enc:v1:"): + raise BackupError("Backup settings are not portable") + conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key)) + # Do not revive reset links or existing browser sessions. Invites remain intact. + conn.execute("DELETE FROM password_reset_tokens") + conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,)) + if not manifest["include_cache"]: + conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0") + conn.commit() + # Remove plaintext secret remnants from replaced/free SQLite pages. + conn.execute("VACUUM") + metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")} + metadata["staged_at"] = _now() + _write_json(stage / "metadata.json", metadata) + # Stage survives reboot; it contains only secrets encrypted for this installation. + os.replace(stage, pending) + _sync_directory(root) + return metadata + + +def backup_status() -> dict[str, Any]: + root = _control_root() + pending_path = root / "pending" / "metadata.json" + last_path = root / "last-restore.json" + return { + "format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES, + "max_expanded_bytes": MAX_EXPANDED_BYTES, + "include_cache_default": False, + "pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None, + "last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None, + } + + +def cancel_restore() -> None: + with _exclusive_operation(): + pending = _control_root() / "pending" + if pending.is_symlink(): + raise BackupError("Invalid staged restore directory") + if pending.exists(): + shutil.rmtree(pending) + + +def _replace_file(source: Path, target: Path) -> None: + _private_dir(target.parent) + temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex) + try: + shutil.copyfile(source, temporary) + temporary.chmod(0o600) + with temporary.open("r+b") as handle: + os.fsync(handle.fileno()) + os.replace(temporary, target) + _sync_directory(target.parent) + finally: + temporary.unlink(missing_ok=True) + + +def _replace_assets(source: Path, target: Path) -> None: + if target.is_symlink(): + raise BackupError("Asset directories must not be symbolic links") + if target.exists(): + shutil.rmtree(target) + if source.exists(): + shutil.copytree(source, target, copy_function=shutil.copyfile) + for parent, _directories, files in os.walk(target): + Path(parent).chmod(0o700) + for filename in files: + (Path(parent) / filename).chmod(0o600) + _sync_tree(target) + if target.parent.exists(): + _sync_directory(target.parent) + + +def _recover(journal: dict, root: Path) -> None: + rollback_name = journal.get("rollback_directory", "") + if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name): + raise BackupError("Restore recovery journal is invalid") + rollback = root / rollback_name + database = Path(_db_path()).absolute() + if journal["had_database"]: + _replace_file(rollback / "database.sqlite3", database) + else: + database.unlink(missing_ok=True) + for suffix in ("-wal", "-shm", "-journal"): + Path(str(database) + suffix).unlink(missing_ok=True) + for name in journal["asset_roots"]: + if name not in {"branding", "artwork"}: + raise BackupError("Restore recovery journal is invalid") + _replace_assets(rollback / "files" / name, _assets_root() / name) + _write_json(root / "last-restore.json", { + "status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name, + "message": "An interrupted or failed restore was rolled back automatically.", + }) + _write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"}) + pending = root / "pending" + if pending.exists(): + shutil.rmtree(pending) + (root / "restore-journal.json").unlink() + _sync_directory(root) + + +def apply_pending_restore() -> bool: + """Call once before init_db, with no other backend processes using the DB.""" + with _exclusive_operation(): + root = _control_root() + journal_path = root / "restore-journal.json" + if journal_path.exists(): + journal = json.loads(journal_path.read_text()) + if journal.get("phase") in {"complete", "rolled_back"}: + if (root / "pending").exists(): + shutil.rmtree(root / "pending") + journal_path.unlink() + _sync_directory(root) + return journal["phase"] == "complete" + _recover(journal, root) + return False + pending = root / "pending" + if not pending.exists(): + return False + if pending.is_symlink(): + raise BackupError("Invalid staged restore directory") + metadata = json.loads((pending / "metadata.json").read_text()) + _validate_database(pending / "database.sqlite3", verify_settings_encryption=True) + database = Path(_db_path()).absolute() + rollback = root / ("rollback-" + uuid.uuid4().hex) + _private_dir(rollback) + # Ensure all disk-space/permission failures in backup happen before replacement. + if database.exists(): + _database_copy(database, rollback / "database.sqlite3") + names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"] + # Reject links anywhere before copying or deleting the controlled asset trees. + list(_asset_files(metadata["include_cache"])) + for name in names: + source = _assets_root() / name + if source.exists(): + shutil.copytree(source, rollback / "files" / name) + _sync_tree(rollback) + journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names} + _write_json(journal_path, journal) + try: + for suffix in ("-wal", "-shm", "-journal"): + Path(str(database) + suffix).unlink(missing_ok=True) + _replace_file(pending / "database.sqlite3", database) + for name in names: + _replace_assets(pending / "files" / name, _assets_root() / name) + _write_json(root / "last-restore.json", { + "status": "restored", "restored_at": _now(), "rollback_directory": rollback.name, + "backup_created_at": metadata["created_at"], + }) + _write_json(journal_path, {**journal, "phase": "complete"}) + except Exception: + _recover(journal, root) + raise + shutil.rmtree(pending) + journal_path.unlink() + _sync_directory(root) + return True diff --git a/backend/app/services/setup.py b/backend/app/services/setup.py new file mode 100644 index 0000000..615d405 --- /dev/null +++ b/backend/app/services/setup.py @@ -0,0 +1,197 @@ +"""Persistent, operator-authorized first-install setup. + +Initialize the marker before the main schema: an existing users table identifies +an upgraded installation, while a new database must finish the setup wizard. +The marker and first administrator are protected by SQLite write transactions. +""" + +from datetime import datetime, timezone +import hmac +from math import ceil +from time import time +from typing import Literal + +from .. import db +from ..config import settings +from ..security import hash_password, validate_password_policy + + +SetupStep = Literal["administrator", "apps", "preferences", "review"] +SETUP_STEPS = ("administrator", "apps", "preferences", "review") +BOOTSTRAP_WINDOW_SECONDS = 15 * 60 +BOOTSTRAP_IP_ATTEMPTS = 5 +BOOTSTRAP_GLOBAL_ATTEMPTS = 30 + + +class SetupUnavailableError(ValueError): + """Setup has finished, or another administrator already exists.""" + + +class InvalidSetupTokenError(ValueError): + """The operator's setup token was absent or did not match.""" + + +def initialize_setup_state() -> None: + """Run once before init_db; subsequent calls preserve progress.""" + with db._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + existing_install = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'" + ).fetchone() is not None + conn.execute( + """CREATE TABLE IF NOT EXISTS installation_setup ( + id INTEGER PRIMARY KEY CHECK (id = 1), + completed INTEGER NOT NULL CHECK (completed IN (0, 1)), + step TEXT NOT NULL, + completed_at TEXT + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS installation_setup_attempts ( + scope TEXT NOT NULL, + key_hash TEXT NOT NULL, + occurred_at REAL NOT NULL + )""" + ) + conn.execute( + """INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at) + VALUES (1, ?, ?, ?)""", + ( + int(existing_install), + "review" if existing_install else "administrator", + datetime.now(timezone.utc).isoformat() if existing_install else None, + ), + ) + + +def get_setup_state() -> dict: + with db._connect() as conn: + # Old databases and isolated callers without startup initialization are + # already installed. A missing marker must never open public bootstrap. + table = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'" + ).fetchone() + row = conn.execute( + "SELECT completed, step, completed_at FROM installation_setup WHERE id = 1" + ).fetchone() if table else None + if row is None: + return {"completed": True, "step": "review", "completed_at": None} + return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]} + + +def is_setup_required() -> bool: + return not get_setup_state()["completed"] + + +def get_public_setup_status() -> dict: + required = is_setup_required() + return {"setup_required": required, "needs_admin": required and not db.has_admin_user()} + + +def setup_token_configured() -> bool: + """Reject missing values and obvious examples, without claiming to measure entropy.""" + token = str(getattr(settings, "setup_token", "") or "").strip() + placeholder = token.casefold().replace("_", "-") + return ( + len(token) >= 32 + and len(set(token)) > 1 + and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token")) + ) + + +def consume_bootstrap_attempt(client_ip: str) -> int | None: + """Atomically reserve one attempt; return Retry-After when limited. + + The IP is keyed using the existing HMAC helper, never stored in clear text. + A shared cap limits distributed attempts and expensive password hashing. + """ + now = time() + cutoff = now - BOOTSTRAP_WINDOW_SECONDS + limits = ( + ("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS), + ("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS), + ) + with db._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "DELETE FROM installation_setup_attempts WHERE occurred_at < ?", + (cutoff,), + ) + retry_after = 0 + for scope, key, maximum in limits: + count, oldest = conn.execute( + """SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts + WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""", + (scope, key, cutoff), + ).fetchone() + if count >= maximum: + retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1) + if retry_after: + return retry_after + conn.executemany( + "INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)", + [(scope, key, now) for scope, key, _ in limits], + ) + return None + + +def bootstrap_administrator(setup_token: str, username: str, password: str) -> None: + """Claim fresh setup exactly once using the deployment's setup token.""" + expected = str(getattr(settings, "setup_token", "") or "") + if not setup_token_configured() or not hmac.compare_digest( + setup_token.encode("utf-8"), expected.encode("utf-8") + ): + raise InvalidSetupTokenError("Invalid setup token.") + username = username.strip() + if not username or len(username) > 100 or any( + character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username + ): + raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.") + if len(password) > 1024: + raise ValueError("Password must contain no more than 1024 characters.") + password = validate_password_policy(password) + if not is_setup_required() or db.has_admin_user(): + raise SetupUnavailableError("Initial administrator setup is no longer available.") + + password_hash = hash_password(password) + with db._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone() + admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone() + if setup is None or setup[0] or admin: + raise SetupUnavailableError("Initial administrator setup is no longer available.") + if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")): + raise SetupUnavailableError("That username already exists.") + conn.execute( + """INSERT INTO users (username, password_hash, role, auth_provider, created_at) + VALUES (?, ?, 'admin', 'local', ?)""", + (username, password_hash, datetime.now(timezone.utc).isoformat()), + ) + conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1") + + +def update_setup_step(step: SetupStep) -> dict: + if step not in SETUP_STEPS: + raise ValueError("Invalid setup step.") + if not is_setup_required(): + return get_setup_state() + with db._connect() as conn: + conn.execute( + "UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,) + ) + return get_setup_state() + + +def complete_setup() -> dict: + if not is_setup_required(): + return get_setup_state() + with db._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone(): + raise SetupUnavailableError("Create an administrator before completing setup.") + conn.execute( + """UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ? + WHERE id = 1 AND completed = 0""", + (datetime.now(timezone.utc).isoformat(),), + ) + return get_setup_state() diff --git a/backend/tests/test_backups.py b/backend/tests/test_backups.py new file mode 100644 index 0000000..cc42f68 --- /dev/null +++ b/backend/tests/test_backups.py @@ -0,0 +1,310 @@ +from contextlib import closing +import io +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +from cryptography.fernet import Fernet +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from backend.app import db +from backend.app.auth import get_current_user +from backend.app.config import settings +from backend.app.routers import backups as backup_router +from backend.app.services import backups + + +PASSPHRASE = "test backup passphrase with spaces" + + +class BackupTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.database = self.root / "magent.db" + for key, value in { + "sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE", + "settings_encryption_key": Fernet.generate_key().decode(), + "jwt_secret": "source-installation-signing-secret-for-backup-tests", + "admin_username": "backup-admin", "admin_password": "a secure initial password", + "jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token", + "discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token", + }.items(): + context = patch.object(settings, key, value) + context.start() + self.addCleanup(context.stop) + context = patch.object(backups, "_assets_root", return_value=self.root / "assets") + context.start() + self.addCleanup(context.stop) + db.init_db() + db.set_setting("sonarr_api_key", "database-integration-secret") + db.set_setting("site_login_message", "Restored configuration") + db.set_setting("installation_setup", "complete") + with closing(sqlite3.connect(self.database)) as conn, conn: + conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')") + conn.execute( + "INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')" + ) + self.assets = self.root / "assets" + (self.assets / "branding").mkdir(parents=True) + (self.assets / "branding" / "logo.png").write_bytes(b"branding fixture") + (self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True) + (self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture") + + def export(self, include_cache=True): + content, filename = backups.create_backup(PASSPHRASE, include_cache) + self.assertTrue(filename.endswith(".magent-backup")) + return content + + def rewrite_archive(self, content, change): + decrypted = backups._decrypt(content, PASSPHRASE) + with zipfile.ZipFile(io.BytesIO(decrypted)) as archive: + files = {entry.filename: archive.read(entry) for entry in archive.infolist()} + change(files) + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name, value in files.items(): + archive.writestr(name, value) + return backups._encrypt(output.getvalue(), PASSPHRASE) + + def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self): + content = self.export() + self.assertNotIn(b"database-integration-secret", content) + self.assertNotIn(b"environment-integration-secret", content) + original_auth_version = db.get_user_by_username("backup-admin")["auth_version"] + db.set_setting("site_login_message", "Live data before restart") + settings.settings_encryption_key = Fernet.generate_key().decode() + settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests" + # Simulate a different host with different env-backed integration settings. + settings.jellyfin_api_key = "destination-env-value" + metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE) + self.assertTrue(metadata["include_cache"]) + self.assertEqual(db.get_setting("site_login_message"), "Live data before restart") + self.assertIsNotNone(backups.backup_status()["pending_restore"]) + staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes() + self.assertNotIn(b"database-integration-secret", staged_bytes) + self.assertNotIn(b"environment-integration-secret", staged_bytes) + self.assertNotIn(b"legacy-private-token", staged_bytes) + (self.assets / "branding" / "logo.png").write_bytes(b"changed logo") + (self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink() + self.assertTrue(backups.apply_pending_restore()) + self.assertEqual(db.get_setting("site_login_message"), "Restored configuration") + self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret") + self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret") + self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token") + self.assertEqual(db.get_setting("installation_setup"), "complete") + self.assertIsNone(db.get_setting("setup_token")) + self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture") + self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture") + self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version) + with closing(sqlite3.connect(self.database)) as conn, conn: + self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",)) + self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",)) + self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:")) + status = backups.backup_status() + self.assertIsNone(status["pending_restore"]) + self.assertEqual(status["last_restore"]["status"], "restored") + self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file()) + self.assertFalse(backups.apply_pending_restore()) + + def test_wal_snapshot_contains_committed_uncheckpointed_rows(self): + with closing(sqlite3.connect(self.database)) as writer: + writer.execute("PRAGMA journal_mode=WAL") + writer.execute("PRAGMA wal_autocheckpoint=0") + writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580") + writer.commit() + self.assertTrue(Path(str(self.database) + "-wal").exists()) + content = self.export() + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + self.assertTrue(backups.apply_pending_restore()) + with closing(sqlite3.connect(self.database)) as restored: + self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL") + + def test_process_interruption_is_recovered_on_next_startup(self): + class ProcessStopped(BaseException): + pass + + content = self.export() + db.set_setting("site_login_message", "Value before interrupted restart") + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + with patch.object(backups, "_replace_assets", side_effect=ProcessStopped): + with self.assertRaises(ProcessStopped): + backups.apply_pending_restore() + self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists()) + self.assertEqual(db.get_setting("site_login_message"), "Restored configuration") + self.assertFalse(backups.apply_pending_restore()) + self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart") + self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back") + self.assertIsNone(backups.backup_status()["pending_restore"]) + + def test_crash_after_rollback_does_not_reapply_pending_restore(self): + class ProcessStopped(BaseException): + pass + + content = self.export() + db.set_setting("site_login_message", "Value to retain") + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + replace_assets = backups._replace_assets + remove_tree = backups.shutil.rmtree + calls = 0 + + def fail_first_copy(source, target): + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("failed apply") + return replace_assets(source, target) + + def interrupt_cleanup(path, *args, **kwargs): + if Path(path).name == "pending": + raise ProcessStopped() + return remove_tree(path, *args, **kwargs) + + with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \ + patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup): + with self.assertRaises(ProcessStopped): + backups.apply_pending_restore() + journal = json.loads((self.root / "backups" / "restore-journal.json").read_text()) + self.assertEqual(journal["phase"], "rolled_back") + self.assertFalse(backups.apply_pending_restore()) + self.assertEqual(db.get_setting("site_login_message"), "Value to retain") + self.assertIsNone(backups.backup_status()["pending_restore"]) + + def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self): + directory = self.root / "schema-test" + directory.mkdir() + backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory) + source = directory / "database.sqlite3" + with closing(sqlite3.connect(source)) as conn, conn: + conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled") + with self.assertRaisesRegex(backups.BackupError, "missing database columns"): + backups._validate_database(source) + + def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self): + content = self.export() + db.set_setting("site_login_message", "Current data") + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + settings.settings_encryption_key = Fernet.generate_key().decode() + with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"): + backups.apply_pending_restore() + self.assertEqual(db.get_setting("site_login_message"), "Current data") + self.assertIsNotNone(backups.backup_status()["pending_restore"]) + + def test_excluding_disk_cache_keeps_database_cache_and_branding(self): + with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive: + self.assertIn("database.sqlite3", archive.namelist()) + self.assertIn("files/branding/logo.png", archive.namelist()) + self.assertFalse(any("artwork" in name for name in archive.namelist())) + + def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self): + content = self.export() + for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)): + with self.subTest(password=password): + with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"): + backups.stage_restore(io.BytesIO(bad_content), password) + self.assertIsNone(backups.backup_status()["pending_restore"]) + self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret") + + def test_path_traversal_unknown_files_and_checksum_failures_rejected(self): + content = self.export() + for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"): + with self.subTest(name=name): + malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"})) + with self.assertRaises(backups.BackupError): + backups.stage_restore(io.BytesIO(malformed), PASSPHRASE) + malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"})) + with self.assertRaises(backups.BackupError): + backups.stage_restore(io.BytesIO(malformed), PASSPHRASE) + self.assertFalse((self.root / "outside.txt").exists()) + + def test_size_limit_and_unsupported_schema_rejected(self): + content = self.export() + with patch.object(backups, "MAX_UPLOAD_BYTES", 16): + with self.assertRaisesRegex(backups.BackupError, "upload limit"): + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + with patch.object(backups, "MAX_EXPANDED_BYTES", 16): + with self.assertRaisesRegex(backups.BackupError, "Expanded backup"): + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + with closing(sqlite3.connect(self.database)) as conn, conn: + conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END") + # Validate the original fixture to avoid executing the malicious trigger in export. + with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"): + backups._validate_database(self.database) + + def test_unsupported_compression_is_rejected_before_expansion(self): + content = self.export() + rewritten = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original: + with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target: + for entry in original.infolist(): + target.writestr(entry.filename, original.read(entry)) + with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"): + backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE) + + def test_cancel_is_idempotent_and_does_not_change_database(self): + content = self.export() + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + with self.assertRaisesRegex(backups.BackupError, "already staged"): + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + backups.cancel_restore() + backups.cancel_restore() + self.assertIsNone(backups.backup_status()["pending_restore"]) + self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret") + + def test_failure_after_database_replacement_rolls_back_both_database_and_files(self): + content = self.export() + db.set_setting("site_login_message", "Keep this current value") + (self.assets / "branding" / "logo.png").write_bytes(b"current logo") + backups.stage_restore(io.BytesIO(content), PASSPHRASE) + original = backups._replace_assets + calls = 0 + + def fail_once(source, target): + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("simulated interrupted copy") + return original(source, target) + + with patch.object(backups, "_replace_assets", side_effect=fail_once): + with self.assertRaisesRegex(OSError, "interrupted copy"): + backups.apply_pending_restore() + self.assertEqual(db.get_setting("site_login_message"), "Keep this current value") + self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo") + self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back") + self.assertFalse(backups.apply_pending_restore()) + + def test_api_requires_admin_and_restore_confirmation(self): + app = FastAPI() + app.include_router(backup_router.router) + with TestClient(app) as client: + self.assertEqual(client.get("/admin/backups").status_code, 401) + app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"} + self.assertEqual(client.get("/admin/backups").status_code, 403) + self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403) + app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"} + status = client.get("/admin/backups") + self.assertEqual(status.status_code, 200) + self.assertEqual(status.headers["cache-control"], "no-store") + self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES) + response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)}, + data={"passphrase": PASSPHRASE, "confirmation": "wrong"}) + self.assertEqual(rejected.status_code, 422) + restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)}, + data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"}) + self.assertEqual(restored.status_code, 202) + self.assertTrue(restored.json()["restart_required"]) + self.assertEqual(client.delete("/admin/backups/restore").status_code, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_installation_http.py b/backend/tests/test_installation_http.py new file mode 100644 index 0000000..d777d9c --- /dev/null +++ b/backend/tests/test_installation_http.py @@ -0,0 +1,175 @@ +"""Real application HTTP checks for installation, cookies, and backup controls. + +All persistence and artwork paths are isolated in temporary directories; workers, +logging file handlers, and the metrics listener are disabled for these tests. +""" + +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from backend.app import db, main +from backend.app.config import settings +from backend.app.services import backups + + +OPERATOR_TOKEN = "installation-http-operator-token-test-123456789" +OWNER_PASSWORD = "installation-http-owner-password-123456789" +BACKUP_PASSPHRASE = "installation-http-backup-passphrase-123456789" + + +class InstallationHttpTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + for key, value in { + "sqlite_path": str(self.root / "magent.db"), + "sqlite_journal_mode": "DELETE", + "jwt_secret": "installation-http-test-jwt-secret-1234567890", + "settings_encryption_key": None, + "admin_username": "unused-environment-admin", + "admin_password": "", + "setup_token": OPERATOR_TOKEN, + "auth_cookie_secure": True, + "auth_cookie_domain": None, + "auth_cookie_samesite": "strict", + }.items(): + context = patch.object(settings, key, value) + context.start() + self.addCleanup(context.stop) + for context in ( + patch.object(main, "configure_logging"), + patch.object(main, "start_metrics"), + patch.object(main, "_background_tasks", []), + patch.object(main, "_background_started", False), + patch.object(backups, "_assets_root", return_value=self.root / "assets"), + patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), + ): + context.start() + self.addCleanup(context.stop) + self.origin = str(settings.cors_allow_origin).rstrip("/") + self.client = self.enterContext(TestClient(main.app, base_url="https://magent.test")) + self.client.headers["Origin"] = self.origin + + def create_owner(self): + response = self.client.post("/setup/bootstrap", json={ + "setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD, + }) + self.assertEqual(response.status_code, 201, response.text) + return response + + def sign_in(self): + response = self.client.post("/auth/login", data={"username": "owner", "password": OWNER_PASSWORD}) + self.assertEqual(response.status_code, 200, response.text) + self.assertIn(settings.auth_cookie_name, self.client.cookies) + auth_cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "=")) + self.assertIn("HttpOnly", auth_cookie) + self.assertIn("Secure", auth_cookie) + self.assertIn("SameSite=strict", auth_cookie) + self.assertNotIn("Authorization", self.client.headers) + + def test_fresh_setup_cookie_settings_completion_and_backup_round_trip(self): + status = self.client.get("/setup/status") + self.assertEqual(status.json(), {"setup_required": True, "needs_admin": True}) + self.assertEqual(status.headers["cache-control"], "no-store") + self.assertIn("default-src 'none'", status.headers["content-security-policy"]) + self.assertEqual(self.client.get("/setup/state").status_code, 401) + self.assertEqual(self.client.get("/admin/backups").status_code, 401) + + self.create_owner() + self.sign_in() + self.assertEqual(self.client.get("/setup/state").json()["step"], "apps") + response = self.client.put("/admin/settings", json={ + "jellyfin_base_url": "http://jellyfin.test:8096", + "jellyfin_api_key": "test-integration-key-for-setup", + "site_login_message": "Welcome to this installation", + }) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["updated"], 3) + values = {row["key"]: row for row in self.client.get("/admin/settings").json()["settings"]} + self.assertEqual(values["jellyfin_base_url"]["value"], "http://jellyfin.test:8096") + self.assertIsNone(values["jellyfin_api_key"]["value"]) + self.assertTrue(values["jellyfin_api_key"]["isSet"]) + response = self.client.put("/setup/state", json={"step": "review"}) + self.assertEqual(response.status_code, 200, response.text) + completed = self.client.post("/setup/complete") + self.assertEqual(completed.status_code, 200, completed.text) + self.assertTrue(completed.json()["completed"]) + self.assertEqual(self.client.get("/setup/status").json(), {"setup_required": False, "needs_admin": False}) + self.assertEqual(main._background_tasks, []) + + exported = self.client.post("/admin/backups/export", json={ + "passphrase": BACKUP_PASSPHRASE, "include_cache": False, + }) + self.assertEqual(exported.status_code, 200, exported.text[:100]) + self.assertTrue(exported.content.startswith(backups.MAGIC)) + self.assertEqual(exported.headers["cache-control"], "no-store") + self.assertNotIn(b"test-integration-key-for-setup", exported.content) + restored = self.client.post("/admin/backups/restore", files={ + "file": ("restore.magent-backup", io.BytesIO(exported.content), "application/octet-stream"), + }, data={"passphrase": BACKUP_PASSPHRASE, "confirmation": "RESTORE"}) + self.assertEqual(restored.status_code, 202, restored.text) + self.assertTrue(restored.json()["restart_required"]) + self.assertEqual(db.get_setting("site_login_message"), "Welcome to this installation") + self.assertIsNotNone(self.client.get("/admin/backups").json()["pending_restore"]) + cancelled = self.client.delete("/admin/backups/restore") + self.assertEqual(cancelled.status_code, 200, cancelled.text) + self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"]) + + def test_cross_origin_bootstrap_and_authenticated_changes_are_rejected(self): + response = self.client.post("/setup/bootstrap", headers={"Origin": "https://unrelated.invalid"}, json={ + "setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD, + }) + self.assertEqual(response.status_code, 403) + self.assertFalse(db.has_admin_user()) + self.create_owner() + self.sign_in() + response = self.client.put("/setup/state", headers={"Origin": "https://unrelated.invalid"}, json={"step": "review"}) + self.assertEqual(response.status_code, 403) + response = self.client.post("/admin/backups/export", headers={"Origin": "https://unrelated.invalid"}, json={"passphrase": BACKUP_PASSPHRASE}) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.client.get("/setup/state").json()["step"], "apps") + + def test_setup_validation_errors_do_not_echo_password_or_token(self): + secret_password = "private-password-marker-" + "p" * 1024 + secret_token = "private-token-marker-" + "t" * 1024 + for payload, secret in ( + ({"setup_token": OPERATOR_TOKEN, "username": "owner", "password": secret_password}, secret_password), + ({"setup_token": secret_token, "username": "owner", "password": OWNER_PASSWORD}, secret_token), + ({"setup_token": OPERATOR_TOKEN, "password": OWNER_PASSWORD}, OWNER_PASSWORD), + ): + with self.subTest(secret=secret[:22]): + response = self.client.post("/setup/bootstrap", json=payload) + self.assertEqual(response.status_code, 422, response.text) + self.assertNotIn(secret, response.text) + self.assertNotIn(OPERATOR_TOKEN, response.text) + for error in response.json()["detail"]: + self.assertNotIn("input", error) + + def test_backup_validation_errors_do_not_echo_passphrases(self): + self.create_owner() + self.sign_in() + passphrase = "private-backup-passphrase-marker-" + "p" * 1024 + response = self.client.post("/admin/backups/export", json={"passphrase": passphrase}) + self.assertEqual(response.status_code, 422) + self.assertNotIn(passphrase, response.text) + response = self.client.post("/admin/backups/restore", files={"file": ("archive", b"data")}, data={ + "passphrase": passphrase, "confirmation": "RESTORE", + }) + self.assertEqual(response.status_code, 422) + self.assertNotIn(passphrase, response.text) + self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"]) + + def test_real_middleware_rejects_oversized_bootstrap_before_creation(self): + response = self.client.post("/setup/bootstrap", content=b"x" * (17 * 1024), headers={"Content-Type": "application/json"}) + self.assertEqual(response.status_code, 413, response.text) + self.assertFalse(db.has_admin_user()) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_installation_lifecycle.py b/backend/tests/test_installation_lifecycle.py new file mode 100644 index 0000000..908e14f --- /dev/null +++ b/backend/tests/test_installation_lifecycle.py @@ -0,0 +1,162 @@ +import asyncio +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +import httpx +from fastapi import FastAPI, File, Request, UploadFile + +from backend.app import db, main +from backend.app.config import settings +from backend.app.request_limits import InstallationBodyLimitMiddleware +from backend.app.services import setup + + +class InstallationLifecycleTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(temporary.cleanup) + patches = [ + patch.object(settings, "sqlite_path", str(Path(temporary.name) / "magent.db")), + patch.object(settings, "jwt_secret", "installation-lifecycle-secret-1234567890"), + patch.object(settings, "settings_encryption_key", None), + patch.object(settings, "admin_password", ""), + patch.object(settings, "setup_token", "operator-setup-token-at-least-32-characters"), + patch.object(main, "_background_started", False), + patch.object(main, "_background_tasks", []), + patch.object(main, "start_metrics"), + patch.object(main, "configure_logging"), + patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "true"}), + ] + for item in patches: + item.start() + self.addCleanup(item.stop) + + async def test_fresh_start_waits_for_admin_and_completion_then_starts_workers_once(self): + with patch.object(main, "_launch_background_task") as launch: + await main.startup() + self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": True}) + launch.assert_not_called() + setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345") + await main._start_background_tasks() + launch.assert_not_called() + setup.complete_setup() + await main.app.state.on_setup_complete() + await main.app.state.on_setup_complete() + self.assertEqual(launch.call_count, 9) + + async def test_upgraded_install_starts_normally_without_setup_token(self): + db.init_db() + db.create_user("owner", "existing-password-12345", role="admin") + settings.setup_token = "" + with patch.object(main, "_launch_background_task") as launch: + await main.startup() + self.assertFalse(setup.is_setup_required()) + self.assertEqual(launch.call_count, 9) + + async def test_disabled_workers_stay_disabled_after_setup(self): + setup.initialize_setup_state() + db.init_db() + setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345") + setup.complete_setup() + with patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), patch.object(main, "_launch_background_task") as launch: + await main._start_background_tasks() + launch.assert_not_called() + + async def test_bad_secret_stops_before_restore_or_database_initialization(self): + settings.jwt_secret = "short" + with patch.object(main, "apply_pending_restore") as restore, patch.object(main, "init_db") as initialize: + with self.assertRaisesRegex(RuntimeError, "JWT_SECRET"): + await main.startup() + restore.assert_not_called() + initialize.assert_not_called() + + async def test_restore_failure_stops_before_initialization_and_workers(self): + with patch.object(main, "apply_pending_restore", side_effect=RuntimeError("restore failed")), patch.object(main, "init_db") as initialize, patch.object(main, "_launch_background_task") as launch: + with self.assertRaisesRegex(RuntimeError, "restore failed"): + await main.startup() + initialize.assert_not_called() + launch.assert_not_called() + + async def test_startup_order_is_restore_then_setup_marker_then_schema(self): + calls = Mock() + calls.attach_mock(Mock(wraps=main.apply_pending_restore), "restore") + calls.attach_mock(Mock(wraps=main.initialize_setup_state), "setup") + calls.attach_mock(Mock(wraps=main.init_db), "schema") + with patch.object(main, "apply_pending_restore", calls.restore), patch.object(main, "initialize_setup_state", calls.setup), patch.object(main, "init_db", calls.schema): + await main.startup() + self.assertEqual([call[0] for call in calls.mock_calls], ["restore", "setup", "schema"]) + + def test_missing_token_does_not_allow_fresh_bootstrap(self): + setup.initialize_setup_state() + db.init_db() + settings.setup_token = "" + with self.assertRaisesRegex(RuntimeError, "SETUP_TOKEN"): + main._enforce_secure_startup_configuration() + + def test_destination_environment_does_not_add_an_admin_to_restored_accounts(self): + db.init_db() + db.create_user("restored-owner", "existing-password-12345", role="admin") + with patch.object(settings, "admin_username", "host-bootstrap"), patch.object(settings, "admin_password", "new-host-password-12345"): + db.init_db() + self.assertIsNone(db.get_user_by_username("host-bootstrap")) + + async def test_shutdown_cancels_workers_and_allows_next_start(self): + task = asyncio.create_task(asyncio.Event().wait()) + main._background_tasks.append(task) + main._background_started = True + await main.shutdown() + self.assertTrue(task.cancelled()) + self.assertEqual(main._background_tasks, []) + self.assertFalse(main._background_started) + + +class InstallationRequestLimitsTests(unittest.IsolatedAsyncioTestCase): + async def test_rejects_oversized_declared_body_before_parser(self): + app = FastAPI() + app.add_middleware(InstallationBodyLimitMiddleware) + + @app.post("/setup/bootstrap") + async def bootstrap(request: Request): + self.fail("Body must be rejected before the endpoint") + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/setup/bootstrap", content=b"{}", headers={"Content-Length": "999999"}) + self.assertEqual(response.status_code, 413) + + async def test_counts_chunks_with_missing_or_forged_content_length(self): + app = FastAPI() + app.add_middleware(InstallationBodyLimitMiddleware) + + @app.post("/setup/bootstrap") + async def bootstrap(request: Request): + return await request.json() + + async def chunks(): + yield b'{"token":"' + yield b"a" * 17000 + yield b'"}' + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + for headers in ({}, {"Content-Length": "1"}): + response = await client.post("/setup/bootstrap", content=chunks(), headers=headers) + self.assertEqual(response.status_code, 413) + + async def test_multipart_stream_limit_is_413_not_parser_500(self): + app = FastAPI() + app.add_middleware(InstallationBodyLimitMiddleware) + + @app.post("/admin/backups/restore") + async def restore(file: UploadFile = File(...)): + return {"size": file.size} + + async def chunks(): + yield b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="backup"\r\n\r\n' + yield b"a" * 2048 + yield b"\r\n--boundary--\r\n" + + with patch("backend.app.request_limits.RESTORE_BODY_LIMIT", 1024): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/admin/backups/restore", content=chunks(), headers={"Content-Type": "multipart/form-data; boundary=boundary"}) + self.assertEqual(response.status_code, 413) diff --git a/backend/tests/test_setup.py b/backend/tests/test_setup.py new file mode 100644 index 0000000..bbcb48f --- /dev/null +++ b/backend/tests/test_setup.py @@ -0,0 +1,267 @@ +from concurrent.futures import ThreadPoolExecutor +import os +import tempfile +from threading import Barrier +from types import SimpleNamespace +import unittest +from unittest.mock import AsyncMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from backend.app import db +from backend.app.config import settings +from backend.app.routers import setup as setup_router +from backend.app.security import create_access_token +from backend.app.services import setup + + +SETUP_TOKEN = "operator-setup-token-for-tests-only-1234567890" +ADMIN_PASSWORD = "A-long-admin-password!123" + + +class SetupTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self.temp.cleanup) + for field, value in { + "sqlite_path": os.path.join(self.temp.name, "test.db"), + "sqlite_journal_mode": "DELETE", + "admin_username": "environment-admin", + "admin_password": "", + "jwt_secret": "setup-test-jwt-secret-only-1234567890", + "settings_encryption_key": "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=", + }.items(): + context = patch.object(settings, field, value) + context.start() + self.addCleanup(context.stop) + context = patch.object(setup, "settings", SimpleNamespace(setup_token=SETUP_TOKEN)) + context.start() + self.addCleanup(context.stop) + setup.initialize_setup_state() + db.init_db() + self.app = FastAPI() + self.app.include_router(setup_router.router) + self.client = TestClient(self.app) + self.addCleanup(self.client.close) + + def bootstrap(self, **changes): + return self.client.post("/setup/bootstrap", json={ + "setup_token": SETUP_TOKEN, + "username": "first-admin", + "password": ADMIN_PASSWORD, + **changes, + }) + + def admin_headers(self): + return {"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}"} + + def test_fresh_install_requires_setup_and_exposes_no_configuration(self): + response = self.client.get("/setup/status") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"setup_required": True, "needs_admin": True}) + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertEqual(self.client.get("/setup/state").status_code, 401) + + def test_existing_install_migrates_as_completed_without_reopening_bootstrap(self): + with db._connect() as conn: + conn.execute("DROP TABLE installation_setup") + setup.initialize_setup_state() + self.assertEqual(setup.get_public_setup_status(), {"setup_required": False, "needs_admin": False}) + self.assertIsNotNone(setup.get_setup_state()["completed_at"]) + self.assertEqual(self.bootstrap().status_code, 409) + + def test_missing_marker_fails_closed(self): + with db._connect() as conn: + conn.execute("DROP TABLE installation_setup") + self.assertFalse(setup.is_setup_required()) + self.assertEqual(self.bootstrap().status_code, 409) + + def test_marker_survives_restart_before_schema_initialization(self): + new_path = os.path.join(self.temp.name, "interrupted.db") + with patch.object(settings, "sqlite_path", new_path): + setup.initialize_setup_state() + setup.initialize_setup_state() + db.init_db() + self.assertTrue(setup.is_setup_required()) + + def test_empty_precreated_database_is_a_fresh_install(self): + new_path = os.path.join(self.temp.name, "empty.db") + with open(new_path, "wb"): + pass + with patch.object(settings, "sqlite_path", new_path): + setup.initialize_setup_state() + db.init_db() + self.assertTrue(setup.is_setup_required()) + + def test_environment_admin_uses_wizard_without_public_bootstrap(self): + with patch.object(settings, "admin_password", ADMIN_PASSWORD): + db.ensure_admin_user() + self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": False}) + self.assertEqual(self.bootstrap().status_code, 409) + + def test_valid_token_creates_local_admin_once_and_uses_password_hash(self): + response = self.bootstrap() + self.assertEqual(response.status_code, 201, response.text) + self.assertEqual(response.json(), {"status": "created", "username": "first-admin"}) + user = db.verify_user_password("first-admin", ADMIN_PASSWORD) + self.assertIsNotNone(user) + self.assertEqual(user["role"], "admin") + self.assertEqual(user["auth_provider"], "local") + self.assertNotEqual(user["password_hash"], ADMIN_PASSWORD) + self.assertEqual(setup.get_setup_state()["step"], "apps") + self.assertEqual(self.bootstrap(username="second-admin").status_code, 409) + self.assertEqual(len(db.get_all_users()), 1) + + def test_invalid_and_missing_operator_tokens_never_create_admin(self): + self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403) + with patch.object(setup.settings, "setup_token", ""): + self.assertEqual(self.bootstrap().status_code, 403) + with patch.object(setup.settings, "setup_token", "too-short"): + self.assertEqual(self.bootstrap(setup_token="too-short").status_code, 403) + self.assertFalse(db.has_admin_user()) + + def test_non_ascii_token_fails_cleanly(self): + self.assertEqual(self.bootstrap(setup_token="invalid-\N{SNOWMAN}").status_code, 403) + self.assertFalse(db.has_admin_user()) + + def test_example_and_repeated_character_setup_tokens_are_rejected(self): + for token in ( + "replace-with-a-separate-random-setup-token", + "CHANGE_ME_before_starting_this_installation", + "your-setup-token-goes-here-at-least-32-characters", + "a" * 64, + "0" * 64, + " " * 64, + ): + with self.subTest(token=token), patch.object(setup.settings, "setup_token", token): + self.assertFalse(setup.setup_token_configured()) + with self.assertRaises(setup.InvalidSetupTokenError): + setup.bootstrap_administrator(token, "owner", ADMIN_PASSWORD) + self.assertFalse(db.has_admin_user()) + self.assertTrue(setup.setup_token_configured()) + + def test_password_policy_and_username_validation(self): + for username in (" ", "admin user", "admin\x7f", "admin\nname"): + with self.subTest(username=repr(username)): + self.assertEqual(self.bootstrap(username=username).status_code, 400) + self.assertEqual(self.bootstrap(password="short").status_code, 400) + self.assertFalse(db.has_admin_user()) + + def test_oversized_fields_and_unexpected_privileges_are_rejected(self): + self.assertEqual(self.bootstrap(password="x" * 1025).status_code, 422) + self.assertEqual(self.bootstrap(username="x" * 101).status_code, 422) + self.assertEqual(self.bootstrap(role="admin").status_code, 422) + self.assertFalse(db.has_admin_user()) + + def test_existing_normalized_username_is_not_replaced(self): + db.create_user("Taken", ADMIN_PASSWORD) + self.assertEqual(self.bootstrap(username="taken").status_code, 409) + self.assertFalse(db.has_admin_user()) + self.assertEqual(len(db.get_all_users()), 1) + + def test_bootstrap_attempts_are_persistently_limited(self): + for _ in range(setup.BOOTSTRAP_IP_ATTEMPTS): + self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403) + setup.initialize_setup_state() + response = self.bootstrap() + self.assertEqual(response.status_code, 429) + self.assertGreater(int(response.headers["retry-after"]), 0) + self.assertFalse(db.has_admin_user()) + with db._connect() as conn: + keys = [row[0] for row in conn.execute("SELECT key_hash FROM installation_setup_attempts")] + self.assertNotIn("testclient", keys) + + def test_rate_limit_global_cap_and_expiry(self): + with patch.object(setup, "time", return_value=1000): + for number in range(setup.BOOTSTRAP_GLOBAL_ATTEMPTS): + self.assertIsNone(setup.consume_bootstrap_attempt(f"192.0.2.{number}")) + self.assertEqual(setup.consume_bootstrap_attempt("198.51.100.1"), 900) + with patch.object(setup, "time", return_value=1901): + self.assertIsNone(setup.consume_bootstrap_attempt("198.51.100.1")) + + def test_concurrent_attempts_cannot_bypass_rate_limit(self): + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(lambda _: setup.consume_bootstrap_attempt("192.0.2.1"), range(12))) + self.assertEqual(results.count(None), setup.BOOTSTRAP_IP_ATTEMPTS) + + def test_concurrent_bootstraps_create_only_one_admin(self): + barrier = Barrier(4) + + def synchronized_hash(_): + barrier.wait(timeout=10) + return "test-only-precomputed-hash" + + def create(number): + try: + setup.bootstrap_administrator(SETUP_TOKEN, f"admin-{number}", ADMIN_PASSWORD) + return True + except setup.SetupUnavailableError: + return False + + with patch.object(setup, "hash_password", side_effect=synchronized_hash): + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(create, range(4))) + self.assertEqual(results.count(True), 1) + self.assertEqual(len(db.get_all_users()), 1) + + def test_state_mutations_require_admin_and_progress_resumes(self): + self.assertEqual(self.bootstrap().status_code, 201) + db.create_user("viewer", ADMIN_PASSWORD) + user_headers = {"Authorization": f"Bearer {create_access_token('viewer', 'user')}"} + for path, method, kwargs in ( + ("/setup/state", "get", {}), + ("/setup/state", "put", {"json": {"step": "review"}}), + ("/setup/complete", "post", {}), + ): + with self.subTest(path=path, method=method): + call = getattr(self.client, method) + self.assertEqual(call(path, **kwargs).status_code, 401) + self.assertEqual(call(path, headers=user_headers, **kwargs).status_code, 403) + response = self.client.put("/setup/state", json={"step": "preferences"}, headers=self.admin_headers()) + self.assertEqual(response.status_code, 200) + setup.initialize_setup_state() + db.init_db() + self.assertEqual(setup.get_setup_state()["step"], "preferences") + self.assertTrue(setup.is_setup_required()) + self.assertEqual(self.client.put( + "/setup/state", json={"step": "invalid"}, headers=self.admin_headers() + ).status_code, 422) + + def test_completion_invokes_worker_callback_and_cannot_reopen_bootstrap(self): + self.assertEqual(self.bootstrap().status_code, 201) + callback = AsyncMock() + self.app.state.on_setup_complete = callback + response = self.client.post("/setup/complete", headers=self.admin_headers()) + self.assertEqual(response.status_code, 200, response.text) + self.assertTrue(response.json()["completed"]) + self.assertIsNotNone(response.json()["completed_at"]) + callback.assert_awaited_once() + self.assertFalse(setup.is_setup_required()) + # A retry can restart an idempotent callback if the first response was + # interrupted, while keeping the original completion timestamp. + retry = self.client.post("/setup/complete", headers=self.admin_headers()) + self.assertEqual(retry.json(), response.json()) + self.assertEqual(callback.await_count, 2) + self.client.put("/setup/state", json={"step": "administrator"}, headers=self.admin_headers()) + with db._connect() as conn: + conn.execute("DELETE FROM users") + self.assertEqual(self.bootstrap().status_code, 409) + self.assertEqual(setup.get_setup_state()["step"], "review") + + def test_completion_requires_an_administrator(self): + with self.assertRaises(setup.SetupUnavailableError): + setup.complete_setup() + self.assertTrue(setup.is_setup_required()) + + def test_sync_callback_is_supported(self): + self.assertEqual(self.bootstrap().status_code, 201) + called = [] + self.app.state.on_setup_complete = lambda: called.append(True) + response = self.client.post("/setup/complete", headers=self.admin_headers()) + self.assertEqual(response.status_code, 200) + self.assertEqual(called, [True]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/installation-and-recovery.md b/docs/installation-and-recovery.md new file mode 100644 index 0000000..bba81f4 --- /dev/null +++ b/docs/installation-and-recovery.md @@ -0,0 +1,63 @@ +# Installation, backup and recovery + +## Fresh installation + +Start with `.env.example`. Generate independent random values for `JWT_SECRET` and `SETUP_TOKEN` (at least 32 characters each), plus a Fernet `SETTINGS_ENCRYPTION_KEY`. Never deploy the example placeholders. Keep the environment file private. + +```bash +python -c "import secrets; print(secrets.token_urlsafe(48))" +python -c "import secrets; print(secrets.token_urlsafe(48))" +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +The first two commands produce the JWT secret and setup token respectively. The third requires the backend dependencies. Alternatively generate the Fernet key using Python's standard library: `python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"`. + +Set the correct browser-facing `CORS_ALLOW_ORIGIN`, `MAGENT_APPLICATION_URL`, cookie HTTPS settings and host paths before starting. These deployment settings are deliberately not editable through public setup. Changing the public application URL in the wizard does not change CORS or reverse-proxy configuration. + +After `docker compose up -d --build`, visit the frontend. A new database redirects to `/setup`: + +1. Enter `SETUP_TOKEN` and create a local administrator with a unique password of at least 12 characters. Alternatively, set `ADMIN_USERNAME` and `ADMIN_PASSWORD` in the environment before the first start, then sign in with that account. +2. Expand each app you use: Jellyfin, Seerr/Jellyseerr, Sonarr, Radarr, Prowlarr, qBittorrent, Bazarr and Jellystat. Enter its internal address and credentials, then **Save & test**. For Sonarr/Radarr, a successful check loads quality profiles and root folders. +3. Set site access, request refresh/retention and optional SMTP preferences. Invite signup remains invite-only. +4. Review and finish. Magent starts its configured background jobs, unless `BACKGROUND_TASKS_ENABLED=false`. + +Use server-reachable addresses: `localhost` in a container refers to that container. Optional apps can be skipped. Each successful save persists; closing the tab leaves setup resumable. Unsaved form fields are not retained. Remove `SETUP_TOKEN` after finishing. Bootstrap is permanently disabled after setup, and cannot replace an existing administrator. Administrators can revisit the wizard from Settings without resetting the installation. + +Upgrades with an existing users table are marked configured automatically. Setup status reveals only whether setup is needed and whether the first administrator is missing. Configuration and wizard progress require administrator authentication. First-admin creation uses a constant-time token comparison, persistent rate limits and a database transaction to prevent concurrent claims. + +## Create a backup + +Open **Settings → Advanced tools → Backup & restore** (`/admin/backups`). Choose a unique backup passphrase of 12–1024 characters, confirm it, optionally include the filesystem artwork cache, and download the `.magent-backup` file. + +Every backup includes: + +- A consistent SQLite snapshot: users, password hashes, invite records, requests, issues, settings, saved statistics, subscriptions and database-backed caches. +- Portable runtime configuration, including environment-provided app credentials. Secrets are decrypted only inside the private export staging area and encrypted archive; they are re-encrypted with the destination installation key when restoring. +- Custom branding (`data/branding/logo.png` and `favicon.ico`). + +The optional cache adds supported TMDB artwork from `data/artwork/tmdb`. In-memory caches are rebuilt, not backed up. Media files, the connected apps' databases, log files, `.env`, TLS private keys, host paths, signing/encryption keys and deployment/network controls are not included. Keep a separate secure record of the deployment configuration and backup passphrase. + +Backups use authenticated AES-256-GCM encryption with a per-backup salt and scrypt-derived key. The passphrase is never stored by Magent and cannot be recovered. Keep backups and their passphrases separately, off the Magent host. Treat backups as sensitive even though encrypted. + +Current limits: **32 MiB encrypted archive**, **128 MiB expanded data**, and **20,000 entries**. These bound memory and disk use; including a large artwork cache can exceed them. Retry without artwork if necessary. For larger installations, use a separate operator-managed offline volume/database backup; this UI does not silently omit oversized data. Automatic scheduled backups and media-server backups are not part of this feature. + +The frontend and backend accept up to 34 MiB for the whole multipart request, including the 32 MiB file. Configure any external reverse proxy's upload limit accordingly (for example `client_max_body_size 34m` in nginx); otherwise it may reject valid files before they reach Magent. + +## Restore safely + +1. Make a fresh backup of the destination. Stop external writes/other backend processes sharing its SQLite file. The supplied deployment uses one backend worker; do not run restore against a multi-worker/shared-database deployment. +2. Sign in as an administrator, select a `.magent-backup`, enter its passphrase and type `RESTORE`. A fresh replacement installation must first create its temporary administrator through `/setup`; then use the **Restore it here** link before connecting apps. +3. Upload and stage the restore. Magent checks authentication, encrypted integrity, archive paths and sizes, checksums, SQLite integrity, schema compatibility and an active restored administrator. Live data is unchanged at this point. A pending restore can be cancelled from the same page. +4. Restart the application using your normal deployment process, for example `docker compose restart magent`. Beta: `docker compose -p magent-beta -f docker-compose.beta.yml restart magent`. The UI never restarts a server automatically. +5. On startup, before schema initialization or workers, Magent creates a private rollback copy, replaces the database/selected assets and records the result. Failed or interrupted replacement is rolled back using a durable journal. Review the backend logs if startup stops. +6. Sign in with an account from the restored backup, verify Settings/service checks, requests, issues and invite policy, then create a new backup. Old sessions and password-reset tokens are invalidated. Existing invite records and links are retained, with their original expiry and usage state. + +Restore **replaces** the destination database; it does not merge changes made after the backup. After staging, pause normal usage until the restart so new writes are not mistaken for restored data. Do not change the destination encryption key between staging and restart. Restoring earlier invite state can also restore its remaining uses: review active invitations after recovery. + +Use the same Magent version for restore, then upgrade normally. Portable settings follow the backup, but destination host identity, JWT/encryption keys, local paths, TLS/cookie/proxy controls and ports remain destination-owned. Review public URLs and service addresses when moving hosts. Without the optional artwork cache, database artwork flags are reset and missing artwork can be fetched again; the existing destination artwork directory is left in place. + +## Recovery files + +The `backups/` directory beside the configured SQLite database contains private staging, lock/journal/status files and `rollback-/` copies. It is not a library of exported encrypted downloads. Rollback copies contain the old database and assets; protect the data volume with host encryption and restrictive access. Magent does not automatically delete rollback copies after success. After validating the restored installation and saving a separate backup, an operator may archive or remove the specific old rollback directories during maintenance. Never remove an active `pending/` directory or `restore-journal.json` during a restore. + +Insufficient disk space or invalid input stops the operation rather than partially accepting a backup. Allow room for the upload, extracted staging database/assets, live data and a rollback copy. The supplied Docker image's unprivileged user must have write access to the persistent data volume. Do not delete the data volume or replace `.env` to retry setup or recovery. diff --git a/frontend/app/MyRequests.tsx b/frontend/app/MyRequests.tsx index 22bd7d2..d337708 100644 --- a/frontend/app/MyRequests.tsx +++ b/frontend/app/MyRequests.tsx @@ -1,16 +1,16 @@ "use client"; -import PageHeading from "./ui/PageHeading"; - import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth"; +import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth"; import { normalizeRecentResults, normalizeSearchResults, type RecentRequest, type RequestSearchResult, } from "./lib/request-results"; +import { useEffectiveRole } from "./lib/viewMode"; +import PageHeading from "./ui/PageHeading"; import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter"; export default function HomePage() { @@ -22,6 +22,8 @@ export default function HomePage() { const [searchResults, setSearchResults] = useState([]); const [searchError, setSearchError] = useState(null); const [role, setRole] = useState(null); + const effectiveRole = useEffectiveRole(role); + const isAdmin = effectiveRole === "admin"; const [recentDays, setRecentDays] = useState(90); const [recentStage, setRecentStage] = useState("all"); const [authReady, setAuthReady] = useState(false); @@ -62,7 +64,7 @@ export default function HomePage() { const userRole = me?.role ?? null; setRole(userRole); setAuthReady(true); - const take = userRole === "admin" ? 50 : 6; + const take = isAdmin ? 50 : 6; const params = new URLSearchParams({ take: String(take), days: String(recentDays), @@ -96,7 +98,7 @@ export default function HomePage() { return () => { cancelled = true; }; - }, [recentDays, recentStage, router]); + }, [isAdmin, recentDays, recentStage, router]); useEffect(() => { if (!authReady) { @@ -305,7 +307,7 @@ export default function HomePage() {
Request activity -

{role === "admin" ? "Recent requests" : "My recent requests"}

+

{isAdmin ? "Recent requests" : "My recent requests"}

{authReady && (
@@ -337,7 +339,7 @@ export default function HomePage() { Try a wider period or a different stage.
) : ( - recent.map((item) => ( + (isAdmin ? recent : recent.slice(0, 6)).map((item) => ( + )} + {data && ( + <> +
+

What is saved

+

+ Every backup includes Magent settings, app connection credentials, branding, and the complete database + with accounts, invites, requests, and cached records. You can also include downloaded artwork caches. +

+

+ Connected apps and media files need their own backups. App credentials configured through the + environment are included, but the deployment environment file, host paths, and signing or encryption + keys are not. +

+ {data.last_restore && ( +

+ {data.last_restore.status === "rolled_back" + ? "Last restore was rolled back" + : "Last restore completed"} + : {dateLabel(data.last_restore.restored_at)}. + {data.last_restore.status === "rolled_back" && + ` ${data.last_restore.message || "The previous data was recovered automatically."}`} +

+ )} +
+ + {data.pending_restore && ( +
+

Restore ready — restart required

+

+ Backup from {dateLabel(data.pending_restore.created_at)} + {data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "} + {data.pending_restore.include_cache ? "included" : "not included"}. +

+

+ Restart the Magent container or service to apply this backup. Changes made since the backup was + created will be replaced. Afterwards, sign in again with an administrator account from the restored + backup. +

+ +
+ )} + +
+
+

Create a backup

+

Download an encrypted backup file. Keep the file and its passphrase in a safe place.

+

+ Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "} + {sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave + artwork caches unchecked. +

+
+
+ Backup options + +

+ Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched + again. Database caches are always included. +

+ +

+ Use at least 12 characters. This passphrase is separate from your login password. A lost + passphrase cannot be recovered. +

+ + +
+
+
+ +
+

Restore a backup

+

+ Restoring replaces Magent's settings and database, including users and invites. Download a + current backup first if you want to keep these changes. +

+
+
+ Choose and confirm a backup + +

+ Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}. +

+ + +

+ The backup is checked before being queued. It only takes effect when you restart Magent; you can + cancel before then. You will need to sign in using an account from the backup. +

+ +
+
+
+
+ + )} +
+ + ); +} diff --git a/frontend/app/admin/configNavigation.ts b/frontend/app/admin/configNavigation.ts index 9b1798e..668941f 100644 --- a/frontend/app/admin/configNavigation.ts +++ b/frontend/app/admin/configNavigation.ts @@ -82,6 +82,12 @@ export const CONFIG_GROUPS: ConfigGroup[] = [ advanced: true, items: [ { href: "/admin/general", label: "Hosting & proxy", description: "Public addresses and deployment options" }, + { href: "/setup", label: "Setup wizard", description: "Guided app connections and installation preferences" }, + { + href: "/admin/backups", + label: "Backup & restore", + description: "Encrypted settings, database and cache backups", + }, { href: "/admin/diagnostics", label: "System health", description: "Service checks and diagnostics" }, { href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" }, { href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" }, diff --git a/frontend/app/insights/page.tsx b/frontend/app/insights/page.tsx index 878dbe9..34307fb 100644 --- a/frontend/app/insights/page.tsx +++ b/frontend/app/insights/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { authFetch, getApiBase } from "../lib/auth"; +import { useEffectiveRole } from "../lib/viewMode"; import PageHeading from "../ui/PageHeading"; import { type Stats, @@ -20,6 +21,7 @@ export default function InsightsPage() { const router = useRouter(); const [days, setDays] = useState(30); const [data, setData] = useState(null); + const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin"; const [busy, setBusy] = useState(true); const [error, setError] = useState(""); const [revision, setRevision] = useState(0); @@ -126,11 +128,11 @@ export default function InsightsPage() {

Your viewing story starts here

- {data.is_admin + {isAdmin ? "Connect your Jellystat instance to bring personal viewing stats into Magent." : "Viewing stats will appear here once your administrator connects Jellystat."}

- {data.is_admin && ( + {isAdmin && ( Connect Jellystat diff --git a/frontend/app/insights/reports/page.tsx b/frontend/app/insights/reports/page.tsx index 04ed73f..a485fc3 100644 --- a/frontend/app/insights/reports/page.tsx +++ b/frontend/app/insights/reports/page.tsx @@ -5,6 +5,7 @@ import EmailReportControl from "./EmailReportControl"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { authFetch, getApiBase } from "../../lib/auth"; +import { useEffectiveRole } from "../../lib/viewMode"; import PageHeading from "../../ui/PageHeading"; import { type Stats, @@ -67,6 +68,7 @@ export default function MonthlyReportsPage() { const [monthReady, setMonthReady] = useState(false); const [months, setMonths] = useState([]); const [data, setData] = useState(null); + const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin"; const [busy, setBusy] = useState(true); const [error, setError] = useState(""); const [revision, setRevision] = useState(0); @@ -267,11 +269,11 @@ export default function MonthlyReportsPage() {

Your monthly story starts here

- {data.is_admin + {isAdmin ? "Connect Jellystat to bring your monthly viewing reports into Magent." : "Monthly reports will appear once your administrator connects Jellystat."}

- {data.is_admin && ( + {isAdmin && ( Connect Jellystat @@ -285,7 +287,7 @@ export default function MonthlyReportsPage() { Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.

- {data.is_admin && ( + {isAdmin && ( Review user identities diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f542b6b..ba3508f 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -9,6 +9,8 @@ import type { ReactNode } from "react"; import BrandingFavicon from "./ui/BrandingFavicon"; import FeatureGate from "./ui/FeatureGate"; import ApplicationChrome from "./ui/ApplicationChrome"; +import SetupGate from "./ui/SetupGate"; +import AdminViewGate from "./ui/AdminViewGate"; export const metadata = { title: "Magent", @@ -26,8 +28,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
- - {children} + + + + {children} + +
diff --git a/frontend/app/lib/user-view-policy.test.ts b/frontend/app/lib/user-view-policy.test.ts new file mode 100644 index 0000000..0ce4632 --- /dev/null +++ b/frontend/app/lib/user-view-policy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { getEffectiveRole, isAdminPage } from "./user-view-policy"; + +describe("user view preview policy", () => { + it("downgrades only the displayed administrator role during preview", () => { + expect(getEffectiveRole("admin", true)).toBe("user"); + expect(getEffectiveRole("admin", false)).toBe("admin"); + for (const role of ["user", null, undefined]) { + expect(getEffectiveRole(role, true)).toBe(role); + expect(getEffectiveRole(role, false)).toBe(role); + } + }); + it("covers configuration, nested admin pages, user management and setup", () => { + for (const path of [ + "/admin", + "/admin/", + "/admin/backups", + "/admin/recaps", + "/users", + "/users/42", + "/setup", + "/admin?section=site", + "/%61dmin/diagnostics", + ]) { + expect(isAdminPage(path), path).toBe(true); + } + }); + it("does not restrict normal member pages or similarly named paths", () => { + for (const path of [ + "/", + "/profile", + "/profile/invites", + "/portal/issues", + "/requests/3580", + "/insights", + "/administrator", + "/users-guide", + ]) { + expect(isAdminPage(path), path).toBe(false); + } + }); + it("keeps public first-install setup separate from admin authentication", () => { + expect(isAdminPage("/setup", false)).toBe(false); + expect(isAdminPage("/admin/backups", false)).toBe(true); + }); +}); diff --git a/frontend/app/lib/user-view-policy.ts b/frontend/app/lib/user-view-policy.ts new file mode 100644 index 0000000..c1fa42c --- /dev/null +++ b/frontend/app/lib/user-view-policy.ts @@ -0,0 +1,16 @@ +// Preview never promotes a user or changes server-side account permissions. +export function getEffectiveRole(role: string | null | undefined, preview: boolean) { + return preview && role === "admin" ? "user" : role; +} + +export function isAdminPage(pathname: string, includeSetup = true): boolean { + let path = pathname.split(/[?#]/, 1)[0]; + try { + path = decodeURIComponent(path); + } catch { + // Let the router handle malformed URLs; never infer a more privileged role. + } + path = path.replace(/\/{2,}/g, "/"); + const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"]; + return roots.some((root) => path === root || path.startsWith(`${root}/`)); +} diff --git a/frontend/app/lib/viewMode.ts b/frontend/app/lib/viewMode.ts index b2f29f9..d10690b 100644 --- a/frontend/app/lib/viewMode.ts +++ b/frontend/app/lib/viewMode.ts @@ -1,13 +1,19 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useSyncExternalStore } from "react"; +import { getEffectiveRole } from "./user-view-policy"; const USER_VIEW_STORAGE_KEY = "magent_user_view_preview"; const USER_VIEW_EVENT = "magent:user-view-change"; +let fallbackPreview = false; const readUserViewPreview = () => { if (typeof window === "undefined") return false; - return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1"; + try { + return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1"; + } catch { + return fallbackPreview; + } }; const applyDocumentMode = (enabled: boolean) => { @@ -17,32 +23,46 @@ const applyDocumentMode = (enabled: boolean) => { export const setUserViewPreview = (enabled: boolean) => { if (typeof window === "undefined") return; - if (enabled) { - window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1"); - } else { - window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY); + fallbackPreview = enabled; + try { + if (enabled) { + window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1"); + } else { + window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY); + } + } catch { + // Preview still works for this document when browser storage is unavailable. } applyDocumentMode(enabled); window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } })); }; -export const useUserViewPreview = () => { - const [enabled, setEnabled] = useState(false); +const subscribe = (notify: () => void) => { + window.addEventListener(USER_VIEW_EVENT, notify); + window.addEventListener("storage", notify); + return () => { + window.removeEventListener(USER_VIEW_EVENT, notify); + window.removeEventListener("storage", notify); + }; +}; + +// Unknown during server rendering/initial hydration: admin pages must not mount +// and fetch privileged data before the saved per-tab preview mode is known. +const serverSnapshot = (): boolean | null => null; + +export const useUserViewState = () => { + const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot); useEffect(() => { - const sync = () => { - const nextValue = readUserViewPreview(); - applyDocumentMode(nextValue); - setEnabled(nextValue); - }; - sync(); - window.addEventListener(USER_VIEW_EVENT, sync); - window.addEventListener("storage", sync); - return () => { - window.removeEventListener(USER_VIEW_EVENT, sync); - window.removeEventListener("storage", sync); - }; - }, []); + if (value !== null) applyDocumentMode(value); + }, [value]); - return enabled; + return { enabled: value === true, ready: value !== null }; +}; + +export const useUserViewPreview = () => useUserViewState().enabled; + +export const useEffectiveRole = (role?: string | null) => { + const { enabled, ready } = useUserViewState(); + return getEffectiveRole(role, !ready || enabled); }; diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 562beb6..d3da499 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -100,6 +100,8 @@ export default function LoginPage() { "/profile#newsletters", "/admin/recaps", "/admin/newsletters", + "/setup", + "/admin/backups", ].includes(next) || /^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) || /^\/issues\/confirm\/\d+$/.test(next); diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index 4482eaa..78b01d0 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -1,12 +1,12 @@ "use client"; -import ResolutionChoice from "../ui/ResolutionChoice"; - -import PageHeading from "../ui/PageHeading"; -import IssueFlowStep from "./IssueFlowStep"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth"; +import { useEffectiveRole } from "../lib/viewMode"; +import PageHeading from "../ui/PageHeading"; +import ResolutionChoice from "../ui/ResolutionChoice"; +import IssueFlowStep from "./IssueFlowStep"; type PortalPermissions = { can_edit?: boolean; @@ -536,7 +536,16 @@ export default function PortalClient({ workspace }: PortalClientProps) { const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState([]); const [selectedEpisodeIds, setSelectedEpisodeIds] = useState([]); - const isAdmin = me?.role === "admin"; + const effectiveRole = useEffectiveRole(me?.role); + const isAdmin = effectiveRole === "admin"; + const isOwner = (item: PortalItem) => me?.username === item.created_by_username; + const canConfirmResolution = (item: PortalItem) => + Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item))); + const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem))); + const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate); + const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete); + const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal); + const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added"); const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0); const workspaceLabel = workspace === "request" ? "request" : "issue"; const workspaceLabelPlural = workspace === "request" ? "requests" : "issues"; @@ -610,6 +619,16 @@ export default function PortalClient({ workspace }: PortalClientProps) { }); const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review"; + useEffect(() => { + if (isAdmin) return; + setDeleteConfirming(false); + if (commentInternal) { + // Do not turn an unfinished internal note into a public comment when preview changes. + setCommentText(""); + setCommentInternal(false); + } + }, [isAdmin, commentInternal]); + useEffect(() => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); @@ -1335,7 +1354,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { const saveItem = async (event: React.FormEvent) => { event.preventDefault(); - if (!selectedItem) return; + if (!selectedItem || !canEditSelected) return; setSaving(true); setError(null); setStatus(null); @@ -1347,7 +1366,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { year: editYear.trim() ? toPositiveInt(editYear) : null, external_ref: editExternalRef || null, }; - if (selectedItem.permissions?.can_moderate) { + if (canModerateSelected) { if (selectedItem.kind === "request") { payload.request_status = editRequestStatus; payload.media_status = editMediaStatus; @@ -1390,6 +1409,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { const postComment = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedItem) return; + if (commentInternal && !isAdmin) return; if (!commentText.trim()) { setError("Comment message is required."); return; @@ -1404,7 +1424,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: commentText, - is_internal: commentInternal, + is_internal: isAdmin && commentInternal, }), }); if (!response.ok) { @@ -1429,7 +1449,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { }; const respondToResolution = async (resolved: boolean) => { - if (!selectedItem) return; + if (!selectedItem || !canConfirmResolution(selectedItem)) return; setRespondingResolution(true); setError(null); setStatus(null); @@ -1465,7 +1485,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { }; const deleteIssue = async () => { - if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return; + if (selectedItem?.kind !== "issue" || !canDeleteSelected) return; setDeleting(true); setError(null); setStatus(null); @@ -1550,7 +1570,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { .filter( (item) => item.status === "awaiting_confirmation" && - item.permissions?.can_confirm_resolution && + canConfirmResolution(item) && item.created_by_username === me?.username, ) .map((item) => ( @@ -2416,7 +2436,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
- {selectedItem?.permissions?.can_delete ? ( + {canDeleteSelected ? (
- {selectedItem.kind === "issue" && deleteConfirming ? ( + {selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
Permanent deletion @@ -2529,7 +2549,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { setEditTitle(event.target.value)} - disabled={!selectedItem.permissions?.can_edit} + disabled={!canEditSelected} /> {selectedItem.kind === "request" ? ( @@ -2548,7 +2568,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { setEditExternalRef(event.target.value)} - disabled={!selectedItem.permissions?.can_edit} + disabled={!canEditSelected} /> - {selectedItem.permissions?.can_moderate && ( + {canModerateSelected && ( <> {selectedItem.kind === "request" ? ( <> @@ -2652,7 +2672,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { )}
-
@@ -2665,13 +2685,13 @@ export default function PortalClient({ workspace }: PortalClientProps) { Recorded work

Issue activity

- {activity.length} events + {visibleActivity.length} events - {activity.length === 0 ? ( + {visibleActivity.length === 0 ? (
No issue activity has been recorded yet.
) : (
    - {activity.map((entry) => ( + {visibleActivity.map((entry) => (