feat: add backup recovery, setup wizard and user-view guards
This commit is contained in:
+6
-1
@@ -11,7 +11,12 @@ LOG_FORMAT=text
|
|||||||
JWT_SECRET=replace-with-at-least-32-random-characters
|
JWT_SECRET=replace-with-at-least-32-random-characters
|
||||||
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
|
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
|
||||||
ADMIN_USERNAME=admin
|
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_SECURE=false
|
||||||
AUTH_COOKIE_SAMESITE=strict
|
AUTH_COOKIE_SAMESITE=strict
|
||||||
|
|||||||
@@ -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).
|
- 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).
|
- 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.
|
- 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)
|
## Quick start (Docker - primary)
|
||||||
|
|
||||||
@@ -42,10 +44,17 @@ Then open:
|
|||||||
|
|
||||||
### Docker setup steps
|
### Docker setup steps
|
||||||
|
|
||||||
1) Create `.env` with your service URLs and API keys.
|
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) Run `docker compose up --build`.
|
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) Log in at http://localhost:3000.
|
3) Run `docker compose up --build`.
|
||||||
4) Visit Settings to confirm service health.
|
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)
|
### 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.
|
- `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.
|
- `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.
|
- 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.
|
- 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.
|
- `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.
|
- 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
|
## History endpoints
|
||||||
|
|
||||||
@@ -207,7 +218,7 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d
|
|||||||
|
|
||||||
### Login fails
|
### 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).
|
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
|
||||||
|
|
||||||
### Services show as down
|
### Services show as down
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
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(
|
auth_cookie_name: str = Field(
|
||||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -929,6 +929,10 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str
|
|||||||
def ensure_admin_user() -> None:
|
def ensure_admin_user() -> None:
|
||||||
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
||||||
return
|
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)
|
existing = get_user_by_username(settings.admin_username)
|
||||||
if existing:
|
if existing:
|
||||||
return
|
return
|
||||||
|
|||||||
+62
-6
@@ -6,6 +6,8 @@ import uuid
|
|||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
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.identities import router as identities_router
|
||||||
from .routers.recaps import router as recaps_router
|
from .routers.recaps import router as recaps_router
|
||||||
from .routers.newsletters import router as newsletters_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.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .services.issue_resolution import run_issue_confirmation_loop
|
from .services.issue_resolution import run_issue_confirmation_loop
|
||||||
from .services.email_recaps import run_email_recap_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 .runtime import get_runtime_settings
|
||||||
from .metrics import record_api, start_metrics
|
from .metrics import record_api, start_metrics
|
||||||
|
from .request_limits import InstallationBodyLimitMiddleware
|
||||||
from .secret_storage import validate_secret_storage_configuration
|
from .secret_storage import validate_secret_storage_configuration
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_background_tasks: list[asyncio.Task[None]] = []
|
_background_tasks: list[asyncio.Task[None]] = []
|
||||||
|
_background_started = False
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
@@ -71,6 +79,23 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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")
|
@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"
|
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
|
||||||
)
|
)
|
||||||
admin_password = str(settings.admin_password or "")
|
admin_password = str(settings.admin_password or "")
|
||||||
if not admin_password or admin_password == "adminadmin":
|
if admin_password == "adminadmin":
|
||||||
logger.warning(
|
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):
|
if bool(settings.api_docs_enabled):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -244,8 +269,11 @@ def _enforce_secure_startup_configuration() -> None:
|
|||||||
_enforce_secret_configuration()
|
_enforce_secret_configuration()
|
||||||
admin_password = str(settings.admin_password or "")
|
admin_password = str(settings.admin_password or "")
|
||||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||||
|
if is_setup_required() and setup_token_configured():
|
||||||
|
return
|
||||||
raise RuntimeError(
|
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)
|
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||||
_log_security_configuration_warnings()
|
_log_security_configuration_warnings()
|
||||||
_enforce_secret_configuration()
|
_enforce_secret_configuration()
|
||||||
|
# Restore offline, before any schema migration, database reader or worker.
|
||||||
|
apply_pending_restore()
|
||||||
|
initialize_setup_state()
|
||||||
init_db()
|
init_db()
|
||||||
_enforce_secure_startup_configuration()
|
_enforce_secure_startup_configuration()
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
@@ -286,9 +317,22 @@ async def startup() -> None:
|
|||||||
runtime.log_background_sync_level,
|
runtime.log_background_sync_level,
|
||||||
runtime.requests_data_source,
|
runtime.requests_data_source,
|
||||||
)
|
)
|
||||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
app.state.on_setup_complete = _start_background_tasks
|
||||||
logger.info("Background imports and automation paused for initial setup")
|
await _start_background_tasks()
|
||||||
|
logger.info("startup complete")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_background_tasks() -> None:
|
||||||
|
global _background_started
|
||||||
|
if _background_started:
|
||||||
return
|
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("jellyfin-sync", run_daily_jellyfin_sync)
|
||||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
||||||
_launch_background_task("request-local-stages", run_local_request_stage_loop)
|
_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("issue-confirmation", run_issue_confirmation_loop)
|
||||||
_launch_background_task("email-recaps", run_email_recap_loop)
|
_launch_background_task("email-recaps", run_email_recap_loop)
|
||||||
_launch_background_task("newsletters", run_newsletter_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)
|
app.include_router(requests_router)
|
||||||
@@ -317,3 +371,5 @@ app.include_router(insights_router)
|
|||||||
app.include_router(identities_router)
|
app.include_router(identities_router)
|
||||||
app.include_router(recaps_router)
|
app.include_router(recaps_router)
|
||||||
app.include_router(newsletters_router)
|
app.include_router(newsletters_router)
|
||||||
|
app.include_router(backups_router)
|
||||||
|
app.include_router(setup_router)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -15,7 +15,7 @@ SENSITIVE_SETTING_KEYS = frozenset(
|
|||||||
"magent_notify_telegram_bot_token", "magent_notify_push_token",
|
"magent_notify_telegram_bot_token", "magent_notify_push_token",
|
||||||
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
|
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
|
||||||
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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-<id>/` 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.
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import PageHeading from "./ui/PageHeading";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
|
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth";
|
||||||
import {
|
import {
|
||||||
normalizeRecentResults,
|
normalizeRecentResults,
|
||||||
normalizeSearchResults,
|
normalizeSearchResults,
|
||||||
type RecentRequest,
|
type RecentRequest,
|
||||||
type RequestSearchResult,
|
type RequestSearchResult,
|
||||||
} from "./lib/request-results";
|
} from "./lib/request-results";
|
||||||
|
import { useEffectiveRole } from "./lib/viewMode";
|
||||||
|
import PageHeading from "./ui/PageHeading";
|
||||||
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
|
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -22,6 +22,8 @@ export default function HomePage() {
|
|||||||
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [role, setRole] = useState<string | null>(null);
|
const [role, setRole] = useState<string | null>(null);
|
||||||
|
const effectiveRole = useEffectiveRole(role);
|
||||||
|
const isAdmin = effectiveRole === "admin";
|
||||||
const [recentDays, setRecentDays] = useState(90);
|
const [recentDays, setRecentDays] = useState(90);
|
||||||
const [recentStage, setRecentStage] = useState<RequestStage>("all");
|
const [recentStage, setRecentStage] = useState<RequestStage>("all");
|
||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
@@ -62,7 +64,7 @@ export default function HomePage() {
|
|||||||
const userRole = me?.role ?? null;
|
const userRole = me?.role ?? null;
|
||||||
setRole(userRole);
|
setRole(userRole);
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
const take = userRole === "admin" ? 50 : 6;
|
const take = isAdmin ? 50 : 6;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
take: String(take),
|
take: String(take),
|
||||||
days: String(recentDays),
|
days: String(recentDays),
|
||||||
@@ -96,7 +98,7 @@ export default function HomePage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [recentDays, recentStage, router]);
|
}, [isAdmin, recentDays, recentStage, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authReady) {
|
if (!authReady) {
|
||||||
@@ -305,7 +307,7 @@ export default function HomePage() {
|
|||||||
<div className="recent-header home-section-heading">
|
<div className="recent-header home-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-kicker">Request activity</span>
|
<span className="section-kicker">Request activity</span>
|
||||||
<h2>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
|
<h2>{isAdmin ? "Recent requests" : "My recent requests"}</h2>
|
||||||
</div>
|
</div>
|
||||||
{authReady && (
|
{authReady && (
|
||||||
<div className="recent-filter-group">
|
<div className="recent-filter-group">
|
||||||
@@ -337,7 +339,7 @@ export default function HomePage() {
|
|||||||
<span>Try a wider period or a different stage.</span>
|
<span>Try a wider period or a different stage.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
recent.map((item) => (
|
(isAdmin ? recent : recent.slice(0, 6)).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary,
|
||||||
|
.pending,
|
||||||
|
.panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary p,
|
||||||
|
.panel > p,
|
||||||
|
.muted,
|
||||||
|
.help {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-inline-start-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="file"] {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 44px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page input:focus-visible,
|
||||||
|
.page button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.columns {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.summary,
|
||||||
|
.pending,
|
||||||
|
.panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiUrl, requestJson } from "../../lib/api-client";
|
||||||
|
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
|
||||||
|
import AdminShell from "../../ui/AdminShell";
|
||||||
|
import styles from "./backups.module.css";
|
||||||
|
|
||||||
|
type BackupDetails = {
|
||||||
|
created_at: string;
|
||||||
|
build: string;
|
||||||
|
include_cache: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackupStatus = {
|
||||||
|
format_version: number;
|
||||||
|
max_upload_bytes: number;
|
||||||
|
max_expanded_bytes: number;
|
||||||
|
include_cache_default: boolean;
|
||||||
|
pending_restore: (BackupDetails & { staged_at: string }) | null;
|
||||||
|
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RestoreResult = {
|
||||||
|
status: "staged";
|
||||||
|
restart_required: true;
|
||||||
|
backup: BackupDetails;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateLabel = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<BackupStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
|
||||||
|
const [includeCache, setIncludeCache] = useState(false);
|
||||||
|
const [exportPassphrase, setExportPassphrase] = useState("");
|
||||||
|
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
||||||
|
const [restorePassphrase, setRestorePassphrase] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleError = useCallback(
|
||||||
|
(cause: unknown, fallback: string) => {
|
||||||
|
if (cause instanceof UnauthorizedError) {
|
||||||
|
router.replace("/login?next=%2Fadmin%2Fbackups");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cause instanceof ForbiddenError) {
|
||||||
|
router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(cause instanceof Error ? cause.message : fallback);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void revision;
|
||||||
|
const abort = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
|
||||||
|
.then((result) => {
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
setData(result);
|
||||||
|
setIncludeCache(result.include_cache_default);
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => {
|
||||||
|
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!abort.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [revision, handleError]);
|
||||||
|
|
||||||
|
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
|
||||||
|
setError("Choose a backup passphrase between 12 and 1,024 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exportPassphrase !== confirmPassphrase) {
|
||||||
|
setError("The backup passphrases do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("export");
|
||||||
|
try {
|
||||||
|
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
|
||||||
|
link.hidden = true;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
try {
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
||||||
|
}
|
||||||
|
setExportPassphrase("");
|
||||||
|
setConfirmPassphrase("");
|
||||||
|
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
|
||||||
|
} catch (cause) {
|
||||||
|
handleError(cause, "Could not create the backup.");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !data || data.pending_restore) return;
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
if (!file || file.size === 0) {
|
||||||
|
setError("Choose a Magent backup file to restore.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > data.max_upload_bytes) {
|
||||||
|
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
|
||||||
|
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmation !== "RESTORE") {
|
||||||
|
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("restore");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("passphrase", restorePassphrase);
|
||||||
|
form.append("confirmation", confirmation);
|
||||||
|
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
|
||||||
|
setData((current) =>
|
||||||
|
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
|
||||||
|
);
|
||||||
|
setRestorePassphrase("");
|
||||||
|
setConfirmation("");
|
||||||
|
setFile(null);
|
||||||
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
|
setNotice(
|
||||||
|
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
handleError(cause, "Could not prepare the restore.");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRestore = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("cancel");
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await requestJson("/admin/backups/restore", { method: "DELETE" });
|
||||||
|
setData((current) => (current ? { ...current, pending_restore: null } : current));
|
||||||
|
setNotice("Pending restore cancelled. Your current data is unchanged.");
|
||||||
|
} catch (cause) {
|
||||||
|
handleError(cause, "Could not cancel the restore.");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
|
||||||
|
<div className={styles.page}>
|
||||||
|
{error && (
|
||||||
|
<p className="error-banner" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p className={styles.notice} role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading && <p role="status">Loading backup settings...</p>}
|
||||||
|
{!loading && !data && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
setError("");
|
||||||
|
setRevision((current) => current + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<section className={styles.summary} aria-labelledby="backup-contents">
|
||||||
|
<h2 id="backup-contents">What is saved</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{data.last_restore && (
|
||||||
|
<p className={styles.muted}>
|
||||||
|
{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."}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data.pending_restore && (
|
||||||
|
<section className={styles.pending} aria-labelledby="pending-restore-title">
|
||||||
|
<h2 id="pending-restore-title">Restore ready — restart required</h2>
|
||||||
|
<p>
|
||||||
|
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"}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
|
||||||
|
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<section className={styles.panel} aria-labelledby="create-backup-title">
|
||||||
|
<h2 id="create-backup-title">Create a backup</h2>
|
||||||
|
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
|
||||||
|
<fieldset className={styles.fields} disabled={!!busy}>
|
||||||
|
<legend className={styles.legend}>Backup options</legend>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeCache}
|
||||||
|
onChange={(event) => setIncludeCache(event.target.checked)}
|
||||||
|
aria-describedby="cache-help"
|
||||||
|
/>
|
||||||
|
Include artwork caches
|
||||||
|
</label>
|
||||||
|
<p id="cache-help" className={styles.help}>
|
||||||
|
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
|
||||||
|
again. Database caches are always included.
|
||||||
|
</p>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Backup passphrase
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={exportPassphrase}
|
||||||
|
onChange={(event) => setExportPassphrase(event.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
required
|
||||||
|
aria-describedby="backup-passphrase-help"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="backup-passphrase-help" className={styles.help}>
|
||||||
|
Use at least 12 characters. This passphrase is separate from your login password. A lost
|
||||||
|
passphrase cannot be recovered.
|
||||||
|
</p>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Confirm backup passphrase
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassphrase}
|
||||||
|
onChange={(event) => setConfirmPassphrase(event.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">
|
||||||
|
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.panel} aria-labelledby="restore-backup-title">
|
||||||
|
<h2 id="restore-backup-title">Restore a backup</h2>
|
||||||
|
<p>
|
||||||
|
Restoring replaces Magent's settings and database, including users and invites. Download a
|
||||||
|
current backup first if you want to keep these changes.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
|
||||||
|
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
|
||||||
|
<legend className={styles.legend}>Choose and confirm a backup</legend>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Backup file
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".magent-backup,application/octet-stream"
|
||||||
|
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||||
|
required
|
||||||
|
aria-describedby="backup-file-help"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="backup-file-help" className={styles.help}>
|
||||||
|
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
|
||||||
|
</p>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Backup passphrase
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={restorePassphrase}
|
||||||
|
onChange={(event) => setRestorePassphrase(event.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Type RESTORE to confirm replacement
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
spellCheck={false}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(event) => setConfirmation(event.target.value)}
|
||||||
|
pattern="RESTORE"
|
||||||
|
required
|
||||||
|
aria-describedby="restore-restart-help"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="restore-restart-help" className={styles.help}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button type="submit" className="danger-button">
|
||||||
|
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,6 +82,12 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
|||||||
advanced: true,
|
advanced: true,
|
||||||
items: [
|
items: [
|
||||||
{ href: "/admin/general", label: "Hosting & proxy", description: "Public addresses and deployment options" },
|
{ 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/diagnostics", label: "System health", description: "Service checks and diagnostics" },
|
||||||
{ href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" },
|
{ href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" },
|
||||||
{ href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" },
|
{ href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { authFetch, getApiBase } from "../lib/auth";
|
import { authFetch, getApiBase } from "../lib/auth";
|
||||||
|
import { useEffectiveRole } from "../lib/viewMode";
|
||||||
import PageHeading from "../ui/PageHeading";
|
import PageHeading from "../ui/PageHeading";
|
||||||
import {
|
import {
|
||||||
type Stats,
|
type Stats,
|
||||||
@@ -20,6 +21,7 @@ export default function InsightsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
const [data, setData] = useState<Stats | null>(null);
|
const [data, setData] = useState<Stats | null>(null);
|
||||||
|
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
@@ -126,11 +128,11 @@ export default function InsightsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<h2>Your viewing story starts here</h2>
|
<h2>Your viewing story starts here</h2>
|
||||||
<p>
|
<p>
|
||||||
{data.is_admin
|
{isAdmin
|
||||||
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
|
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
|
||||||
: "Viewing stats will appear here once your administrator connects Jellystat."}
|
: "Viewing stats will appear here once your administrator connects Jellystat."}
|
||||||
</p>
|
</p>
|
||||||
{data.is_admin && (
|
{isAdmin && (
|
||||||
<a className="stats-action" href="/admin/jellystat">
|
<a className="stats-action" href="/admin/jellystat">
|
||||||
Connect Jellystat
|
Connect Jellystat
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import EmailReportControl from "./EmailReportControl";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { authFetch, getApiBase } from "../../lib/auth";
|
import { authFetch, getApiBase } from "../../lib/auth";
|
||||||
|
import { useEffectiveRole } from "../../lib/viewMode";
|
||||||
import PageHeading from "../../ui/PageHeading";
|
import PageHeading from "../../ui/PageHeading";
|
||||||
import {
|
import {
|
||||||
type Stats,
|
type Stats,
|
||||||
@@ -67,6 +68,7 @@ export default function MonthlyReportsPage() {
|
|||||||
const [monthReady, setMonthReady] = useState(false);
|
const [monthReady, setMonthReady] = useState(false);
|
||||||
const [months, setMonths] = useState<string[]>([]);
|
const [months, setMonths] = useState<string[]>([]);
|
||||||
const [data, setData] = useState<MonthlyReport | null>(null);
|
const [data, setData] = useState<MonthlyReport | null>(null);
|
||||||
|
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
@@ -267,11 +269,11 @@ export default function MonthlyReportsPage() {
|
|||||||
<section className="stats-state">
|
<section className="stats-state">
|
||||||
<h2>Your monthly story starts here</h2>
|
<h2>Your monthly story starts here</h2>
|
||||||
<p>
|
<p>
|
||||||
{data.is_admin
|
{isAdmin
|
||||||
? "Connect Jellystat to bring your monthly viewing reports into Magent."
|
? "Connect Jellystat to bring your monthly viewing reports into Magent."
|
||||||
: "Monthly reports will appear once your administrator connects Jellystat."}
|
: "Monthly reports will appear once your administrator connects Jellystat."}
|
||||||
</p>
|
</p>
|
||||||
{data.is_admin && (
|
{isAdmin && (
|
||||||
<a className="stats-action" href="/admin/jellystat">
|
<a className="stats-action" href="/admin/jellystat">
|
||||||
Connect Jellystat
|
Connect Jellystat
|
||||||
</a>
|
</a>
|
||||||
@@ -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 report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
|
||||||
your user identities.
|
your user identities.
|
||||||
</p>
|
</p>
|
||||||
{data.is_admin && (
|
{isAdmin && (
|
||||||
<a className="stats-action" href="/admin/identities">
|
<a className="stats-action" href="/admin/identities">
|
||||||
Review user identities
|
Review user identities
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { ReactNode } from "react";
|
|||||||
import BrandingFavicon from "./ui/BrandingFavicon";
|
import BrandingFavicon from "./ui/BrandingFavicon";
|
||||||
import FeatureGate from "./ui/FeatureGate";
|
import FeatureGate from "./ui/FeatureGate";
|
||||||
import ApplicationChrome from "./ui/ApplicationChrome";
|
import ApplicationChrome from "./ui/ApplicationChrome";
|
||||||
|
import SetupGate from "./ui/SetupGate";
|
||||||
|
import AdminViewGate from "./ui/AdminViewGate";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Magent",
|
title: "Magent",
|
||||||
@@ -26,8 +28,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<body>
|
<body>
|
||||||
<BrandingFavicon />
|
<BrandingFavicon />
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<ApplicationChrome />
|
<SetupGate>
|
||||||
<FeatureGate>{children}</FeatureGate>
|
<ApplicationChrome />
|
||||||
|
<AdminViewGate>
|
||||||
|
<FeatureGate>{children}</FeatureGate>
|
||||||
|
</AdminViewGate>
|
||||||
|
</SetupGate>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}/`));
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
"use client";
|
"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_STORAGE_KEY = "magent_user_view_preview";
|
||||||
const USER_VIEW_EVENT = "magent:user-view-change";
|
const USER_VIEW_EVENT = "magent:user-view-change";
|
||||||
|
let fallbackPreview = false;
|
||||||
|
|
||||||
const readUserViewPreview = () => {
|
const readUserViewPreview = () => {
|
||||||
if (typeof window === "undefined") return false;
|
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) => {
|
const applyDocumentMode = (enabled: boolean) => {
|
||||||
@@ -17,32 +23,46 @@ const applyDocumentMode = (enabled: boolean) => {
|
|||||||
|
|
||||||
export const setUserViewPreview = (enabled: boolean) => {
|
export const setUserViewPreview = (enabled: boolean) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (enabled) {
|
fallbackPreview = enabled;
|
||||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
|
try {
|
||||||
} else {
|
if (enabled) {
|
||||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
|
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);
|
applyDocumentMode(enabled);
|
||||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
|
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUserViewPreview = () => {
|
const subscribe = (notify: () => void) => {
|
||||||
const [enabled, setEnabled] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const sync = () => {
|
if (value !== null) applyDocumentMode(value);
|
||||||
const nextValue = readUserViewPreview();
|
}, [value]);
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export default function LoginPage() {
|
|||||||
"/profile#newsletters",
|
"/profile#newsletters",
|
||||||
"/admin/recaps",
|
"/admin/recaps",
|
||||||
"/admin/newsletters",
|
"/admin/newsletters",
|
||||||
|
"/setup",
|
||||||
|
"/admin/backups",
|
||||||
].includes(next) ||
|
].includes(next) ||
|
||||||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
|
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
|
||||||
/^\/issues\/confirm\/\d+$/.test(next);
|
/^\/issues\/confirm\/\d+$/.test(next);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import ResolutionChoice from "../ui/ResolutionChoice";
|
|
||||||
|
|
||||||
import PageHeading from "../ui/PageHeading";
|
|
||||||
import IssueFlowStep from "./IssueFlowStep";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
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 = {
|
type PortalPermissions = {
|
||||||
can_edit?: boolean;
|
can_edit?: boolean;
|
||||||
@@ -536,7 +536,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
|
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
|
||||||
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
|
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
|
||||||
|
|
||||||
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 visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
|
||||||
const workspaceLabel = workspace === "request" ? "request" : "issue";
|
const workspaceLabel = workspace === "request" ? "request" : "issue";
|
||||||
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
|
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
|
||||||
@@ -610,6 +619,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
});
|
});
|
||||||
const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -1335,7 +1354,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
|
|
||||||
const saveItem = async (event: React.FormEvent) => {
|
const saveItem = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selectedItem) return;
|
if (!selectedItem || !canEditSelected) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -1347,7 +1366,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
year: editYear.trim() ? toPositiveInt(editYear) : null,
|
year: editYear.trim() ? toPositiveInt(editYear) : null,
|
||||||
external_ref: editExternalRef || null,
|
external_ref: editExternalRef || null,
|
||||||
};
|
};
|
||||||
if (selectedItem.permissions?.can_moderate) {
|
if (canModerateSelected) {
|
||||||
if (selectedItem.kind === "request") {
|
if (selectedItem.kind === "request") {
|
||||||
payload.request_status = editRequestStatus;
|
payload.request_status = editRequestStatus;
|
||||||
payload.media_status = editMediaStatus;
|
payload.media_status = editMediaStatus;
|
||||||
@@ -1390,6 +1409,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const postComment = async (event: React.FormEvent) => {
|
const postComment = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selectedItem) return;
|
if (!selectedItem) return;
|
||||||
|
if (commentInternal && !isAdmin) return;
|
||||||
if (!commentText.trim()) {
|
if (!commentText.trim()) {
|
||||||
setError("Comment message is required.");
|
setError("Comment message is required.");
|
||||||
return;
|
return;
|
||||||
@@ -1404,7 +1424,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: commentText,
|
message: commentText,
|
||||||
is_internal: commentInternal,
|
is_internal: isAdmin && commentInternal,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1429,7 +1449,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const respondToResolution = async (resolved: boolean) => {
|
const respondToResolution = async (resolved: boolean) => {
|
||||||
if (!selectedItem) return;
|
if (!selectedItem || !canConfirmResolution(selectedItem)) return;
|
||||||
setRespondingResolution(true);
|
setRespondingResolution(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -1465,7 +1485,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteIssue = async () => {
|
const deleteIssue = async () => {
|
||||||
if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return;
|
if (selectedItem?.kind !== "issue" || !canDeleteSelected) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -1550,7 +1570,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.status === "awaiting_confirmation" &&
|
item.status === "awaiting_confirmation" &&
|
||||||
item.permissions?.can_confirm_resolution &&
|
canConfirmResolution(item) &&
|
||||||
item.created_by_username === me?.username,
|
item.created_by_username === me?.username,
|
||||||
)
|
)
|
||||||
.map((item) => (
|
.map((item) => (
|
||||||
@@ -2416,7 +2436,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="issue-modal-toolbar-actions">
|
<div className="issue-modal-toolbar-actions">
|
||||||
{selectedItem?.permissions?.can_delete ? (
|
{canDeleteSelected ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger-button"
|
className="danger-button"
|
||||||
@@ -2442,7 +2462,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<>
|
<>
|
||||||
{selectedItem.kind === "issue" &&
|
{selectedItem.kind === "issue" &&
|
||||||
selectedItem.status === "awaiting_confirmation" &&
|
selectedItem.status === "awaiting_confirmation" &&
|
||||||
selectedItem.permissions?.can_confirm_resolution && (
|
canConfirmResolution(selectedItem) && (
|
||||||
<ResolutionChoice
|
<ResolutionChoice
|
||||||
title={selectedItem.title}
|
title={selectedItem.title}
|
||||||
busy={respondingResolution}
|
busy={respondingResolution}
|
||||||
@@ -2478,7 +2498,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedItem.kind === "issue" && deleteConfirming ? (
|
{selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
|
||||||
<section className="issue-delete-confirmation" aria-live="polite">
|
<section className="issue-delete-confirmation" aria-live="polite">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-kicker">Permanent deletion</span>
|
<span className="section-kicker">Permanent deletion</span>
|
||||||
@@ -2529,7 +2549,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<input
|
<input
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(event) => setEditTitle(event.target.value)}
|
onChange={(event) => setEditTitle(event.target.value)}
|
||||||
disabled={!selectedItem.permissions?.can_edit}
|
disabled={!canEditSelected}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="portal-field-span-2">
|
<label className="portal-field-span-2">
|
||||||
@@ -2538,7 +2558,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
rows={4}
|
rows={4}
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(event) => setEditDescription(event.target.value)}
|
onChange={(event) => setEditDescription(event.target.value)}
|
||||||
disabled={!selectedItem.permissions?.can_edit}
|
disabled={!canEditSelected}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{selectedItem.kind === "request" ? (
|
{selectedItem.kind === "request" ? (
|
||||||
@@ -2548,7 +2568,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<select
|
<select
|
||||||
value={editMediaType}
|
value={editMediaType}
|
||||||
onChange={(event) => setEditMediaType(event.target.value)}
|
onChange={(event) => setEditMediaType(event.target.value)}
|
||||||
disabled={!selectedItem.permissions?.can_edit}
|
disabled={!canEditSelected}
|
||||||
>
|
>
|
||||||
{MEDIA_TYPE_OPTIONS.map((option) => (
|
{MEDIA_TYPE_OPTIONS.map((option) => (
|
||||||
<option key={option.value || "none"} value={option.value}>
|
<option key={option.value || "none"} value={option.value}>
|
||||||
@@ -2563,7 +2583,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
value={editYear}
|
value={editYear}
|
||||||
onChange={(event) => setEditYear(event.target.value)}
|
onChange={(event) => setEditYear(event.target.value)}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
disabled={!selectedItem.permissions?.can_edit}
|
disabled={!canEditSelected}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
@@ -2573,10 +2593,10 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<input
|
<input
|
||||||
value={editExternalRef}
|
value={editExternalRef}
|
||||||
onChange={(event) => setEditExternalRef(event.target.value)}
|
onChange={(event) => setEditExternalRef(event.target.value)}
|
||||||
disabled={!selectedItem.permissions?.can_edit}
|
disabled={!canEditSelected}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{selectedItem.permissions?.can_moderate && (
|
{canModerateSelected && (
|
||||||
<>
|
<>
|
||||||
{selectedItem.kind === "request" ? (
|
{selectedItem.kind === "request" ? (
|
||||||
<>
|
<>
|
||||||
@@ -2652,7 +2672,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="admin-inline-actions portal-field-span-2">
|
<div className="admin-inline-actions portal-field-span-2">
|
||||||
<button type="submit" disabled={saving || !selectedItem.permissions?.can_edit}>
|
<button type="submit" disabled={saving || !canEditSelected}>
|
||||||
{saving ? "Saving…" : "Save changes"}
|
{saving ? "Saving…" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2665,13 +2685,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<span className="section-kicker">Recorded work</span>
|
<span className="section-kicker">Recorded work</span>
|
||||||
<h3>Issue activity</h3>
|
<h3>Issue activity</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="small-pill">{activity.length} events</span>
|
<span className="small-pill">{visibleActivity.length} events</span>
|
||||||
</div>
|
</div>
|
||||||
{activity.length === 0 ? (
|
{visibleActivity.length === 0 ? (
|
||||||
<div className="status-banner">No issue activity has been recorded yet.</div>
|
<div className="status-banner">No issue activity has been recorded yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<ol className="issue-activity-list">
|
<ol className="issue-activity-list">
|
||||||
{activity.map((entry) => (
|
{visibleActivity.map((entry) => (
|
||||||
<li key={entry.id}>
|
<li key={entry.id}>
|
||||||
<i aria-hidden="true" />
|
<i aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
@@ -2690,11 +2710,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
|
|
||||||
<div className="portal-comments-block">
|
<div className="portal-comments-block">
|
||||||
<h3>Comments</h3>
|
<h3>Comments</h3>
|
||||||
{comments.length === 0 ? (
|
{visibleComments.length === 0 ? (
|
||||||
<div className="status-banner">No comments yet.</div>
|
<div className="status-banner">No comments yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="portal-comment-list">
|
<div className="portal-comment-list">
|
||||||
{comments.map((comment) => (
|
{visibleComments.map((comment) => (
|
||||||
<article key={comment.id} className="portal-comment-card">
|
<article key={comment.id} className="portal-comment-card">
|
||||||
<header>
|
<header>
|
||||||
<strong>{comment.author_username}</strong>
|
<strong>{comment.author_username}</strong>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
|
|
||||||
|
|
||||||
import PageHeading from "../../ui/PageHeading";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
||||||
|
import { useEffectiveRole } from "../../lib/viewMode";
|
||||||
|
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
|
||||||
|
import PageHeading from "../../ui/PageHeading";
|
||||||
|
|
||||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
|
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
|
||||||
type OwnedInvite = {
|
type OwnedInvite = {
|
||||||
@@ -77,6 +76,10 @@ export default function ProfileInvitesPage() {
|
|||||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
|
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
|
||||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
|
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
|
||||||
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
|
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
|
||||||
|
const effectiveRole = useEffectiveRole(profile?.role);
|
||||||
|
const canManageInvites =
|
||||||
|
effectiveRole === "admin" ||
|
||||||
|
(profile?.role === "admin" ? Boolean(profile.invite_management_enabled) : inviteAccessEnabled);
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
const signupBaseUrl = useMemo(() => {
|
||||||
if (typeof window === "undefined") return "/signup";
|
if (typeof window === "undefined") return "/signup";
|
||||||
@@ -158,6 +161,7 @@ export default function ProfileInvitesPage() {
|
|||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!canManageInvites) return;
|
||||||
const inviteName = inviteForm.label.trim();
|
const inviteName = inviteForm.label.trim();
|
||||||
const recipientEmail = inviteForm.recipient_email.trim();
|
const recipientEmail = inviteForm.recipient_email.trim();
|
||||||
if (!inviteName) {
|
if (!inviteName) {
|
||||||
@@ -225,6 +229,7 @@ export default function ProfileInvitesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteInvite = async (invite: OwnedInvite) => {
|
const deleteInvite = async (invite: OwnedInvite) => {
|
||||||
|
if (!canManageInvites) return;
|
||||||
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
|
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -240,6 +245,7 @@ export default function ProfileInvitesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||||
|
if (!canManageInvites) return;
|
||||||
try {
|
try {
|
||||||
let usableInvite = invite;
|
let usableInvite = invite;
|
||||||
if (!invite.code_available) {
|
if (!invite.code_available) {
|
||||||
@@ -263,7 +269,6 @@ export default function ProfileInvitesPage() {
|
|||||||
|
|
||||||
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
|
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
|
||||||
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
|
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
|
||||||
const canManageInvites = profile?.role === "admin" || inviteAccessEnabled;
|
|
||||||
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
|
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
|
||||||
|
|
||||||
if (loading) return <main className="card">Loading invite workspace…</main>;
|
if (loading) return <main className="card">Loading invite workspace…</main>;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
||||||
import { canAccess, type FeatureAccess } from "../lib/features";
|
import { canAccess, type FeatureAccess } from "../lib/features";
|
||||||
|
import { useEffectiveRole } from "../lib/viewMode";
|
||||||
import PageHeading from "../ui/PageHeading";
|
import PageHeading from "../ui/PageHeading";
|
||||||
import MonthlyRecapPreference from "./MonthlyRecapPreference";
|
import MonthlyRecapPreference from "./MonthlyRecapPreference";
|
||||||
import NewsletterPreference from "./NewsletterPreference";
|
import NewsletterPreference from "./NewsletterPreference";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
|
||||||
|
|
||||||
type ProfileInfo = {
|
type ProfileInfo = {
|
||||||
features?: FeatureAccess;
|
features?: FeatureAccess;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -214,6 +214,7 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const user = data?.user;
|
const user = data?.user;
|
||||||
|
const effectiveRole = useEffectiveRole(user?.role);
|
||||||
const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
|
const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
|
||||||
const canChangePassword =
|
const canChangePassword =
|
||||||
user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
|
user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
|
||||||
@@ -239,7 +240,7 @@ export default function ProfilePage() {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{user.username}</strong>
|
<strong>{user.username}</strong>
|
||||||
<span>{user.role === "admin" ? "Administrator" : "Member"}</span>
|
<span>{effectiveRole === "admin" ? "Administrator" : "Member"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -340,7 +341,9 @@ export default function ProfilePage() {
|
|||||||
: "Signed in with your media account"}
|
: "Signed in with your media account"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{canAccess(user, "stats") && <MonthlyRecapPreference key={user.email || "no-email"} />}
|
{canAccess({ ...user, role: effectiveRole ?? undefined }, "stats") && (
|
||||||
|
<MonthlyRecapPreference key={user.email || "no-email"} />
|
||||||
|
)}
|
||||||
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
|
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import Image from "next/image";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
||||||
import { canAccess } from "../../lib/features";
|
import { canAccess, type FeatureAccess } from "../../lib/features";
|
||||||
import { lockBodyScroll } from "../../lib/scrollLock";
|
import { lockBodyScroll } from "../../lib/scrollLock";
|
||||||
|
import { useEffectiveRole } from "../../lib/viewMode";
|
||||||
import PageHeading from "../../ui/PageHeading";
|
import PageHeading from "../../ui/PageHeading";
|
||||||
import LatestActivity from "./LatestActivity";
|
import LatestActivity from "./LatestActivity";
|
||||||
import RequestLanguage from "./RequestLanguage";
|
import RequestLanguage from "./RequestLanguage";
|
||||||
@@ -329,8 +330,10 @@ export default function RequestTimelinePage() {
|
|||||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]);
|
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]);
|
||||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]);
|
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]);
|
||||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
|
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [viewer, setViewer] = useState<{ role?: string; features?: Partial<FeatureAccess> } | null>(null);
|
||||||
const [canReportIssues, setCanReportIssues] = useState(false);
|
const effectiveRole = useEffectiveRole(viewer?.role);
|
||||||
|
const isAdmin = effectiveRole === "admin";
|
||||||
|
const canReportIssues = canAccess(viewer ? { ...viewer, role: effectiveRole ?? undefined } : null, "issues");
|
||||||
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([]);
|
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([]);
|
||||||
const awaitingMediaIndex = Boolean(
|
const awaitingMediaIndex = Boolean(
|
||||||
snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"),
|
snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"),
|
||||||
@@ -389,32 +392,13 @@ export default function RequestTimelinePage() {
|
|||||||
throw new Error("Unable to verify your request access.");
|
throw new Error("Unable to verify your request access.");
|
||||||
}
|
}
|
||||||
const me = await meResponse.json();
|
const me = await meResponse.json();
|
||||||
const viewerIsAdmin = me?.role === "admin";
|
setViewer(me);
|
||||||
setIsAdmin(viewerIsAdmin);
|
|
||||||
setCanReportIssues(canAccess(me, "issues"));
|
|
||||||
if (!snapshotResponse.ok) {
|
if (!snapshotResponse.ok) {
|
||||||
throw new Error(await readApiError(snapshotResponse, "Unable to load this request."));
|
throw new Error(await readApiError(snapshotResponse, "Unable to load this request."));
|
||||||
}
|
}
|
||||||
const snapshotData = await snapshotResponse.json();
|
const snapshotData = await snapshotResponse.json();
|
||||||
if (!isSnapshotPayload(snapshotData)) throw new Error("Unable to load this request.");
|
if (!isSnapshotPayload(snapshotData)) throw new Error("Unable to load this request.");
|
||||||
setSnapshot(snapshotData);
|
setSnapshot(snapshotData);
|
||||||
if (viewerIsAdmin) {
|
|
||||||
const [historyResponse, actionsResponse] = await Promise.all([
|
|
||||||
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
|
|
||||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
|
|
||||||
]);
|
|
||||||
if (historyResponse.ok) {
|
|
||||||
const historyData = await historyResponse.json();
|
|
||||||
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots);
|
|
||||||
}
|
|
||||||
if (actionsResponse.ok) {
|
|
||||||
const actionsData = await actionsResponse.json();
|
|
||||||
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setHistorySnapshots([]);
|
|
||||||
setHistoryActions([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setLoadError(error instanceof Error ? error.message : "Unable to load this request.");
|
setLoadError(error instanceof Error ? error.message : "Unable to load this request.");
|
||||||
@@ -425,6 +409,37 @@ export default function RequestTimelinePage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [requestId, router]);
|
}, [requestId, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin || !requestId) {
|
||||||
|
setShowDetails(false);
|
||||||
|
setHistorySnapshots([]);
|
||||||
|
setHistoryActions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loadHistory = async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase();
|
||||||
|
const [historyResponse, actionsResponse] = await Promise.all([
|
||||||
|
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`, { signal: controller.signal }),
|
||||||
|
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`, { signal: controller.signal }),
|
||||||
|
]);
|
||||||
|
if (historyResponse.ok) {
|
||||||
|
const data = await historyResponse.json();
|
||||||
|
if (!controller.signal.aborted && Array.isArray(data.snapshots)) setHistorySnapshots(data.snapshots);
|
||||||
|
}
|
||||||
|
if (actionsResponse.ok) {
|
||||||
|
const data = await actionsResponse.json();
|
||||||
|
if (!controller.signal.aborted && Array.isArray(data.actions)) setHistoryActions(data.actions);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadHistory();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [isAdmin, requestId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken() || !requestId) return;
|
if (!getToken() || !requestId) return;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { apiUrl, requestJson } from "../lib/api-client";
|
||||||
|
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
|
||||||
|
import MagentMark from "../ui/MagentMark";
|
||||||
|
import { serviceStatusLabel } from "../admin/configNavigation";
|
||||||
|
import {
|
||||||
|
ALL_FIELDS,
|
||||||
|
APPS,
|
||||||
|
PREFERENCES,
|
||||||
|
configuredApp,
|
||||||
|
settingsPayload,
|
||||||
|
settingsValues,
|
||||||
|
type AppDefinition,
|
||||||
|
type Field,
|
||||||
|
type Setting,
|
||||||
|
type SetupState,
|
||||||
|
type SetupStatus,
|
||||||
|
type SetupStep,
|
||||||
|
type Values,
|
||||||
|
} from "./setup-model";
|
||||||
|
import styles from "./setup.module.css";
|
||||||
|
|
||||||
|
type Check = { status: string; message?: string };
|
||||||
|
type CollectorOptions = { rootFolders: { path: string }[]; qualityProfiles: { id: number; name: string }[] };
|
||||||
|
const steps: { id: SetupStep; label: string }[] = [
|
||||||
|
{ id: "administrator", label: "Administrator" },
|
||||||
|
{ id: "apps", label: "Apps" },
|
||||||
|
{ id: "preferences", label: "Preferences" },
|
||||||
|
{ id: "review", label: "Review" },
|
||||||
|
];
|
||||||
|
const json = (body: unknown, method = "POST"): RequestInit => ({
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const message = (error: unknown) =>
|
||||||
|
error instanceof Error ? error.message : "Something went wrong. Please try again.";
|
||||||
|
|
||||||
|
export default function SetupPage() {
|
||||||
|
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||||
|
const [state, setState] = useState<SetupState | null>(null);
|
||||||
|
const [step, setStep] = useState<SetupStep>("administrator");
|
||||||
|
const [settings, setSettings] = useState<Setting[]>([]);
|
||||||
|
const [draft, setDraft] = useState<Values>({});
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
const [forbidden, setForbidden] = useState(false);
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [username, setUsername] = useState("admin");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [setupToken, setSetupToken] = useState("");
|
||||||
|
const [checks, setChecks] = useState<Record<string, Check>>({});
|
||||||
|
const [options, setOptions] = useState<Record<string, CollectorOptions>>({});
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
const values = { ...settingsValues(settings), ...draft };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const current = await requestJson<SetupStatus>(
|
||||||
|
"/setup/status",
|
||||||
|
{ signal: controller.signal, cache: "no-store" },
|
||||||
|
authFetch,
|
||||||
|
);
|
||||||
|
setStatus(current);
|
||||||
|
if (current.needs_admin) return;
|
||||||
|
const response = await authFetch(apiUrl("/auth/me"), { signal: controller.signal });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const user = await response.json();
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
setForbidden(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [progress, config] = await Promise.all([
|
||||||
|
requestJson<SetupState>("/setup/state", { signal: controller.signal }),
|
||||||
|
requestJson<{ settings: Setting[] }>("/admin/settings", { signal: controller.signal }),
|
||||||
|
]);
|
||||||
|
setAdmin(true);
|
||||||
|
setState(progress);
|
||||||
|
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
|
||||||
|
setSettings(config.settings);
|
||||||
|
} catch (failure) {
|
||||||
|
if (!controller.signal.aborted) setError(message(failure));
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) setReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Object.keys(draft).length) return;
|
||||||
|
const warn = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", warn);
|
||||||
|
return () => window.removeEventListener("beforeunload", warn);
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const run = async (name: string, action: () => Promise<void>) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(name);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (failure) {
|
||||||
|
if (failure instanceof UnauthorizedError) {
|
||||||
|
setAdmin(false);
|
||||||
|
setForbidden(false);
|
||||||
|
setAccepted(false);
|
||||||
|
setPassword("");
|
||||||
|
setError("Your session expired. Sign in to continue; your unsaved changes are still here.");
|
||||||
|
} else if (failure instanceof ForbiddenError) {
|
||||||
|
setAdmin(false);
|
||||||
|
setForbidden(true);
|
||||||
|
setAccepted(false);
|
||||||
|
} else setError(message(failure));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signIn = async (loginPassword = password) => {
|
||||||
|
const result = await requestJson<{ authenticated: boolean; user?: { role: string } }>(
|
||||||
|
"/auth/login",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ username: username.trim(), password: loginPassword }),
|
||||||
|
},
|
||||||
|
authFetch,
|
||||||
|
);
|
||||||
|
if (!result.authenticated) throw new Error("Could not sign in. Try your administrator credentials again.");
|
||||||
|
setToken("cookie");
|
||||||
|
setPassword("");
|
||||||
|
setConfirmation("");
|
||||||
|
const user = await requestJson<{ role: string }>("/auth/me");
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
setForbidden(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [progress, config] = await Promise.all([
|
||||||
|
requestJson<SetupState>("/setup/state"),
|
||||||
|
requestJson<{ settings: Setting[] }>("/admin/settings"),
|
||||||
|
]);
|
||||||
|
setAdmin(true);
|
||||||
|
setForbidden(false);
|
||||||
|
setNotice("");
|
||||||
|
setState(progress);
|
||||||
|
setSettings(config.settings);
|
||||||
|
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const authenticate = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void run("account", async () => {
|
||||||
|
let loginPassword = password;
|
||||||
|
if (status?.needs_admin) {
|
||||||
|
if (password !== confirmation) throw new Error("The passwords do not match.");
|
||||||
|
loginPassword = password.trim();
|
||||||
|
if (loginPassword.length < 12)
|
||||||
|
throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces.");
|
||||||
|
await requestJson(
|
||||||
|
"/setup/bootstrap",
|
||||||
|
json({ setup_token: setupToken, username: username.trim(), password }),
|
||||||
|
authFetch,
|
||||||
|
);
|
||||||
|
setPassword(loginPassword);
|
||||||
|
setSetupToken("");
|
||||||
|
setStatus({ setup_required: true, needs_admin: false });
|
||||||
|
setNotice("Administrator created. Signing in...");
|
||||||
|
}
|
||||||
|
await signIn(loginPassword);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchAccount = () =>
|
||||||
|
void run("switch-account", async () => {
|
||||||
|
await logout();
|
||||||
|
setAdmin(false);
|
||||||
|
setForbidden(false);
|
||||||
|
setAccepted(false);
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setConfirmation("");
|
||||||
|
setDraft({});
|
||||||
|
setSettings([]);
|
||||||
|
setChecks({});
|
||||||
|
setOptions({});
|
||||||
|
setNotice("Sign in with a Magent administrator account to continue setup.");
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async (fields: Field[] = ALL_FIELDS) => {
|
||||||
|
const payload = settingsPayload(draft, fields);
|
||||||
|
if (!Object.keys(payload).length) return;
|
||||||
|
if (values.site_login_show_local_login === false && values.site_login_show_jellyfin_login === false) {
|
||||||
|
throw new Error("Keep at least one sign-in method enabled.");
|
||||||
|
}
|
||||||
|
if (values.magent_notify_email_use_tls === true && values.magent_notify_email_use_ssl === true) {
|
||||||
|
throw new Error("Choose STARTTLS or implicit TLS, not both.");
|
||||||
|
}
|
||||||
|
await requestJson("/admin/settings", json(payload, "PUT"));
|
||||||
|
const config = await requestJson<{ settings: Setting[] }>("/admin/settings");
|
||||||
|
setSettings(config.settings);
|
||||||
|
setDraft((previous) =>
|
||||||
|
Object.fromEntries(Object.entries(previous).filter(([key]) => !fields.some((field) => field.key === key))),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = (next: SetupStep) =>
|
||||||
|
void run("save", async () => {
|
||||||
|
await save();
|
||||||
|
if (!state?.completed) setState(await requestJson<SetupState>("/setup/state", json({ step: next }, "PUT")));
|
||||||
|
setStep(next);
|
||||||
|
setNotice("Settings saved. You can return to finish setup later.");
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = (app: AppDefinition) =>
|
||||||
|
void run(app.id, async () => {
|
||||||
|
await save(app.fields);
|
||||||
|
const check = await requestJson<Check>(`/status/services/${app.id}/test`, { method: "POST" });
|
||||||
|
setChecks((previous) => ({ ...previous, [app.id]: check }));
|
||||||
|
setNotice(`${app.name}: ${serviceStatusLabel(check.status)}${check.message ? ` — ${check.message}` : ""}`);
|
||||||
|
if ((app.id === "sonarr" || app.id === "radarr") && check.status === "up") {
|
||||||
|
const choices = await requestJson<CollectorOptions>(`/admin/${app.id}/options`);
|
||||||
|
setOptions((previous) => ({ ...previous, [app.id]: choices }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = (field: Field, value: string | boolean) => {
|
||||||
|
setDraft((previous) => ({ ...previous, [field.key]: value }));
|
||||||
|
setAccepted(false);
|
||||||
|
setNotice("");
|
||||||
|
const app = APPS.find((candidate) => candidate.fields.some((item) => item.key === field.key));
|
||||||
|
if (app) setChecks((previous) => ({ ...previous, [app.id]: { status: "unchecked" } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldControl = (field: Field) => {
|
||||||
|
const saved = settings.some((setting) => setting.key === field.key && setting.isSet);
|
||||||
|
const collectorId = field.key.startsWith("sonarr_") ? "sonarr" : "radarr";
|
||||||
|
const choices = options[collectorId];
|
||||||
|
const profile = field.key.endsWith("_quality_profile_id") && choices?.qualityProfiles.length;
|
||||||
|
const folders = field.key.endsWith("_root_folder") && choices?.rootFolders.length;
|
||||||
|
return (
|
||||||
|
<div key={field.key} className={`${styles.field} ${field.type === "checkbox" ? styles.toggle : ""}`}>
|
||||||
|
<label htmlFor={`setup-${field.key}`}>
|
||||||
|
{field.label}
|
||||||
|
{field.type === "password" && saved && <small>Saved securely</small>}
|
||||||
|
</label>
|
||||||
|
{field.type === "checkbox" ? (
|
||||||
|
<input
|
||||||
|
id={`setup-${field.key}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={values[field.key] === true}
|
||||||
|
onChange={(event) => update(field, event.target.checked)}
|
||||||
|
disabled={!!busy}
|
||||||
|
/>
|
||||||
|
) : field.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
id={`setup-${field.key}`}
|
||||||
|
value={String(values[field.key] ?? "")}
|
||||||
|
onChange={(event) => update(field, event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : profile ? (
|
||||||
|
<select
|
||||||
|
id={`setup-${field.key}`}
|
||||||
|
value={String(values[field.key] ?? "")}
|
||||||
|
onChange={(event) => update(field, event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
>
|
||||||
|
<option value="">Choose a profile</option>
|
||||||
|
{choices.qualityProfiles.map((choice) => (
|
||||||
|
<option key={choice.id} value={choice.id}>
|
||||||
|
{choice.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id={`setup-${field.key}`}
|
||||||
|
type={field.type || "text"}
|
||||||
|
value={String(values[field.key] ?? "")}
|
||||||
|
onChange={(event) => update(field, event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
autoComplete={field.type === "password" ? "new-password" : "off"}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
placeholder={
|
||||||
|
field.type === "password" && saved ? "Leave blank to keep saved credential" : field.placeholder
|
||||||
|
}
|
||||||
|
list={folders ? `options-${field.key}` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{folders ? (
|
||||||
|
<datalist id={`options-${field.key}`}>
|
||||||
|
{choices.rootFolders.map((folder) => (
|
||||||
|
<option key={folder.path} value={folder.path} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
) : null}
|
||||||
|
{field.hint && <p>{field.hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.setup}>
|
||||||
|
<header className={styles.heading}>
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<MagentMark />
|
||||||
|
<span>Magent / Installation</span>
|
||||||
|
</div>
|
||||||
|
<h1>Set up Magent</h1>
|
||||||
|
<p>Connect your media apps, choose your settings and make yourself at home.</p>
|
||||||
|
</header>
|
||||||
|
{!ready ? (
|
||||||
|
<p role="status">Checking installation...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p className={styles.notice} role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!status ? (
|
||||||
|
<button type="button" onClick={() => window.location.reload()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
) : forbidden ? (
|
||||||
|
<section className={styles.panel}>
|
||||||
|
<h2>Administrator access required</h2>
|
||||||
|
<p>Ask an administrator to finish installation.</p>
|
||||||
|
<button type="button" disabled={!!busy} onClick={switchAccount}>
|
||||||
|
{busy === "switch-account" ? "Signing out..." : "Sign in with an administrator account"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : !admin ? (
|
||||||
|
<section className={styles.panel}>
|
||||||
|
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
|
||||||
|
<p>
|
||||||
|
{status.needs_admin
|
||||||
|
? "Enter the SETUP_TOKEN from your deployment environment. Only the server operator can create the first administrator."
|
||||||
|
: "Use your local Magent administrator account. Settings are never available to unauthenticated visitors."}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={authenticate} className={styles.account}>
|
||||||
|
{status.needs_admin && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="setup-token">Setup token</label>
|
||||||
|
<input
|
||||||
|
id="setup-token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
minLength={32}
|
||||||
|
maxLength={1024}
|
||||||
|
value={setupToken}
|
||||||
|
onChange={(event) => setSetupToken(event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="setup-username">Username</label>
|
||||||
|
<input
|
||||||
|
id="setup-username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="setup-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="setup-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={status.needs_admin ? "new-password" : "current-password"}
|
||||||
|
required
|
||||||
|
minLength={status.needs_admin ? 12 : undefined}
|
||||||
|
maxLength={1024}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{status.needs_admin && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="setup-confirm">Confirm password</label>
|
||||||
|
<input
|
||||||
|
id="setup-confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(event) => setConfirmation(event.target.value)}
|
||||||
|
disabled={!!busy}
|
||||||
|
/>
|
||||||
|
<p>Use at least 12 characters and a unique password.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={!!busy}>
|
||||||
|
{busy ? "Working..." : status.needs_admin ? "Create administrator" : "Sign in to continue"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{!status.setup_required && <a href="/login?next=/setup">Use Jellyfin sign-in instead</a>}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{state?.completed ? (
|
||||||
|
<p className={styles.notice}>
|
||||||
|
This installation is already set up. You can use this guide to update its connections.{" "}
|
||||||
|
<a href="/admin">Back to settings</a>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={styles.notice}>
|
||||||
|
Your administrator is ready. Background imports and automation are paused until you finish. Already
|
||||||
|
have a backup? <a href="/admin/backups">Restore it here</a>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<nav aria-label="Setup steps" className={styles.steps}>
|
||||||
|
{steps.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
aria-current={step === item.id ? "step" : undefined}
|
||||||
|
disabled={!!busy || item.id === "administrator"}
|
||||||
|
onClick={() => go(item.id)}
|
||||||
|
>
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
{item.id === "administrator" ? "Administrator ready" : item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
go(step === "apps" ? "preferences" : "review");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === "apps" && (
|
||||||
|
<section aria-labelledby="apps-title">
|
||||||
|
<h2 id="apps-title">Connect your apps</h2>
|
||||||
|
<p>
|
||||||
|
Each app is optional. Expand the apps you use, save and test their connections, then continue. In
|
||||||
|
Docker, localhost means the Magent container itself.
|
||||||
|
</p>
|
||||||
|
<div className={styles.apps}>
|
||||||
|
{APPS.map((app) => (
|
||||||
|
<details key={app.id} className={styles.panel}>
|
||||||
|
<summary>
|
||||||
|
<span>
|
||||||
|
<strong>{app.name}</strong>
|
||||||
|
<small>{app.description}</small>
|
||||||
|
</span>
|
||||||
|
<span className={styles.badge}>
|
||||||
|
{checks[app.id]
|
||||||
|
? serviceStatusLabel(checks[app.id].status)
|
||||||
|
: configuredApp(app, settings)
|
||||||
|
? "Configured"
|
||||||
|
: "Optional / not set up"}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className={styles.fields}>{app.fields.map(fieldControl)}</div>
|
||||||
|
<button type="button" disabled={!!busy} onClick={() => test(app)}>
|
||||||
|
{busy === app.id ? "Testing..." : `Save & test ${app.name}`}
|
||||||
|
</button>
|
||||||
|
{checks[app.id]?.message && <p role="status">{checks[app.id].message}</p>}
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{step === "preferences" && (
|
||||||
|
<section aria-labelledby="preferences-title">
|
||||||
|
<h2 id="preferences-title">Choose your preferences</h2>
|
||||||
|
<p>
|
||||||
|
Defaults are loaded from your installation. Advanced notification channels, branding and invite
|
||||||
|
policies are available in Settings afterwards.
|
||||||
|
</p>
|
||||||
|
{PREFERENCES.map((group) => (
|
||||||
|
<section key={group.title} className={styles.panel}>
|
||||||
|
<h3>{group.title}</h3>
|
||||||
|
<div className={styles.fields}>{group.fields.map(fieldControl)}</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{step === "review" && (
|
||||||
|
<section className={styles.panel} aria-labelledby="review-title">
|
||||||
|
<h2 id="review-title">Ready to finish?</h2>
|
||||||
|
<p>Unconfigured apps remain disconnected. You can change every connection later in Settings.</p>
|
||||||
|
<ul className={styles.review}>
|
||||||
|
{APPS.map((app) => (
|
||||||
|
<li key={app.id}>
|
||||||
|
<span>{app.name}</span>
|
||||||
|
<span>
|
||||||
|
{checks[app.id]
|
||||||
|
? serviceStatusLabel(checks[app.id].status)
|
||||||
|
: configuredApp(app, settings)
|
||||||
|
? "Configured (not tested this session)"
|
||||||
|
: "Not configured"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Finishing starts the configured background imports and automation, unless disabled in your
|
||||||
|
deployment. Save an encrypted backup once you have checked the installation.
|
||||||
|
</p>
|
||||||
|
<label className={styles.confirm}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accepted}
|
||||||
|
onChange={(event) => setAccepted(event.target.checked)}
|
||||||
|
disabled={!!busy}
|
||||||
|
/>
|
||||||
|
I have reviewed the connections and want to finish setup.
|
||||||
|
</label>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
You may remove SETUP_TOKEN from your environment after completion. Existing users and invites are
|
||||||
|
preserved.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{step !== "apps" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={() => go(step === "review" ? "preferences" : "apps")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span>{Object.keys(draft).length ? "Unsaved changes" : "Progress is saved"}</span>
|
||||||
|
{step !== "review" ? (
|
||||||
|
<button type="submit" disabled={!!busy}>
|
||||||
|
{busy === "save" ? "Saving..." : "Save & continue"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy || !accepted}
|
||||||
|
onClick={() =>
|
||||||
|
void run("finish", async () => {
|
||||||
|
await save();
|
||||||
|
await requestJson("/setup/complete", { method: "POST" });
|
||||||
|
window.location.assign("/admin");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy === "finish" ? "Finishing..." : "Finish setup"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { APPS, configuredApp, settingsPayload, settingsValues } from "./setup-model";
|
||||||
|
|
||||||
|
describe("installation settings", () => {
|
||||||
|
it("offers every supported media integration", () => {
|
||||||
|
expect(APPS.map((app) => app.id).sort()).toEqual([
|
||||||
|
"bazarr",
|
||||||
|
"jellyfin",
|
||||||
|
"jellystat",
|
||||||
|
"prowlarr",
|
||||||
|
"qbittorrent",
|
||||||
|
"radarr",
|
||||||
|
"seerr",
|
||||||
|
"sonarr",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("never copies saved secrets into the form or overwrites them with a blank", () => {
|
||||||
|
expect(settingsValues([{ key: "sonarr_api_key", value: "secret", sensitive: true, isSet: true }])).toEqual({
|
||||||
|
sonarr_api_key: "",
|
||||||
|
});
|
||||||
|
expect(settingsPayload({ sonarr_api_key: "", sonarr_base_url: "http://sonarr:8989" })).toEqual({
|
||||||
|
sonarr_base_url: "http://sonarr:8989",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("sends only editable fields and validates numeric settings", () => {
|
||||||
|
expect(
|
||||||
|
settingsPayload({ jwt_secret: "no", requests_cleanup_days: "90", site_login_show_signup_link: false }),
|
||||||
|
).toEqual({ requests_cleanup_days: 90, site_login_show_signup_link: false });
|
||||||
|
expect(() => settingsPayload({ requests_cleanup_days: "-1" })).toThrow("whole number");
|
||||||
|
expect(() => settingsPayload({ sonarr_quality_profile_id: "1.5" })).toThrow("whole number");
|
||||||
|
});
|
||||||
|
it("can save just one app without accidentally saving another draft", () => {
|
||||||
|
expect(
|
||||||
|
settingsPayload(
|
||||||
|
{ sonarr_base_url: "http://sonarr:8989", radarr_api_key: "draft-secret" },
|
||||||
|
APPS.find((app) => app.id === "sonarr")?.fields,
|
||||||
|
),
|
||||||
|
).toEqual({ sonarr_base_url: "http://sonarr:8989" });
|
||||||
|
});
|
||||||
|
it("validates URL drafts even when app testing bypasses browser form validation", () => {
|
||||||
|
for (const value of [
|
||||||
|
"sonarr:8989",
|
||||||
|
"/sonarr",
|
||||||
|
"ftp://sonarr:8989",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"http://sonarr/my library",
|
||||||
|
]) {
|
||||||
|
expect(() => settingsPayload({ sonarr_base_url: value })).toThrow("HTTP or HTTPS URL");
|
||||||
|
}
|
||||||
|
expect(() => settingsPayload({ sonarr_base_url: "https://user:secret@sonarr.test" })).toThrow("credential fields");
|
||||||
|
expect(
|
||||||
|
settingsPayload({
|
||||||
|
sonarr_base_url: " http://sonarr:8989 ",
|
||||||
|
magent_application_url: "https://magent.example.test",
|
||||||
|
}),
|
||||||
|
).toEqual({ sonarr_base_url: "http://sonarr:8989", magent_application_url: "https://magent.example.test" });
|
||||||
|
expect(settingsPayload({ sonarr_base_url: "" })).toEqual({ sonarr_base_url: "" });
|
||||||
|
});
|
||||||
|
it("validates sender email and sync time before step navigation saves", () => {
|
||||||
|
for (const value of ["not-an-email", "two@@example.test", "name@example test", "Name <name@example.test>"]) {
|
||||||
|
expect(() => settingsPayload({ magent_notify_email_from_address: value })).toThrow("valid email address");
|
||||||
|
}
|
||||||
|
for (const value of ["24:00", "12:60", "2:30", "02:30:00"]) {
|
||||||
|
expect(() => settingsPayload({ requests_full_sync_time: value })).toThrow("HH:MM");
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
settingsPayload({
|
||||||
|
magent_notify_email_from_address: " alerts+admin@example.test ",
|
||||||
|
requests_full_sync_time: "23:59",
|
||||||
|
}),
|
||||||
|
).toEqual({ magent_notify_email_from_address: "alerts+admin@example.test", requests_full_sync_time: "23:59" });
|
||||||
|
expect(settingsPayload({ magent_notify_email_from_address: "", requests_full_sync_time: "" })).toEqual({
|
||||||
|
magent_notify_email_from_address: "",
|
||||||
|
requests_full_sync_time: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("does not call a URL-only app configured", () => {
|
||||||
|
const app = APPS[0];
|
||||||
|
const url = { key: "jellyfin_base_url", value: "http://jellyfin:8096", sensitive: false, isSet: true };
|
||||||
|
expect(configuredApp(app, [url])).toBe(false);
|
||||||
|
expect(configuredApp(app, [url, { key: "jellyfin_api_key", value: null, sensitive: true, isSet: true }])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
export type SetupStep = "administrator" | "apps" | "preferences" | "review";
|
||||||
|
export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null };
|
||||||
|
export type SetupStatus = { setup_required: boolean; needs_admin: boolean };
|
||||||
|
export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean };
|
||||||
|
export type Values = Record<string, string | boolean>;
|
||||||
|
export type Field = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea";
|
||||||
|
hint?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
};
|
||||||
|
export type AppDefinition = { id: string; name: string; description: string; fields: Field[] };
|
||||||
|
|
||||||
|
const connection = (prefix: string, placeholder: string): Field[] => [
|
||||||
|
{
|
||||||
|
key: `${prefix}_base_url`,
|
||||||
|
label: "Server URL",
|
||||||
|
type: "url",
|
||||||
|
placeholder,
|
||||||
|
hint: "Use an address reachable from the Magent server, not your browser.",
|
||||||
|
},
|
||||||
|
{ key: `${prefix}_api_key`, label: "API key", type: "password" },
|
||||||
|
];
|
||||||
|
const collector = (prefix: string): Field[] => [
|
||||||
|
{
|
||||||
|
key: `${prefix}_quality_profile_id`,
|
||||||
|
label: "Quality profile ID",
|
||||||
|
type: "number",
|
||||||
|
min: 1,
|
||||||
|
hint: "Save and test the connection to load available profiles.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `${prefix}_root_folder`,
|
||||||
|
label: "Root folder",
|
||||||
|
hint: "The library path as seen by this app, for example /tv or /movies.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `${prefix}_qbittorrent_category`,
|
||||||
|
label: "Download category",
|
||||||
|
hint: "Match the category configured in the app's download client.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APPS: AppDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "jellyfin",
|
||||||
|
name: "Jellyfin",
|
||||||
|
description: "Playback, library availability and Jellyfin sign-in.",
|
||||||
|
fields: [
|
||||||
|
...connection("jellyfin", "http://jellyfin:8096"),
|
||||||
|
{
|
||||||
|
key: "jellyfin_public_url",
|
||||||
|
label: "Public playback URL",
|
||||||
|
type: "url",
|
||||||
|
hint: "The address your users open to watch media.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "jellyfin_sync_to_arr",
|
||||||
|
label: "Sync Jellyfin library into Sonarr / Radarr",
|
||||||
|
type: "checkbox",
|
||||||
|
hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "seerr",
|
||||||
|
name: "Seerr",
|
||||||
|
description: "Requests, approvals and request history (including Jellyseerr).",
|
||||||
|
fields: connection("jellyseerr", "http://seerr:5055"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sonarr",
|
||||||
|
name: "Sonarr",
|
||||||
|
description: "TV requests, seasons and collection progress.",
|
||||||
|
fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "radarr",
|
||||||
|
name: "Radarr",
|
||||||
|
description: "Movie requests and collection progress.",
|
||||||
|
fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prowlarr",
|
||||||
|
name: "Prowlarr",
|
||||||
|
description: "Indexer searches and release discovery.",
|
||||||
|
fields: connection("prowlarr", "http://prowlarr:9696"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qbittorrent",
|
||||||
|
name: "qBittorrent",
|
||||||
|
description: "Download progress and recovery actions.",
|
||||||
|
fields: [
|
||||||
|
{ key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" },
|
||||||
|
{ key: "qbittorrent_username", label: "Username" },
|
||||||
|
{ key: "qbittorrent_password", label: "Password", type: "password" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bazarr",
|
||||||
|
name: "Bazarr",
|
||||||
|
description: "Optional subtitle searches and repairs.",
|
||||||
|
fields: [
|
||||||
|
...connection("bazarr", "http://bazarr:6767"),
|
||||||
|
{ key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jellystat",
|
||||||
|
name: "Jellystat",
|
||||||
|
description: "Optional personal viewing statistics.",
|
||||||
|
fields: connection("jellystat", "http://jellystat:3000"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PREFERENCES: { title: string; fields: Field[] }[] = [
|
||||||
|
{
|
||||||
|
title: "Site & access",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "magent_application_url",
|
||||||
|
label: "Public Magent URL",
|
||||||
|
type: "url",
|
||||||
|
hint: "Used in invite and notification links. Set CORS_ALLOW_ORIGIN in your environment to the same origin; changing this field does not change CORS.",
|
||||||
|
},
|
||||||
|
{ key: "site_login_message", label: "Login page message", type: "textarea" },
|
||||||
|
{
|
||||||
|
key: "site_login_show_local_login",
|
||||||
|
label: "Show Magent account sign-in",
|
||||||
|
type: "checkbox",
|
||||||
|
hint: "Keep this enabled for local administrator access.",
|
||||||
|
},
|
||||||
|
{ key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" },
|
||||||
|
{
|
||||||
|
key: "site_login_show_signup_link",
|
||||||
|
label: "Show invite signup link",
|
||||||
|
type: "checkbox",
|
||||||
|
hint: "Account creation still requires a valid invite. This does not open public registration.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Request updates",
|
||||||
|
fields: [
|
||||||
|
{ key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 },
|
||||||
|
{
|
||||||
|
key: "requests_delta_sync_interval_minutes",
|
||||||
|
label: "Incremental sync interval (minutes)",
|
||||||
|
type: "number",
|
||||||
|
min: 1,
|
||||||
|
},
|
||||||
|
{ key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" },
|
||||||
|
{ key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Email (optional)",
|
||||||
|
fields: [
|
||||||
|
{ key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" },
|
||||||
|
{
|
||||||
|
key: "magent_notify_email_enabled",
|
||||||
|
label: "Enable email delivery",
|
||||||
|
type: "checkbox",
|
||||||
|
hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.",
|
||||||
|
},
|
||||||
|
{ key: "magent_notify_email_smtp_host", label: "SMTP hostname" },
|
||||||
|
{ key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 },
|
||||||
|
{ key: "magent_notify_email_smtp_username", label: "SMTP username" },
|
||||||
|
{ key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" },
|
||||||
|
{ key: "magent_notify_email_from_address", label: "Sender email", type: "email" },
|
||||||
|
{ key: "magent_notify_email_from_name", label: "Sender name" },
|
||||||
|
{ key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" },
|
||||||
|
{
|
||||||
|
key: "magent_notify_email_use_ssl",
|
||||||
|
label: "Use implicit TLS (usually port 465)",
|
||||||
|
type: "checkbox",
|
||||||
|
hint: "Choose either STARTTLS or implicit TLS, not both.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
|
||||||
|
|
||||||
|
export function settingsValues(settings: Setting[]): Values {
|
||||||
|
const values: Values = {};
|
||||||
|
for (const field of ALL_FIELDS) {
|
||||||
|
const setting = settings.find((candidate) => candidate.key === field.key);
|
||||||
|
if (!setting) continue;
|
||||||
|
values[field.key] =
|
||||||
|
field.type === "password" || setting.sensitive
|
||||||
|
? ""
|
||||||
|
: field.type === "checkbox"
|
||||||
|
? setting.value === true || setting.value === "true" || setting.value === "1"
|
||||||
|
: String(setting.value ?? "");
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only explicitly edited fields are sent. A blank password never clears a saved
|
||||||
|
// secret (masked values from the settings endpoint are not actual credentials).
|
||||||
|
export function settingsPayload(
|
||||||
|
draft: Values,
|
||||||
|
fields: Field[] = ALL_FIELDS,
|
||||||
|
): Record<string, string | boolean | number> {
|
||||||
|
const payload: Record<string, string | boolean | number> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = draft[field.key];
|
||||||
|
if (value === undefined || (field.type === "password" && !String(value).trim())) continue;
|
||||||
|
if (field.type === "url" || field.type === "email" || field.type === "time") {
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (text && field.type === "url") {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!/^https?:\/\//i.test(text) ||
|
||||||
|
!["http:", "https:"].includes(url.protocol) ||
|
||||||
|
!url.hostname ||
|
||||||
|
/\s/.test(text)
|
||||||
|
) {
|
||||||
|
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
|
||||||
|
}
|
||||||
|
if (url.username || url.password)
|
||||||
|
throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
field.type === "email" &&
|
||||||
|
!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(`${field.label} must be a valid email address.`);
|
||||||
|
}
|
||||||
|
if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
|
||||||
|
throw new Error(`${field.label} must be a valid time in HH:MM format.`);
|
||||||
|
}
|
||||||
|
payload[field.key] = text;
|
||||||
|
} else if (field.type === "number" && value !== "") {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) {
|
||||||
|
throw new Error(
|
||||||
|
`${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
payload[field.key] = number;
|
||||||
|
} else payload[field.key] = value;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configuredApp(app: AppDefinition, settings: Setting[]): boolean {
|
||||||
|
return app.fields
|
||||||
|
.filter((field) => field.key.endsWith("_base_url") || field.type === "password")
|
||||||
|
.every((field) => settings.some((setting) => setting.key === field.key && setting.isSet));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
|
||||||
|
.heading { margin-bottom: 30px; }
|
||||||
|
.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
|
||||||
|
.setup p { color: var(--ops-muted); line-height: 1.6; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
|
||||||
|
.brand svg { width: 38px; height: 38px; }
|
||||||
|
.panel { padding: 24px; margin: 16px 0; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); min-width: 0; }
|
||||||
|
.panel h2, .panel h3 { margin-top: 0; }
|
||||||
|
.panel summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; list-style: none; }
|
||||||
|
.panel summary::after { content: "+"; color: var(--ops-primary-2); }
|
||||||
|
.panel[open] summary::after { content: "−"; }
|
||||||
|
.panel summary > span:first-child { flex: 1; }
|
||||||
|
.panel summary strong { display: block; font-size: 17px; }
|
||||||
|
.panel summary small { display: block; margin-top: 6px; color: var(--ops-muted); line-height: 1.5; }
|
||||||
|
.panel[open] summary { margin-bottom: 24px; }
|
||||||
|
.badge { font-size: 12px; color: var(--ops-primary-2); }
|
||||||
|
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||||
|
.field label { color: var(--ops-text); font-size: 13px; }
|
||||||
|
.field label small { margin-left: 8px; color: var(--ops-green); }
|
||||||
|
.field p, .hint { font-size: 12px; margin: 0; }
|
||||||
|
.field input:not([type=checkbox]), .field textarea, .field select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); border-radius: 8px; font: inherit; font-size: 14px; }
|
||||||
|
.field textarea { resize: vertical; }
|
||||||
|
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
|
||||||
|
.toggle p { grid-column: 1 / -1; }
|
||||||
|
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
|
||||||
|
.account { display: grid; gap: 20px; max-width: 440px; margin: 24px 0; }
|
||||||
|
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 30px; }
|
||||||
|
.steps button { flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px; background: var(--ops-panel); color: var(--ops-muted); border: 1px solid var(--ops-line); box-shadow: none; }
|
||||||
|
.steps button[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
|
||||||
|
.steps button span { font-size: 12px; }
|
||||||
|
.actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--ops-line); }
|
||||||
|
.actions > span { flex: 1; color: var(--ops-muted); font-size: 12px; }
|
||||||
|
.error, .notice { padding: 16px 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-bg-2); overflow-wrap: anywhere; }
|
||||||
|
.setup .error { border-color: var(--ops-red); color: var(--ops-red); }
|
||||||
|
.review { list-style: none; padding: 0; margin: 24px 0; }
|
||||||
|
.review li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--ops-line); }
|
||||||
|
.review li span:last-child { font-size: 13px; color: var(--ops-muted); text-align: right; }
|
||||||
|
.confirm { display: flex; align-items: center; gap: 12px; margin: 24px 0; }
|
||||||
|
.setup :is(button, input, textarea, select, a, summary):focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 3px; }
|
||||||
|
.setup button:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.setup { margin-top: 20px; padding: 0 4px; }
|
||||||
|
.fields { grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
.panel { padding: 18px; }
|
||||||
|
.steps button { flex-basis: 42%; font-size: 12px; }
|
||||||
|
.badge { max-width: 100px; text-align: right; }
|
||||||
|
.panel summary { gap: 10px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { isAdminPage } from "../lib/user-view-policy";
|
||||||
|
import { setUserViewPreview, useUserViewState } from "../lib/viewMode";
|
||||||
|
|
||||||
|
export default function AdminViewGate({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { enabled, ready } = useUserViewState();
|
||||||
|
if (!isAdminPage(pathname)) return children;
|
||||||
|
if (!ready)
|
||||||
|
return (
|
||||||
|
<main className="card" role="status">
|
||||||
|
Checking view mode...
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
if (!enabled) return children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="card">
|
||||||
|
<h1>Administrator tools are hidden</h1>
|
||||||
|
<p>Configuration, user management and other admin tools are unavailable while previewing user view.</p>
|
||||||
|
<p>Your account is unchanged. Exit the preview to return to this page.</p>
|
||||||
|
<div className="config-inline-controls">
|
||||||
|
<a href="/">Go to My Requests</a>
|
||||||
|
<button type="button" onClick={() => setUserViewPreview(false)}>
|
||||||
|
Exit user view
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export default function ApplicationChrome() {
|
|||||||
"/welcome",
|
"/welcome",
|
||||||
"/coming-soon",
|
"/coming-soon",
|
||||||
"/login",
|
"/login",
|
||||||
|
"/setup",
|
||||||
"/forgot-password",
|
"/forgot-password",
|
||||||
"/reset-password",
|
"/reset-password",
|
||||||
"/signup",
|
"/signup",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { authFetch, getApiBase, getToken } from "../lib/auth";
|
import { authFetch, getApiBase, getToken } from "../lib/auth";
|
||||||
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
|
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
|
||||||
|
import { useEffectiveRole } from "../lib/viewMode";
|
||||||
|
import { isAdminPage } from "../lib/user-view-policy";
|
||||||
|
|
||||||
export function useFeatureUser() {
|
export function useFeatureUser() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -11,6 +13,7 @@ export function useFeatureUser() {
|
|||||||
path: string;
|
path: string;
|
||||||
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
|
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
|
||||||
}>({ path: "", user: null });
|
}>({ path: "", user: null });
|
||||||
|
const role = useEffectiveRole(state.user?.role);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -33,13 +36,26 @@ export function useFeatureUser() {
|
|||||||
window.removeEventListener("focus", load);
|
window.removeEventListener("focus", load);
|
||||||
};
|
};
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
return { user: state.user, ready: state.path === pathname };
|
return { user: state.user ? { ...state.user, role: role ?? undefined } : null, ready: state.path === pathname };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeatureGate({ children }: { children: ReactNode }) {
|
export default function FeatureGate({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, ready } = useFeatureUser();
|
const { user, ready } = useFeatureUser();
|
||||||
const feature = featureForPath(pathname);
|
const feature = featureForPath(pathname);
|
||||||
|
if (isAdminPage(pathname, false)) {
|
||||||
|
if (!ready) return <main className="card">Checking administrator access...</main>;
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<main className="card">
|
||||||
|
<h1>Administrator access required</h1>
|
||||||
|
<p>Sign in with an administrator account to use configuration and administration tools.</p>
|
||||||
|
<a href="/login">Sign in</a>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
if (!feature) return children;
|
if (!feature) return children;
|
||||||
if (!ready) return <main className="card">Loading account access...</main>;
|
if (!ready) return <main className="card">Loading account access...</main>;
|
||||||
if (!getToken()) return children;
|
if (!getToken()) return children;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
|
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
|
||||||
import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
|
import { setUserViewPreview, useEffectiveRole, useUserViewPreview } from "../lib/viewMode";
|
||||||
|
|
||||||
export default function HeaderIdentity() {
|
export default function HeaderIdentity() {
|
||||||
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
|
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
|
||||||
const [buildNumber, setBuildNumber] = useState<string | null>(null);
|
const [buildNumber, setBuildNumber] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const viewAsUser = useUserViewPreview();
|
const viewAsUser = useUserViewPreview();
|
||||||
|
const visibleRole = useEffectiveRole(identity?.role);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -102,7 +103,7 @@ export default function HeaderIdentity() {
|
|||||||
<a href="/profile" onClick={() => setOpen(false)}>
|
<a href="/profile" onClick={() => setOpen(false)}>
|
||||||
My profile
|
My profile
|
||||||
</a>
|
</a>
|
||||||
{identity.role === "admin" ? (
|
{visibleRole === "admin" ? (
|
||||||
<a href="/admin" onClick={() => setOpen(false)}>
|
<a href="/admin" onClick={() => setOpen(false)}>
|
||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { requestJson } from "../lib/api-client";
|
||||||
|
import { authFetch } from "../lib/auth";
|
||||||
|
|
||||||
|
// Backup access stays available so a fresh installation can be restored before
|
||||||
|
// connecting any apps. This is navigation only; the API enforces admin access.
|
||||||
|
export default function SetupGate({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const bypass = pathname === "/setup" || pathname === "/admin/backups";
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bypass) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
void requestJson<{ setup_required: boolean }>(
|
||||||
|
"/setup/status",
|
||||||
|
{ signal: controller.signal, cache: "no-store" },
|
||||||
|
authFetch,
|
||||||
|
)
|
||||||
|
.then((status) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
if (status.setup_required) router.replace("/setup");
|
||||||
|
else setChecked(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Never hide an existing installation during an API outage or rollout.
|
||||||
|
if (!controller.signal.aborted) setChecked(true);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [bypass, router]);
|
||||||
|
|
||||||
|
if (bypass || checked) return children;
|
||||||
|
return (
|
||||||
|
<main className="card" role="status">
|
||||||
|
Checking installation...
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ export default function UserViewBanner() {
|
|||||||
<div className="user-view-banner" role="status">
|
<div className="user-view-banner" role="status">
|
||||||
<div>
|
<div>
|
||||||
<strong>User view</strong>
|
<strong>User view</strong>
|
||||||
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
|
<span>
|
||||||
|
Admin controls are hidden. You are still using your own account and data; backend permissions are unchanged.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setUserViewPreview(false)}>
|
<button type="button" onClick={() => setUserViewPreview(false)}>
|
||||||
Exit user view
|
Exit user view
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || "http://backend:8000";
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
compress: true,
|
compress: true,
|
||||||
experimental: { proxyTimeout: 180000 },
|
// API rewrites clone bodies even when excluded from proxy.ts's matcher.
|
||||||
|
// Match the backend restore envelope cap (32 MiB backup + multipart margin).
|
||||||
|
experimental: { proxyTimeout: 180000, proxyClientMaxBodySize: 34 * 1024 * 1024 },
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
// Fixture-only installation review. All API calls are intercepted; no backups are restored.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright');
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3114';
|
||||||
|
const output = process.env.REVIEW_DIR;
|
||||||
|
|
||||||
|
async function reviewBackups(browser) {
|
||||||
|
const context = await browser.newContext({ acceptDownloads: true });
|
||||||
|
try {
|
||||||
|
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
let role = 'admin';
|
||||||
|
let failStatus = false;
|
||||||
|
let failRestore = false;
|
||||||
|
const backup = { created_at: '2026-09-18T01:00:00Z', build: 'fixture-build', include_cache: true };
|
||||||
|
const status = {
|
||||||
|
format_version: 1,
|
||||||
|
max_upload_bytes: 1024,
|
||||||
|
max_expanded_bytes: 134217728,
|
||||||
|
include_cache_default: false,
|
||||||
|
pending_restore: null,
|
||||||
|
last_restore: null,
|
||||||
|
};
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const pathname = new URL(request.url()).pathname;
|
||||||
|
const method = request.method();
|
||||||
|
const json = request.headers()['content-type']?.includes('application/json') ? request.postDataJSON() : null;
|
||||||
|
calls.push({ pathname, method, json, body: request.postDataBuffer() });
|
||||||
|
const reply = (value) => route.fulfill({ json: value });
|
||||||
|
if (pathname === '/api/setup/status') return reply({ setup_required: false, needs_admin: false });
|
||||||
|
if (pathname === '/api/auth/me') {
|
||||||
|
return reply({ username: 'Fixture admin', role, features: {}, invite_management_enabled: true });
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/api/admin/backups')) {
|
||||||
|
if (role !== 'admin') {
|
||||||
|
return route.fulfill({ status: role === 'unauthorized' ? 401 : 403, json: { detail: 'Admin access required.' } });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/backups' && method === 'GET') {
|
||||||
|
if (failStatus) return route.fulfill({ status: 503, json: { detail: 'Backup service unavailable.' } });
|
||||||
|
return reply(status);
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/backups/export' && method === 'POST') {
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
headers: { 'Content-Disposition': 'attachment; filename="magent-backup-fixture.magent-backup"' },
|
||||||
|
body: Buffer.from('fixture-only-backup-download'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/backups/restore' && method === 'POST') {
|
||||||
|
if (failRestore) return route.fulfill({ status: 400, json: { detail: 'Invalid backup or passphrase.' } });
|
||||||
|
status.pending_restore = { ...backup, staged_at: '2026-09-18T02:00:00Z' };
|
||||||
|
return reply({ status: 'staged', restart_required: true, backup, message: 'Restart to apply.' });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/backups/restore' && method === 'DELETE') {
|
||||||
|
status.pending_restore = null;
|
||||||
|
return reply({ status: 'cancelled' });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected backup API call: ${method} ${pathname}`);
|
||||||
|
}
|
||||||
|
if (pathname.includes('/events/stream')) {
|
||||||
|
return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
|
||||||
|
}
|
||||||
|
if (pathname.includes('/branding/')) return route.fulfill({ status: 404 });
|
||||||
|
return reply({ items: [], total: 0, services: [], navigation: { showRequests: true } });
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const exportPanel = page.getByRole('region', { name: 'Create a backup', exact: true });
|
||||||
|
const restorePanel = page.getByRole('region', { name: 'Restore a backup', exact: true });
|
||||||
|
const exportCalls = () => calls.filter((call) => call.pathname === '/api/admin/backups/export');
|
||||||
|
const restoreCalls = () => calls.filter((call) => call.pathname === '/api/admin/backups/restore' && call.method === 'POST');
|
||||||
|
const screenshot = async (name) => {
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, name), fullPage: true });
|
||||||
|
};
|
||||||
|
for (const width of [1440, 980, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await page.goto(`${base}/admin/backups`);
|
||||||
|
await exportPanel.getByRole('button', { name: 'Download encrypted backup', exact: true }).waitFor();
|
||||||
|
assert.equal(await exportPanel.getByLabel('Include artwork caches', { exact: true }).isChecked(), false);
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Backups overflow at ${width}px`);
|
||||||
|
await screenshot(`installation-backups-${width}.png`);
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await exportPanel.getByLabel('Backup passphrase', { exact: true }).fill('fixture-passphrase-alpha');
|
||||||
|
await exportPanel.getByLabel('Confirm backup passphrase', { exact: true }).fill('fixture-passphrase-other');
|
||||||
|
await exportPanel.getByRole('button', { name: 'Download encrypted backup', exact: true }).click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'passphrases do not match' }).waitFor();
|
||||||
|
assert.equal(exportCalls().length, 0, 'Mismatched passphrases must not request a backup');
|
||||||
|
|
||||||
|
await exportPanel.getByLabel('Confirm backup passphrase', { exact: true }).fill('fixture-passphrase-alpha');
|
||||||
|
await exportPanel.getByLabel('Include artwork caches', { exact: true }).check();
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
exportPanel.getByRole('button', { name: 'Download encrypted backup', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
assert.equal(download.suggestedFilename(), 'magent-backup-fixture.magent-backup');
|
||||||
|
await page.getByRole('status').filter({ hasText: 'encrypted backup is ready' }).waitFor();
|
||||||
|
assert.deepEqual(exportCalls().map((call) => call.json), [{ passphrase: 'fixture-passphrase-alpha', include_cache: true }]);
|
||||||
|
assert.equal(await exportPanel.getByLabel('Backup passphrase', { exact: true }).inputValue(), '');
|
||||||
|
assert.equal(await exportPanel.getByLabel('Confirm backup passphrase', { exact: true }).inputValue(), '');
|
||||||
|
|
||||||
|
const upload = restorePanel.getByLabel('Backup file', { exact: true });
|
||||||
|
const confirmation = restorePanel.getByLabel('Type RESTORE to confirm replacement', { exact: true });
|
||||||
|
const prepare = restorePanel.getByRole('button', { name: 'Prepare restore', exact: true });
|
||||||
|
await restorePanel.getByLabel('Backup passphrase', { exact: true }).fill('fixture-passphrase-alpha');
|
||||||
|
await confirmation.fill('RESTORE');
|
||||||
|
await upload.setInputFiles({ name: 'empty.magent-backup', mimeType: 'application/octet-stream', buffer: Buffer.alloc(0) });
|
||||||
|
await prepare.click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Choose a Magent backup file' }).waitFor();
|
||||||
|
assert.equal(restoreCalls().length, 0, 'Empty files must not be uploaded');
|
||||||
|
|
||||||
|
await upload.setInputFiles({ name: 'oversize.magent-backup', mimeType: 'application/octet-stream', buffer: Buffer.alloc(1025) });
|
||||||
|
await prepare.click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'no larger than' }).waitFor();
|
||||||
|
assert.equal(restoreCalls().length, 0, 'Files exceeding the server limit must not be uploaded');
|
||||||
|
|
||||||
|
const fixtureFile = { name: 'fixture.magent-backup', mimeType: 'application/octet-stream', buffer: Buffer.from('fixture-backup-upload') };
|
||||||
|
await upload.setInputFiles(fixtureFile);
|
||||||
|
await confirmation.fill('restore');
|
||||||
|
await prepare.click();
|
||||||
|
assert.equal(await confirmation.evaluate((element) => element.validity.patternMismatch), true);
|
||||||
|
assert.equal(restoreCalls().length, 0, 'RESTORE must be typed exactly before uploading');
|
||||||
|
|
||||||
|
await confirmation.fill('RESTORE');
|
||||||
|
failRestore = true;
|
||||||
|
await prepare.click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Invalid backup or passphrase.' }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('heading', { name: 'Restore ready — restart required', exact: true }).count(), 0);
|
||||||
|
assert.equal(await prepare.isEnabled(), true, 'A rejected backup must leave the form usable');
|
||||||
|
|
||||||
|
failRestore = false;
|
||||||
|
await prepare.click();
|
||||||
|
await page.getByRole('heading', { name: 'Restore ready — restart required', exact: true }).waitFor();
|
||||||
|
assert.equal(restoreCalls().length, 2);
|
||||||
|
const submitted = restoreCalls()[1].body.toString('utf8');
|
||||||
|
assert.match(submitted, /name="file"; filename="fixture\.magent-backup"/);
|
||||||
|
assert.match(submitted, /name="passphrase"\r\n\r\nfixture-passphrase-alpha/);
|
||||||
|
assert.match(submitted, /name="confirmation"\r\n\r\nRESTORE/);
|
||||||
|
assert.equal(await prepare.isDisabled(), true, 'A pending restore must block a second upload');
|
||||||
|
assert.equal(await restorePanel.getByLabel('Backup passphrase', { exact: true }).inputValue(), '');
|
||||||
|
assert.equal(await confirmation.inputValue(), '');
|
||||||
|
assert.equal(await upload.evaluate((element) => element.files.length), 0);
|
||||||
|
await screenshot('installation-backups-pending.png');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('heading', { name: 'Restore ready — restart required', exact: true }).waitFor();
|
||||||
|
assert.equal(await prepare.isDisabled(), true, 'Pending state must survive reloading the page');
|
||||||
|
await page.getByRole('button', { name: 'Cancel pending restore', exact: true }).click();
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Pending restore cancelled.' }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('heading', { name: 'Restore ready — restart required', exact: true }).count(), 0);
|
||||||
|
assert.equal(await prepare.isEnabled(), true);
|
||||||
|
assert.equal(calls.filter((call) => call.pathname === '/api/admin/backups/restore' && call.method === 'DELETE').length, 1);
|
||||||
|
|
||||||
|
failStatus = true;
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Backup service unavailable.' }).waitFor();
|
||||||
|
assert.equal(await exportPanel.count(), 0, 'Backup controls must not render without authenticated status');
|
||||||
|
failStatus = false;
|
||||||
|
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||||
|
await exportPanel.getByRole('button', { name: 'Download encrypted backup', exact: true }).waitFor();
|
||||||
|
|
||||||
|
status.last_restore = {
|
||||||
|
status: 'rolled_back', restored_at: '2026-09-18T04:00:00Z', rollback_directory: 'fixture-rollback',
|
||||||
|
message: 'An interrupted or failed restore was rolled back automatically.',
|
||||||
|
};
|
||||||
|
await page.reload();
|
||||||
|
await page.getByText(/Last restore was rolled back/).waitFor();
|
||||||
|
assert.equal(await page.getByText(/Last restore completed/).count(), 0, 'A rollback must not be labelled a successful restore');
|
||||||
|
|
||||||
|
role = 'user';
|
||||||
|
await page.goto(`${base}/admin/backups`);
|
||||||
|
await page.getByRole('heading', { name: 'Administrator access required', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('heading', { name: 'Create a backup', exact: true }).count(), 0);
|
||||||
|
role = 'unauthorized';
|
||||||
|
await page.goto(`${base}/admin/backups`);
|
||||||
|
await page.getByRole('heading', { name: 'Administrator access required', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('link', { name: 'Sign in', exact: true }).getAttribute('href'), '/login');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.equal(calls.some((call) => /restart/.test(call.pathname)), false, 'Preparing a restore must not restart the app');
|
||||||
|
console.log('Backup UI passed: responsive layouts, passphrase confirmation, encrypted download, file bounds, RESTORE confirmation, rejected backup retry, pending/cancel state, and admin access.');
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviewSetup(browser) {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
try {
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
const securityErrors = [];
|
||||||
|
let authenticated = false;
|
||||||
|
let role = 'admin';
|
||||||
|
let needsAdmin = true;
|
||||||
|
let failBootstrap = false;
|
||||||
|
let failComplete = false;
|
||||||
|
let expireNextSave = false;
|
||||||
|
let expectedLoginPassword = 'Fixture-administrator-passphrase';
|
||||||
|
const state = { completed: false, step: 'administrator', completed_at: null };
|
||||||
|
const settings = new Map(Object.entries({
|
||||||
|
site_login_show_local_login: true,
|
||||||
|
site_login_show_jellyfin_login: false,
|
||||||
|
site_login_show_signup_link: true,
|
||||||
|
magent_notify_email_use_tls: true,
|
||||||
|
magent_notify_email_use_ssl: false,
|
||||||
|
magent_notify_email_smtp_port: 587,
|
||||||
|
requests_poll_interval_seconds: 30,
|
||||||
|
requests_delta_sync_interval_minutes: 15,
|
||||||
|
requests_full_sync_time: '03:00',
|
||||||
|
requests_cleanup_days: 30,
|
||||||
|
}));
|
||||||
|
const secret = (key) => key.endsWith('_api_key') || key.endsWith('_password');
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const pathname = new URL(request.url()).pathname;
|
||||||
|
const method = request.method();
|
||||||
|
const json = request.headers()['content-type']?.includes('application/json') ? request.postDataJSON() : null;
|
||||||
|
calls.push({ pathname, method, json, body: request.postData() });
|
||||||
|
const reply = (value) => route.fulfill({ json: value });
|
||||||
|
if (pathname === '/api/setup/status') return reply({ setup_required: !state.completed, needs_admin: needsAdmin });
|
||||||
|
if (pathname === '/api/setup/bootstrap') {
|
||||||
|
if (failBootstrap) return route.fulfill({ status: 403, json: { detail: 'Invalid setup token.' } });
|
||||||
|
assert.equal(needsAdmin, true, 'An existing administrator must never be recreated');
|
||||||
|
needsAdmin = false;
|
||||||
|
state.step = 'apps';
|
||||||
|
return route.fulfill({ status: 201, json: { status: 'created', username: json.username } });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/auth/login') {
|
||||||
|
const credentials = new URLSearchParams(request.postData());
|
||||||
|
assert.equal(credentials.get('username'), 'fixture-admin');
|
||||||
|
assert.equal(credentials.get('password'), expectedLoginPassword);
|
||||||
|
authenticated = true;
|
||||||
|
return reply({ authenticated: true, user: { role } });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/auth/logout') {
|
||||||
|
authenticated = false;
|
||||||
|
return reply({ status: 'ok' });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/auth/me') {
|
||||||
|
return authenticated
|
||||||
|
? reply({ username: 'Fixture admin', role, features: {}, invite_management_enabled: true })
|
||||||
|
: route.fulfill({ status: 401, json: { detail: 'Not authenticated.' } });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/settings' && method === 'PUT' && expireNextSave) {
|
||||||
|
expireNextSave = false;
|
||||||
|
authenticated = false;
|
||||||
|
return route.fulfill({ status: 401, json: { detail: 'Session expired.' } });
|
||||||
|
}
|
||||||
|
if (pathname === '/api/setup/state' || pathname === '/api/setup/complete' || pathname.startsWith('/api/admin/')) {
|
||||||
|
assert.equal(authenticated, true, `Unauthenticated access attempted: ${pathname}`);
|
||||||
|
assert.equal(role, 'admin', `Non-admin access attempted: ${pathname}`);
|
||||||
|
}
|
||||||
|
if (pathname === '/api/setup/state') {
|
||||||
|
if (method === 'PUT') state.step = json.step;
|
||||||
|
return reply(state);
|
||||||
|
}
|
||||||
|
if (pathname === '/api/setup/complete') {
|
||||||
|
if (failComplete) return route.fulfill({ status: 503, json: { detail: 'Could not finish setup. Please retry.' } });
|
||||||
|
state.completed = true;
|
||||||
|
state.completed_at = '2026-09-18T03:00:00Z';
|
||||||
|
return reply(state);
|
||||||
|
}
|
||||||
|
if (pathname === '/api/admin/settings') {
|
||||||
|
if (method === 'PUT') for (const [key, value] of Object.entries(json)) settings.set(key, value);
|
||||||
|
return reply({ settings: Array.from(settings, ([key, value]) => ({
|
||||||
|
key, value: secret(key) ? '********' : value, sensitive: secret(key), isSet: value !== '' && value !== null,
|
||||||
|
})) });
|
||||||
|
}
|
||||||
|
if (/\/status\/services\/[^/]+\/test$/.test(pathname)) return reply({ status: 'up', message: 'Fixture connection succeeded.' });
|
||||||
|
if (/\/admin\/(sonarr|radarr)\/options$/.test(pathname)) {
|
||||||
|
return reply({ rootFolders: [{ path: '/library/tv' }], qualityProfiles: [{ id: 8, name: 'HD 1080p' }] });
|
||||||
|
}
|
||||||
|
if (pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
|
||||||
|
if (pathname.includes('/branding/')) return route.fulfill({ status: 404 });
|
||||||
|
return reply({ items: [], total: 0, services: [], navigation: { showRequests: true } });
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('console', (entry) => {
|
||||||
|
if (/content security policy|blocked by cors policy/i.test(entry.text())) securityErrors.push(entry.text());
|
||||||
|
});
|
||||||
|
const bootstrapCalls = () => calls.filter((call) => call.pathname === '/api/setup/bootstrap');
|
||||||
|
const writes = () => calls.filter((call) => call.pathname === '/api/admin/settings' && call.method === 'PUT');
|
||||||
|
const screenshot = async (name) => {
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `Setup overflow: ${name}`);
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, name), fullPage: true });
|
||||||
|
};
|
||||||
|
const appPanel = (name) => page.locator('details').filter({ has: page.getByText(name, { exact: true }) });
|
||||||
|
const continueButton = page.getByRole('button', { name: 'Save & continue', exact: true });
|
||||||
|
|
||||||
|
await page.goto(`${base}/welcome`);
|
||||||
|
await page.waitForURL(`${base}/setup`);
|
||||||
|
await page.getByRole('heading', { name: 'Create your administrator', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.locator('.header').count(), 0, 'Setup must not display account navigation before installation');
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await screenshot(`installation-administrator-${width}.png`);
|
||||||
|
}
|
||||||
|
await page.getByLabel('Setup token', { exact: true }).fill('fixture-operator-token-0123456789abcdef');
|
||||||
|
await page.getByLabel('Username', { exact: true }).fill('fixture-admin');
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(' Fixture-administrator-passphrase ');
|
||||||
|
await page.getByLabel('Confirm password', { exact: true }).fill('Different-administrator-passphrase');
|
||||||
|
await page.getByRole('button', { name: 'Create administrator', exact: true }).click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'passwords do not match' }).waitFor();
|
||||||
|
assert.equal(bootstrapCalls().length, 0);
|
||||||
|
await page.getByLabel('Confirm password', { exact: true }).fill(' Fixture-administrator-passphrase ');
|
||||||
|
failBootstrap = true;
|
||||||
|
await page.getByRole('button', { name: 'Create administrator', exact: true }).click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Invalid setup token.' }).waitFor();
|
||||||
|
assert.equal(authenticated, false);
|
||||||
|
failBootstrap = false;
|
||||||
|
await page.getByRole('button', { name: 'Create administrator', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Connect your apps', exact: true }).waitFor();
|
||||||
|
assert.equal(bootstrapCalls().length, 2);
|
||||||
|
assert.equal(needsAdmin, false);
|
||||||
|
assert.equal((await context.cookies()).some((cookie) => cookie.name === 'magent_logged_in'), true);
|
||||||
|
assert.equal(await page.getByLabel('Setup token', { exact: true }).count(), 0);
|
||||||
|
assert.equal(await page.getByRole('link', { name: 'Restore it here', exact: true }).getAttribute('href'), '/admin/backups');
|
||||||
|
assert.equal(await page.locator('details').count(), 8, 'Every supported app must appear');
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await screenshot(`installation-apps-${width}.png`);
|
||||||
|
}
|
||||||
|
const jellyfin = appPanel('Jellyfin');
|
||||||
|
await jellyfin.locator('summary').click();
|
||||||
|
await jellyfin.getByLabel('Server URL', { exact: true }).fill('jellyfin:8096');
|
||||||
|
await jellyfin.getByRole('button', { name: 'Save & test Jellyfin', exact: true }).click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'HTTP or HTTPS URL' }).waitFor();
|
||||||
|
assert.equal(writes().length, 0, 'Save and test must validate URLs without relying on native submit validation');
|
||||||
|
await page.getByRole('navigation', { name: 'Setup steps', exact: true }).getByRole('button', { name: 'Preferences' }).click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'HTTP or HTTPS URL' }).waitFor();
|
||||||
|
assert.equal(writes().length, 0, 'Step navigation must validate URL drafts too');
|
||||||
|
await jellyfin.getByLabel('Server URL', { exact: true }).fill('http://jellyfin:8096');
|
||||||
|
await jellyfin.getByLabel('API key', { exact: true }).fill('fixture-jellyfin-secret');
|
||||||
|
await jellyfin.getByLabel('Public playback URL', { exact: true }).fill('https://watch.example.test');
|
||||||
|
await jellyfin.getByRole('button', { name: 'Save & test Jellyfin', exact: true }).click();
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Jellyfin: Connected' }).waitFor();
|
||||||
|
assert.deepEqual(writes()[0].json, {
|
||||||
|
jellyfin_base_url: 'http://jellyfin:8096', jellyfin_api_key: 'fixture-jellyfin-secret', jellyfin_public_url: 'https://watch.example.test',
|
||||||
|
});
|
||||||
|
assert.equal(await page.locator('#setup-jellyfin_api_key').inputValue(), '', 'Saved API keys must not be echoed into the form');
|
||||||
|
assert.match(await page.locator('#setup-jellyfin_api_key').getAttribute('placeholder'), /Leave blank to keep/);
|
||||||
|
await jellyfin.getByLabel('Public playback URL', { exact: true }).fill('https://new-watch.example.test');
|
||||||
|
await jellyfin.getByRole('button', { name: 'Save & test Jellyfin', exact: true }).click();
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Jellyfin: Connected' }).waitFor();
|
||||||
|
assert.deepEqual(writes()[1].json, { jellyfin_public_url: 'https://new-watch.example.test' });
|
||||||
|
assert.equal(settings.get('jellyfin_api_key'), 'fixture-jellyfin-secret', 'Blank secret fields must preserve credentials');
|
||||||
|
await screenshot('installation-jellyfin-320.png');
|
||||||
|
|
||||||
|
const sonarr = appPanel('Sonarr');
|
||||||
|
await sonarr.locator('summary').click();
|
||||||
|
await sonarr.getByLabel('Server URL', { exact: true }).fill('http://sonarr:8989');
|
||||||
|
await sonarr.getByLabel('API key', { exact: true }).fill('fixture-sonarr-secret');
|
||||||
|
await sonarr.getByRole('button', { name: 'Save & test Sonarr', exact: true }).click();
|
||||||
|
await sonarr.getByRole('combobox', { name: 'Quality profile ID', exact: true }).selectOption('8');
|
||||||
|
await sonarr.getByLabel('Root folder', { exact: true }).fill('/library/tv');
|
||||||
|
await continueButton.click();
|
||||||
|
await page.getByRole('heading', { name: 'Choose your preferences', exact: true }).waitFor();
|
||||||
|
assert.equal(settings.get('sonarr_quality_profile_id'), 8);
|
||||||
|
assert.equal(settings.get('sonarr_root_folder'), '/library/tv');
|
||||||
|
assert.equal(state.step, 'preferences');
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('heading', { name: 'Choose your preferences', exact: true }).waitFor();
|
||||||
|
assert.equal(bootstrapCalls().length, 2, 'Reloading must resume without recreating an administrator');
|
||||||
|
assert.equal(await page.getByLabel('Show Magent account sign-in', { exact: true }).isChecked(), true);
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await screenshot(`installation-preferences-${width}.png`);
|
||||||
|
}
|
||||||
|
await page.getByLabel('Show Magent account sign-in', { exact: true }).uncheck();
|
||||||
|
const writesBeforeInvalidPreferences = writes().length;
|
||||||
|
await continueButton.click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'at least one sign-in method' }).waitFor();
|
||||||
|
assert.equal(writes().length, writesBeforeInvalidPreferences);
|
||||||
|
await page.getByLabel('Show Magent account sign-in', { exact: true }).check();
|
||||||
|
await page.getByLabel('Use implicit TLS (usually port 465)', { exact: true }).check();
|
||||||
|
await continueButton.click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Choose STARTTLS or implicit TLS' }).waitFor();
|
||||||
|
assert.equal(writes().length, writesBeforeInvalidPreferences);
|
||||||
|
await page.getByLabel('Use implicit TLS (usually port 465)', { exact: true }).uncheck();
|
||||||
|
await page.getByLabel('Public Magent URL', { exact: true }).fill('https://magent.example.test');
|
||||||
|
await page.getByLabel('Login page message', { exact: true }).fill('Welcome to the fixture installation.');
|
||||||
|
await continueButton.click();
|
||||||
|
await page.getByRole('heading', { name: 'Ready to finish?', exact: true }).waitFor();
|
||||||
|
assert.equal(state.step, 'review');
|
||||||
|
assert.equal(settings.get('magent_application_url'), 'https://magent.example.test');
|
||||||
|
assert.equal(settings.get('site_login_message'), 'Welcome to the fixture installation.');
|
||||||
|
const finish = page.getByRole('button', { name: 'Finish setup', exact: true });
|
||||||
|
assert.equal(await finish.isDisabled(), true, 'Setup must not complete without explicit review confirmation');
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await screenshot(`installation-review-${width}.png`);
|
||||||
|
}
|
||||||
|
await page.getByLabel('I have reviewed the connections and want to finish setup.', { exact: true }).check();
|
||||||
|
failComplete = true;
|
||||||
|
await finish.click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Could not finish setup. Please retry.' }).waitFor();
|
||||||
|
assert.equal(state.completed, false);
|
||||||
|
failComplete = false;
|
||||||
|
await finish.click();
|
||||||
|
await page.waitForURL(`${base}/admin`);
|
||||||
|
await page.getByRole('heading', { name: 'Settings', exact: true }).waitFor();
|
||||||
|
assert.equal(state.completed, true);
|
||||||
|
assert.equal(calls.filter((call) => call.pathname === '/api/setup/complete').length, 2);
|
||||||
|
|
||||||
|
await page.goto(`${base}/setup`);
|
||||||
|
await page.getByText(/This installation is already set up/).waitFor();
|
||||||
|
await page.getByRole('heading', { name: 'Connect your apps', exact: true }).waitFor();
|
||||||
|
await appPanel('Jellyfin').locator('summary').click();
|
||||||
|
assert.equal(await page.locator('#setup-jellyfin_api_key').inputValue(), '');
|
||||||
|
assert.equal(bootstrapCalls().length, 2);
|
||||||
|
|
||||||
|
await appPanel('Jellyfin').getByLabel('Public playback URL', { exact: true }).fill('https://after-expiry.example.test');
|
||||||
|
expireNextSave = true;
|
||||||
|
await appPanel('Jellyfin').getByRole('button', { name: 'Save & test Jellyfin', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Sign in to continue', exact: true }).waitFor();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Your session expired.' }).waitFor();
|
||||||
|
await page.getByLabel('Username', { exact: true }).fill('fixture-admin');
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(expectedLoginPassword);
|
||||||
|
await page.getByRole('button', { name: 'Sign in to continue', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Connect your apps', exact: true }).waitFor();
|
||||||
|
await appPanel('Jellyfin').locator('summary').click();
|
||||||
|
assert.equal(await appPanel('Jellyfin').getByLabel('Public playback URL', { exact: true }).inputValue(), 'https://after-expiry.example.test');
|
||||||
|
await appPanel('Jellyfin').getByRole('button', { name: 'Save & test Jellyfin', exact: true }).click();
|
||||||
|
await page.getByRole('status').filter({ hasText: 'Jellyfin: Connected' }).waitFor();
|
||||||
|
assert.equal(settings.get('jellyfin_public_url'), 'https://after-expiry.example.test');
|
||||||
|
|
||||||
|
await context.clearCookies();
|
||||||
|
authenticated = false;
|
||||||
|
await page.goto(`${base}/setup`);
|
||||||
|
await page.getByRole('heading', { name: 'Sign in to continue', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.getByLabel('Setup token', { exact: true }).count(), 0);
|
||||||
|
await page.getByLabel('Username', { exact: true }).fill('fixture-admin');
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('Fixture-administrator-passphrase');
|
||||||
|
await page.getByRole('button', { name: 'Sign in to continue', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Connect your apps', exact: true }).waitFor();
|
||||||
|
const protectedReads = calls.filter((call) => ['/api/setup/state', '/api/admin/settings'].includes(call.pathname)).length;
|
||||||
|
role = 'user';
|
||||||
|
state.completed = false;
|
||||||
|
await page.goto(`${base}/setup`);
|
||||||
|
await page.getByRole('heading', { name: 'Administrator access required', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('navigation', { name: 'Setup steps', exact: true }).count(), 0);
|
||||||
|
assert.equal(calls.filter((call) => ['/api/setup/state', '/api/admin/settings'].includes(call.pathname)).length, protectedReads);
|
||||||
|
await page.getByRole('button', { name: 'Sign in with an administrator account', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Sign in to continue', exact: true }).waitFor();
|
||||||
|
assert.equal(page.url(), `${base}/setup`, 'Switching accounts on an unfinished install must avoid the login redirect loop');
|
||||||
|
role = 'admin';
|
||||||
|
expectedLoginPassword = ' Existing-administrator-passphrase ';
|
||||||
|
await page.getByLabel('Username', { exact: true }).fill('fixture-admin');
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(expectedLoginPassword);
|
||||||
|
await page.getByRole('button', { name: 'Sign in to continue', exact: true }).click();
|
||||||
|
await page.getByRole('heading', { name: 'Ready to finish?', exact: true }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(securityErrors, [], 'Setup must hydrate without CSP or CORS errors');
|
||||||
|
console.log('Setup UI passed: fresh-install redirect, token/password checks, bootstrap/login, app save/test, masked credentials, collector choices, preferences validation, resume, finish/retry, existing installs, and admin-only access.');
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (output) fs.mkdirSync(output, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.REVIEW_CHROMIUM || undefined });
|
||||||
|
try {
|
||||||
|
await reviewBackups(browser);
|
||||||
|
await reviewSetup(browser);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Fixture-only user-view regression checks. No real accounts or backend writes are used.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright');
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3114';
|
||||||
|
const output = process.env.REVIEW_DIR;
|
||||||
|
const previewKey = 'magent_user_view_preview';
|
||||||
|
|
||||||
|
async function setPreview(page, enabled) {
|
||||||
|
await page.evaluate(({ key, enabled }) => {
|
||||||
|
if (enabled) sessionStorage.setItem(key, '1');
|
||||||
|
else sessionStorage.removeItem(key);
|
||||||
|
window.dispatchEvent(new CustomEvent('magent:user-view-change', { detail: { enabled } }));
|
||||||
|
}, { key: previewKey, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reviewUserView(browser) {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
try {
|
||||||
|
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||||
|
const calls = [];
|
||||||
|
const errors = [];
|
||||||
|
const securityErrors = [];
|
||||||
|
let role = 'admin';
|
||||||
|
const features = { stats: true, requests: true, new_requests: true, issues: true, invites: true, ignore_profile_limits: false };
|
||||||
|
const user = () => ({ id: 1, username: 'Fixture account', role, features, invite_management_enabled: false });
|
||||||
|
const snapshot = {
|
||||||
|
request_id: '99', title: 'Fixture movie', year: 2026, request_type: 'movie', state: 'AVAILABLE',
|
||||||
|
state_reason: 'Available on the fixture media server.', timeline: [], actions: [],
|
||||||
|
presentation: {
|
||||||
|
status: { label: 'Available to watch', meaning: 'Ready on the fixture server.' },
|
||||||
|
pipeline: [{ id: 'available', label: 'Available to watch', state: 'complete', summary: 'Ready', link: 'https://watch.example.test/' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const issue = {
|
||||||
|
id: 1, kind: 'issue', title: 'Fixture playback issue', description: 'Fixture only.', status: 'new', priority: 'normal',
|
||||||
|
created_by_username: 'Other fixture user', created_at: '2026-09-18T01:00:00Z', updated_at: '2026-09-18T01:00:00Z',
|
||||||
|
last_activity_at: '2026-09-18T01:00:00Z', permissions: { can_edit: true, can_comment: true, can_moderate: true, can_delete: true },
|
||||||
|
};
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const pathname = new URL(request.url()).pathname;
|
||||||
|
calls.push({ pathname, method: request.method() });
|
||||||
|
const reply = (value) => route.fulfill({ json: value });
|
||||||
|
if (pathname === '/api/setup/status') return reply({ setup_required: false, needs_admin: false });
|
||||||
|
if (pathname === '/api/setup/state') return reply({ completed: true, step: 'review', completed_at: '2026-09-18T01:00:00Z' });
|
||||||
|
if (pathname === '/api/admin/settings') return reply({ settings: [] });
|
||||||
|
if (pathname === '/api/auth/me') return reply(user());
|
||||||
|
if (pathname === '/api/auth/profile') return reply({ user: user(), stats: { total: 0, ready: 0, in_progress: 0 }, activity: { recent: [] } });
|
||||||
|
if (pathname === '/api/auth/profile/invites') return reply({ invites: [], invite_access: { enabled: false }, master_invite: null });
|
||||||
|
if (pathname === '/api/requests/99/snapshot') return reply(snapshot);
|
||||||
|
if (pathname === '/api/requests/99/history') return reply({ snapshots: [] });
|
||||||
|
if (pathname === '/api/requests/99/actions') return reply({ actions: [] });
|
||||||
|
if (pathname === '/api/requests/99/language') return reply({ language: null });
|
||||||
|
if (pathname === '/api/insights') return reply({
|
||||||
|
state: 'not_configured', is_admin: role === 'admin',
|
||||||
|
requests: { total: 0, pending: 0, approved: 0, declined: 0, available: 0, failed: 0, movies: 0, tv: 0, recent: [] },
|
||||||
|
});
|
||||||
|
if (pathname === '/api/portal/overview') return reply({ overview: { by_kind: { issue: 1 } } });
|
||||||
|
if (pathname === '/api/portal/items') return reply({ items: [issue], total: 1, has_more: false });
|
||||||
|
if (pathname === '/api/portal/items/1') return reply({
|
||||||
|
item: issue,
|
||||||
|
comments: [{ id: 1, item_id: 1, author_username: 'Fixture admin', author_role: 'admin', message: 'Private fixture note', is_internal: true, created_at: issue.created_at }],
|
||||||
|
activity: [{ id: 1, item_id: 1, event_type: 'internal_note_added', actor_username: 'Fixture admin', actor_role: 'admin', message: 'Private fixture activity', created_at: issue.created_at }],
|
||||||
|
});
|
||||||
|
if (pathname.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' });
|
||||||
|
if (pathname.includes('/branding/')) return route.fulfill({ status: 404 });
|
||||||
|
return reply({ items: [], total: 0, services: [], navigation: { showRequests: true } });
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('console', (entry) => {
|
||||||
|
if (/content security policy|blocked by cors policy/i.test(entry.text())) securityErrors.push(entry.text());
|
||||||
|
});
|
||||||
|
const enterPreview = page.getByRole('button', { name: 'View as user', exact: true });
|
||||||
|
const blocked = page.getByRole('heading', { name: 'Administrator tools are hidden', exact: true });
|
||||||
|
const advanced = page.getByRole('button', { name: /Advanced details/ });
|
||||||
|
const adminNavigation = page.locator('.header-actions a[href="/admin"], .workspace-mobile-nav a[href="/admin"], .signed-in-dropdown a[href="/admin"]');
|
||||||
|
const privilegedReads = (entries) => entries.filter((call) => (
|
||||||
|
/^\/api\/admin(?:\/|$)/.test(call.pathname)
|
||||||
|
|| call.pathname === '/api/status/services'
|
||||||
|
|| /^\/api\/setup\/(state|complete|bootstrap)$/.test(call.pathname)
|
||||||
|
));
|
||||||
|
const histories = (entries) => entries.filter((call) => /^\/api\/requests\/99\/(history|actions)$/.test(call.pathname));
|
||||||
|
const assertHiddenNavigation = async () => {
|
||||||
|
assert.equal(await adminNavigation.count(), 0, 'Desktop, mobile and account menu must not include configuration');
|
||||||
|
await page.locator('.avatar-button').click();
|
||||||
|
assert.equal(await page.locator('.signed-in-dropdown a[href="/admin"]').count(), 0, 'The open account menu must not contain Settings');
|
||||||
|
await page.locator('.avatar-button').click();
|
||||||
|
};
|
||||||
|
const screenshot = async (name) => {
|
||||||
|
if (output) await page.screenshot({ path: path.join(output, name), fullPage: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
await page.goto(`${base}/admin`);
|
||||||
|
await page.getByRole('heading', { name: 'Settings', exact: true }).waitFor();
|
||||||
|
await page.getByText('Advanced tools', { exact: true }).waitFor();
|
||||||
|
assert.equal(await page.locator('.header-actions a[href="/admin"]').count(), 1);
|
||||||
|
const beforeToggle = calls.length;
|
||||||
|
await enterPreview.click();
|
||||||
|
await blocked.waitFor();
|
||||||
|
assert.equal(await page.getByRole('heading', { name: 'Settings', exact: true }).count(), 0, 'Entering preview must unmount the current config page');
|
||||||
|
assert.equal(await page.getByText('Advanced tools', { exact: true }).count(), 0, 'Advanced tools must not merely be hidden by CSS');
|
||||||
|
await assertHiddenNavigation();
|
||||||
|
assert.deepEqual(privilegedReads(calls.slice(beforeToggle)), [], 'Entering preview must not start admin reads');
|
||||||
|
await screenshot('user-view-blocked-desktop.png');
|
||||||
|
assert.equal(await page.evaluate((key) => sessionStorage.getItem(key), previewKey), '1');
|
||||||
|
|
||||||
|
for (const route of ['/admin', '/admin/general', '/admin/backups', '/admin/users', '/admin/issues', '/users', '/users/2', '/setup']) {
|
||||||
|
const beforeVisit = calls.length;
|
||||||
|
await page.goto(base + route);
|
||||||
|
await blocked.waitFor();
|
||||||
|
assert.deepEqual(privilegedReads(calls.slice(beforeVisit)), [], `Preview must not mount admin data loaders on ${route}`);
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Exit user view', exact: true }).count() > 0, true, `${route} must provide an exit`);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Exit user view', exact: true }).first().click();
|
||||||
|
await page.getByRole('heading', { name: 'Connect your apps', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.evaluate((key) => sessionStorage.getItem(key), previewKey), null);
|
||||||
|
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
await page.goto(`${base}/requests/99`);
|
||||||
|
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||||
|
await advanced.waitFor();
|
||||||
|
await enterPreview.click();
|
||||||
|
await advanced.waitFor({ state: 'detached' });
|
||||||
|
await assertHiddenNavigation();
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `User view must not overflow at ${width}px`);
|
||||||
|
await screenshot(`user-view-request-${width}.png`);
|
||||||
|
const beforeReload = calls.length;
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||||
|
assert.equal(await advanced.count(), 0, 'Reloading must not expose diagnostics before preview initializes');
|
||||||
|
assert.deepEqual(histories(calls.slice(beforeReload)), [], 'Reloading in preview must not request admin histories');
|
||||||
|
await assertHiddenNavigation();
|
||||||
|
await page.getByRole('button', { name: 'Exit user view', exact: true }).first().click();
|
||||||
|
await advanced.waitFor();
|
||||||
|
assert.equal(await page.locator('.header-actions a[href="/admin"]').count(), 1, 'Exiting restores the admin navigation');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${base}/insights`);
|
||||||
|
await page.getByRole('link', { name: 'Connect Jellystat', exact: true }).waitFor();
|
||||||
|
await enterPreview.click();
|
||||||
|
await page.getByRole('link', { name: 'Connect Jellystat', exact: true }).waitFor({ state: 'detached' });
|
||||||
|
await page.getByText('Viewing stats will appear here once your administrator connects Jellystat.', { exact: true }).waitFor();
|
||||||
|
|
||||||
|
await page.goto(`${base}/profile/invites`);
|
||||||
|
await page.getByRole('heading', { name: 'Invites are not enabled for your account', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('heading', { name: 'Create an invite', exact: true }).count(), 0, 'Preview must not retain the admin-only invite bypass');
|
||||||
|
|
||||||
|
await setPreview(page, false);
|
||||||
|
await page.goto(`${base}/portal/issues`);
|
||||||
|
await page.getByRole('button', { name: /Fixture playback issue/ }).click();
|
||||||
|
const issueDialog = page.getByRole('dialog', { name: 'Issue #1', exact: true });
|
||||||
|
await issueDialog.getByRole('button', { name: 'Delete issue', exact: true }).waitFor();
|
||||||
|
await issueDialog.getByRole('checkbox', { name: 'Internal comment (admin only)', exact: true }).check();
|
||||||
|
await issueDialog.getByLabel('Add comment', { exact: true }).fill('Private fixture draft');
|
||||||
|
await issueDialog.getByRole('button', { name: 'Delete issue', exact: true }).click();
|
||||||
|
await issueDialog.getByRole('button', { name: 'Delete permanently', exact: true }).waitFor();
|
||||||
|
await issueDialog.getByText('Private fixture note', { exact: true }).waitFor();
|
||||||
|
await setPreview(page, true);
|
||||||
|
await issueDialog.getByRole('button', { name: 'Delete issue', exact: true }).waitFor({ state: 'detached' });
|
||||||
|
assert.equal(await issueDialog.getByRole('button', { name: 'Delete permanently', exact: true }).count(), 0);
|
||||||
|
assert.equal(await issueDialog.getByRole('checkbox', { name: 'Internal comment (admin only)', exact: true }).count(), 0);
|
||||||
|
assert.equal(await issueDialog.getByLabel('Priority', { exact: true }).count(), 0);
|
||||||
|
assert.equal(await issueDialog.getByLabel('Assignee username', { exact: true }).count(), 0);
|
||||||
|
assert.equal(await issueDialog.getByText('Private fixture note', { exact: true }).count(), 0);
|
||||||
|
assert.equal(await issueDialog.getByText('Private fixture activity', { exact: true }).count(), 0);
|
||||||
|
assert.equal(await issueDialog.getByLabel('Add comment', { exact: true }).inputValue(), '', 'An internal draft must not become a public comment when leaving admin mode');
|
||||||
|
assert.equal(await issueDialog.getByRole('button', { name: 'Save changes', exact: true }).isDisabled(), true);
|
||||||
|
await screenshot('user-view-issue-moderation-hidden.png');
|
||||||
|
issue.created_by_username = 'Fixture account';
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Fixture playback issue/ }).click();
|
||||||
|
await issueDialog.getByLabel('Title', { exact: true }).waitFor();
|
||||||
|
assert.equal(await issueDialog.getByRole('button', { name: 'Save changes', exact: true }).isEnabled(), true, 'Preview must preserve editing of the account\'s own issues');
|
||||||
|
|
||||||
|
role = 'user';
|
||||||
|
await setPreview(page, false);
|
||||||
|
for (const enabled of [false, true]) {
|
||||||
|
await page.goto(`${base}/requests/99`);
|
||||||
|
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||||
|
await setPreview(page, enabled);
|
||||||
|
await assertHiddenNavigation();
|
||||||
|
assert.equal(await enterPreview.count(), 0, 'Ordinary users must not gain an admin toggle');
|
||||||
|
assert.equal(await advanced.count(), 0, 'Changing the preview flag must not grant admin diagnostics');
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('heading', { name: 'Fixture movie', exact: true }).waitFor();
|
||||||
|
assert.equal(await advanced.count(), 0);
|
||||||
|
}
|
||||||
|
await setPreview(page, false);
|
||||||
|
for (const route of ['/admin', '/admin/backups', '/users/2']) {
|
||||||
|
const beforeVisit = calls.length;
|
||||||
|
await page.goto(base + route);
|
||||||
|
await page.getByRole('heading', { name: 'Administrator access required', exact: true }).waitFor();
|
||||||
|
assert.deepEqual(privilegedReads(calls.slice(beforeVisit)), [], `A regular account must not mount ${route}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(errors, [], 'No uncaught browser errors');
|
||||||
|
assert.deepEqual(securityErrors, [], 'Preview must hydrate without CSP or CORS errors');
|
||||||
|
assert.equal(calls.some((call) => call.method !== 'GET'), false, 'Preview controls must not change backend permissions or settings');
|
||||||
|
console.log('User view passed: immediate admin-page unmount, direct admin/users/setup route blocking without privileged reads, desktop/mobile/account navigation, request diagnostics, reload persistence, exit restoration, stats/invite controls, issue moderation and private notes/drafts, own-issue editing, and no ordinary-user privilege escalation. API traffic used fixtures only.');
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (output) fs.mkdirSync(output, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.REVIEW_CHROMIUM || undefined });
|
||||||
|
try { await reviewUserView(browser); } finally { await browser.close(); }
|
||||||
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||||
Reference in New Issue
Block a user