Compare commits

..
2 Commits
137 changed files with 3692 additions and 5826 deletions
-1
View File
@@ -1 +0,0 @@
0803262237
+41 -17
View File
@@ -1,20 +1,44 @@
.git
.env
.env.*
.venv/
**/.pytest_cache/
stitch_magent_media_operations_redesign/
*.tar
*.tar.gz
*.zip
bootstrap-admin.json
release.tar
*.log
data/*
# Release builds accept only application sources and explicit build inputs.
# Local configuration, databases, backups, Git metadata and tool caches must
# never be sent to the builder, even if new directories are added to the repo.
**
!Dockerfile
!.dockerignore
!LICENSE
!backend/
!backend/requirements.txt
!backend/app/
!backend/app/**
!frontend/
!frontend/package.json
!frontend/package-lock.json
!frontend/next-env.d.ts
!frontend/next.config.js
!frontend/proxy.ts
!frontend/tsconfig.json
!frontend/app/
!frontend/app/**
!frontend/public/
!frontend/public/**
!docker/
!docker/supervisord.conf
!docker/requirements-runtime.txt
!data/
!data/branding/
!data/branding/**
frontend/node_modules/
frontend/.next/
backend/__pycache__/
**/__pycache__/
# Defense in depth for accidental private/generated files under allowed paths.
**/.env
**/.env.*
**/__pycache__
**/*.pyc
**/*.log
**/*.db
**/*.db-*
**/*.sqlite
**/*.sqlite3
**/bootstrap-admin.json
**/bootstrap-secrets.json
**/.magent-secrets-*
**/node_modules
**/.next
-20
View File
@@ -1,20 +0,0 @@
# Provision this as .env on the beta host. Do not copy production secrets or data.
APP_NAME=Magent Beta
CORS_ALLOW_ORIGIN=https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL=https://beta.grizzlyflix.co.nz
MAGENT_API_URL=https://beta.grizzlyflix.co.nz/api
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=json
JWT_SECRET=replace-with-an-independent-beta-secret-of-at-least-32-characters
SETTINGS_ENCRYPTION_KEY=replace-with-an-independent-valid-fernet-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-beta-bootstrap-password
AUTH_COOKIE_NAME=magent_beta_auth
AUTH_STATE_COOKIE_NAME=magent_beta_logged_in
AUTH_COOKIE_DOMAIN=beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+17 -3
View File
@@ -1,13 +1,26 @@
# Copy to .env for local development. Never reuse these example values in a deployed environment.
# Copy to .env for a fresh install; never replace an existing deployment's keys.
# See docs/PUBLIC_RELEASE.md. The localhost settings below are for local HTTP only.
# Never deploy the example secret placeholders.
APP_NAME=Magent
# Public Docker Hub template: choose a published prod-<commit> tag or sha256 digest.
# Intentionally no default: do not silently pull a mutable or incompatible image.
MAGENT_IMAGE=
MAGENT_BIND_ADDRESS=127.0.0.1
MAGENT_HTTP_PORT=3000
# For public hosting set BOTH URLs to your exact HTTPS origin (no trailing slash),
# for example https://magent.example.com, and AUTH_COOKIE_SECURE=true below.
CORS_ALLOW_ORIGIN=http://localhost:3000
MAGENT_APPLICATION_URL=http://localhost:3000
MAGENT_API_URL=http://localhost:8000
# Backend address is internal to the combined container, not a browser endpoint.
MAGENT_API_URL=http://127.0.0.1:8000
SQLITE_PATH=/app/data/magent.db
LOG_FILE=/app/data/magent.log
LOG_FORMAT=text
# Generate independent values as documented in README.md.
# Generate independent values as documented in docs/PUBLIC_RELEASE.md.
# Keep both unchanged when upgrading or restoring an offline data-volume backup.
JWT_SECRET=replace-with-at-least-32-random-characters
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
ADMIN_USERNAME=admin
@@ -18,6 +31,7 @@ SETUP_TOKEN=replace-with-a-separate-random-setup-token
# Leave blank to create the account using the setup wizard and SETUP_TOKEN.
ADMIN_PASSWORD=
# false is ONLY for local HTTP; public HTTPS deployments must use true.
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
-106
View File
@@ -1,106 +0,0 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- main
- prod
pull_request:
branches:
- beta
- main
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Verify generated build metadata
run: python scripts/verify_build_metadata.py
- name: Audit frontend production dependencies
working-directory: frontend
run: npm audit --omit=dev --package-lock-only --audit-level=high
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Check frontend formatting
working-directory: frontend
run: npm run format:check
- name: Type-check frontend
working-directory: frontend
run: npm run typecheck
- name: Test frontend
working-directory: frontend
run: npm test
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Validate Compose configuration
run: |
cp .env.example .env
docker compose -f docker-compose.yml config --quiet
- name: Build and smoke-test container
run: bash scripts/ci_container_smoke.sh
deploy-beta:
if: github.ref_name == 'beta'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy beta to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
run: bash scripts/deploy_beta_ams_dev01.sh
+12 -11
View File
@@ -1,14 +1,10 @@
.env
bootstrap-admin.json
.env.*
!.env.example
.venv/
.security-test-venv*/
data/
!data/branding/
!data/branding/**
backend/__pycache__/
**/__pycache__/
*.pyc
backend/.pytest_cache/
**/.pytest_cache/
.coverage
coverage.xml
htmlcov/
@@ -16,10 +12,15 @@ frontend/node_modules/
frontend/.next/
*.tsbuildinfo
*.log
**/.pytest_cache/
.env.*
!.env.example
!.env.*.example
*.db
*.db-*
*.sqlite*
*.magent-backup
bootstrap-admin.json
bootstrap-secrets.json
.magent-secrets-*
data/*
!data/branding/
*.tar
*.tar.gz
*.zip
+54 -26
View File
@@ -1,8 +1,12 @@
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS frontend-builder
WORKDIR /frontend
# GNU cp is needed only to collect third-party notices in the builder.
RUN apk add --no-cache coreutils
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
BACKEND_INTERNAL_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_BASE=/api
@@ -16,53 +20,77 @@ COPY frontend/next.config.js ./next.config.js
COPY frontend/proxy.ts ./proxy.ts
COPY frontend/tsconfig.json ./tsconfig.json
RUN npm run build
# Keep dependency notices outside the traced bundle: file tracing deliberately
# omits many license files that still need to accompany redistributed packages.
RUN npm run build \
&& npm prune --omit=dev \
&& mkdir /licenses \
&& npm ls --omit=dev --all --json > /licenses/dependencies.json \
&& find node_modules -type f \
\( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'copyright*' \) \
-exec cp --parents -t /licenses {} +
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
FROM python:3.14-alpine@sha256:016508ba505da24f7139765bc4bb669df4e88eb2f12eeadd571bf2f88d7533df AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
MAGENT_MANAGED_SECRETS=auto \
SQLITE_PATH=/app/data/magent.db \
API_DOCS_ENABLED=false \
NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg supervisor \
&& curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Keep curl for existing deployments that override the image healthcheck.
# Copy only Node's runtime binary: npm, headers and the NodeSource installer
# are build tools, not dependencies of the standalone frontend server.
RUN apk upgrade --no-cache \
&& apk add --no-cache curl libstdc++
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend-builder /usr/local/LICENSE /usr/local/share/doc/nodejs/LICENSE
RUN node --version
ARG MAGENT_UID=1000
ARG MAGENT_GID=1000
RUN groupadd --gid ${MAGENT_GID} magent \
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
RUN addgroup -g ${MAGENT_GID} magent \
&& adduser -D -u ${MAGENT_UID} -G magent -s /sbin/nologin magent \
&& install -d -o magent -g magent -m 0700 /app/data \
&& install -d -o magent -g magent -m 0755 /app/frontend/.next/cache
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/requirements.txt docker/requirements-runtime.txt /tmp/requirements/
RUN pip install --no-cache-dir --no-compile \
-r /tmp/requirements/requirements.txt \
-r /tmp/requirements/requirements-runtime.txt \
&& pip uninstall -y pip \
&& rm /tmp/requirements/requirements.txt /tmp/requirements/requirements-runtime.txt \
&& rmdir /tmp/requirements
COPY --chown=magent:magent backend/app ./app
COPY --chown=magent:magent data/branding /app/data/branding
COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
# Next's traced standalone output excludes the full dev/build dependency tree.
COPY --chown=magent:magent --from=frontend-builder /frontend/.next/standalone /app/frontend
COPY --chown=magent:magent --from=frontend-builder /frontend/.next/static /app/frontend/.next/static
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
COPY --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
COPY LICENSE /usr/share/licenses/magent/LICENSE
COPY --from=frontend-builder /licenses /usr/share/licenses/magent/frontend
LABEL org.opencontainers.image.title="Magent" \
org.opencontainers.image.description="Self-hosted media requests, issues and viewing insights" \
org.opencontainers.image.licenses="MIT"
RUN chown -R magent:magent /app
USER magent:magent
EXPOSE 3000 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
CMD curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8000/health >/dev/null \
&& curl --fail --silent --show-error --max-time 2 http://127.0.0.1:3000/login >/dev/null \
|| exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
ENTRYPOINT ["python", "-m", "app.container_bootstrap"]
CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Magent contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-71
View File
@@ -1,71 +0,0 @@
# Production
Magent runs as one combined frontend/API image: `rephl3xnz/magent`.
The root `Dockerfile` is the supported build entry point. Source releases come
from `main`; use `prod-<short-commit>` tags to identify an exact release.
## Live deployment
- Host: GRZ-DKR01 (`10.30.1.81`).
- Container and Compose service: `magent`; Compose project: `arrstack`.
- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`.
- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data``/app/data`.
- Public URL: `https://magent.grizzlyflix.co.nz`.
- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`.
- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes.
## Release checklist
1. Run the backend tests and frontend production build. Review only the intended
changes, then commit and push `main`.
The repository workflow verifies `main` but intentionally does not deploy it;
production changes require the remaining explicit release steps below.
2. Build from a clean source export using the root Dockerfile. Never include
`.env`, databases or bootstrap credentials in the build context.
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
Confirm their digests match.
4. Pull the new image before stopping production. Keep the old image under a
rollback tag and back up the current Compose configuration.
5. Briefly stop only `magent`, then back up its complete data directory so SQLite
and its WAL files are consistent. Protect backups: they contain private data.
6. Recreate only this service with `docker compose -p arrstack -f
/home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`.
Confirm that Compose selects the intended image before running this command.
7. Check container health, the API `/health` endpoint, public login, the changed
feature, database integrity and account counts. Do not trigger bulk permission
changes, email sends or user imports as a deployment smoke test.
Include browser-origin POST checks for both `/api/auth/login` and
`/api/auth/jellyfin/login`: an empty form with `Origin` set to the public URL
must reach input validation (422), while an unrelated origin must return 403.
GET-only login/health checks do not detect origin-policy lockouts. Set
`CORS_ALLOW_ORIGIN` to the exact public origin; the state-change guard also
accepts the explicitly configured Hosting & proxy public URL, never a URL
inferred from request Host or forwarded headers.
For rollback, select the saved image and recreate only Magent. Restore data only
if needed; doing so can discard activity since the backup. Never restore a whole
shared Compose or Caddy file without checking for unrelated changes first.
## Build metadata
`.build_number` and `backend/app/build_info.py` currently hold the same legacy
display build number as the frontend package files. `.env` should have exactly
one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release
tags identify the deployed source commit independently of this display value.
`scripts/process1.ps1` is a local development workflow: it updates metadata,
runs tests, rebuilds local Docker, and can commit changes/send Discord messages.
It is **not** the production deployment command. Its build-number helper can be
tested safely with `powershell -File scripts/test_env_build_number.ps1`.
## Fresh instances and historical notes
`scripts/prepare_production_settings.py` exports only allowlisted connection and
SMTP settings for a fresh instance. Do not use it to replace a live database.
`docker-compose.production.yml` is the separate fresh-instance template, not the
live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub
template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta.
The temporary AMS-DEV01 setup and coming-soon cutover are retained under
[archived cutover notes](docs/archive/production-cutover-2026-09-07.md).
+70 -203
View File
@@ -1,237 +1,104 @@
# Magent
Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It shows a clear timeline of where a request is stuck, explains what is happening in plain English, and offers safe actions to help fix issues.
Self-hosted media requests, viewing stats and issue management for Jellyfin,
Seerr, Sonarr, Radarr and related services. Magent combines a Python/FastAPI API,
a Next.js frontend and SQLite in one non-root container.
## How it works
## Install
1) Requests are pulled from Seerr and stored locally.
2) Magent joins that request to Sonarr/Radarr, Prowlarr, qBittorrent, and Jellyfin using TMDB/TVDB IDs and download hashes.
3) A state engine normalizes noisy service statuses into a simple, user-friendly state.
4) The UI renders a timeline and a central status box for each request.
5) Optional AI triage summarizes the likely cause and safest next steps.
Paste [compose.yml](compose.yml) into a Portainer **Docker Standalone** stack.
It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no
environment variables or Dockerfile on the user's machine.
## Core features
**Image availability:** the managed-install image is published on Docker Hub.
Only Linux/amd64 has been validated. `latest` is mutable; record the resolved
image digest before updating, or pin an immutable release tag.
- Request search by title/year or request ID.
- Recent requests list with posters and status.
- Timeline view across Seerr, Arr, Prowlarr, qBittorrent, Jellyfin.
- Central status box with clear reason + next steps.
- Safe action buttons (search, resume, re-add, etc.).
- Admin settings for service URLs, API keys, profiles, and root folders.
- Health status for each service in the pipeline.
- Cache and sync controls (full sync, delta sync, scheduled syncs).
- Local database for speed and audit history.
- Users and access control (admin vs user, block access).
- Local account password changes via "My profile".
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
- Docker-first deployment for easy hosting.
- Guided, resumable first-install setup with app connection tests.
- Encrypted backups of configuration, database and optional artwork cache, with restart-only restore.
1. Deploy the stack and wait for the container to become healthy.
2. In its console, select `/bin/ash` and user `magent`, then run:
## Quick start (Docker - primary)
Docker is the recommended way to run Magent. It includes the backend and frontend with sane defaults.
```bash
docker compose up --build
```sh
python -m app.container_bootstrap setup-token
```
Then open:
3. Open the Docker host's address on port 3000. Confirm the browser-facing URL
in setup and use the token to create the first administrator.
The **Get setup token** button shows the console instructions and lets you
copy the command; it never reveals the token to public visitors.
4. Connect your apps, choose preferences and finish setup. Optional apps can
be skipped. Save an encrypted backup afterwards.
- Frontend: http://localhost:3000
- Backend: http://localhost:8000
Keep the Compose security block unchanged. Database storage is fixed at
`/app/data/magent.db` and API docs are disabled in managed installs. CORS and
cookie security follow the confirmed URL. Use HTTPS before public access.
### Docker setup steps
See [Portainer setup](docs/PORTAINER.md),
[all environment options](docs/ENVIRONMENT.md),
[backup and restore](docs/installation-and-recovery.md) and
[advanced installation/upgrades](docs/PUBLIC_RELEASE.md).
Existing installations must retain their original data volume and signing/
encryption keys; this fresh-install template is not an automatic migration.
1) Copy `.env.example` to `.env`. Generate independent `JWT_SECRET`, `SETTINGS_ENCRYPTION_KEY` and `SETUP_TOKEN` values as described below. Do not use the example placeholders.
2) Set `CORS_ALLOW_ORIGIN` and `MAGENT_APPLICATION_URL` to your browser-facing origin. For public deployments, use HTTPS and `AUTH_COOKIE_SECURE=true`.
3) Run `docker compose up --build`.
4) Open http://localhost:3000. A fresh database opens the setup wizard automatically. Use your `SETUP_TOKEN` to create a local administrator, then connect and test each app you use.
5) Choose site, sign-in, request-sync and email preferences, review the connections, and finish setup. Remove `SETUP_TOKEN` from the deployment environment afterwards.
## Build and test
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**.
The source tree contains everything needed to build the application:
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)
```bash
JELLYSEERR_URL="http://localhost:5055"
JELLYSEERR_API_KEY="..."
SONARR_URL="http://localhost:8989"
SONARR_API_KEY="..."
SONARR_QUALITY_PROFILE_ID="1"
SONARR_ROOT_FOLDER="/tv"
RADARR_URL="http://localhost:7878"
RADARR_API_KEY="..."
RADARR_QUALITY_PROFILE_ID="1"
RADARR_ROOT_FOLDER="/movies"
PROWLARR_URL="http://localhost:9696"
PROWLARR_API_KEY="..."
QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="replace-with-at-least-32-random-characters"
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
JWT_EXP_MINUTES="120"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```sh
docker compose -f compose.yml -f compose.build.yml up -d --build
```
## Screenshots
For a disposable verification run, without touching an existing installation:
Add screenshots here once available:
```sh
docker build -t magent:review .
bash scripts/ci_container_smoke.sh magent:review
MAGENT_SMOKE_MANAGED=true bash scripts/ci_container_smoke.sh magent:review
```
- `docs/screenshots/home.png`
- `docs/screenshots/request-timeline.png`
- `docs/screenshots/settings.png`
- `docs/screenshots/profile.png`
Unit checks require Python 3.14 and Node 24:
## Local development (secondary)
Use this only when you need to modify code locally.
### Backend (FastAPI)
```bash
cd backend
```sh
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
Environment variables (sample):
```bash
$env:JELLYSEERR_URL="http://localhost:5055"
$env:JELLYSEERR_API_KEY="..."
$env:SONARR_URL="http://localhost:8989"
$env:SONARR_API_KEY="..."
$env:SONARR_QUALITY_PROFILE_ID="1"
$env:SONARR_ROOT_FOLDER="/tv"
$env:RADARR_URL="http://localhost:7878"
$env:RADARR_API_KEY="..."
$env:RADARR_QUALITY_PROFILE_ID="1"
$env:RADARR_ROOT_FOLDER="/movies"
$env:PROWLARR_URL="http://localhost:9696"
$env:PROWLARR_API_KEY="..."
$env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
$env:JWT_EXP_MINUTES="120"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
### Frontend (Next.js)
```bash
cd frontend
npm install
npm run dev
```
Open http://localhost:3000
Admin panel: http://localhost:3000/admin
Login uses the admin credentials above (or any other local user you create in SQLite).
### Local quality checks
```bash
bash scripts/ci_backend_quality_gate.sh
. .venv/bin/activate
pip install -r backend/requirements-dev.txt
python -m unittest discover -s backend/tests -p 'test_*.py'
python scripts/check_environment_docs.py
cd frontend
npm ci
npm test
npm run lint
npm run format:check
npm run typecheck
npm test
npm run build
```
## Public Hosting Notes
On Windows, activate `.venv\Scripts\Activate.ps1` instead. Do not point tests
at live services or use production credentials.
The frontend proxies `/api/*` to the backend container. Set:
## How it is organised
- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
- `backend/app/routers/`: authenticated API endpoints and administration.
- `backend/app/clients/`: media-service clients; `services/`: request states,
synchronisation, notifications, setup and encrypted backups.
- `backend/app/db.py` and `schema_migrations.py`: SQLite persistence/migrations.
- `frontend/app/`: pages and shared interface components; `frontend/proxy.ts`:
browser security headers and request nonces.
- `backend/tests/` and frontend `*.test.*`: synthetic regression tests.
- `Dockerfile` and `docker/`: multi-stage build and process supervision.
- `compose.yml`: prebuilt-image install; `compose.build.yml`: source override.
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
Requests are cached from Seerr, joined to collector/download/library evidence,
normalised into a user-facing state and displayed by the frontend. App settings
are stored in SQLite; sensitive settings are encrypted with installation-specific
keys. Integrations are optional and are configured through the setup wizard.
## Gitea CI/CD
This `release` branch intentionally excludes internal deployment scripts,
environment files, runtime data, development reports and prior Git history.
It contains no workflow that automatically deploys or publishes an image.
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
## Contributing and security
- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
- Push to `main` or `prod`: runs the same verification without automatically changing production.
- Production releases are tagged from `main` and deployed to `GRZ-DKR01` using the checklist in `PRODUCTION.md`.
Keep changes focused, add regression tests and run the checks above. Never
commit tokens, database exports, backups or real user information.
See [SECURITY.md](SECURITY.md) for reporting guidance and deployment precautions.
The beta deploy step ships tracked repository files over SSH, preserves beta's own `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
- `http://127.0.0.1:8000/health`
- `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job:
The existing `PROD_*` names are retained for compatibility, but this workflow uses them only for the isolated beta host deployment.
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
Beta always deploys to the isolated `/home/<deployment-user>/magent-beta` directory; the production path secret is intentionally ignored.
## Security and data handling
Generate independent signing and settings-encryption secrets before first startup:
```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
- `SETUP_TOKEN` is a separate random value of at least 32 characters, generated using the first command above a second time. It only authorizes first-admin creation on an unfinished, fresh installation. Never put it in a URL or share it with ordinary users. After completion the public bootstrap endpoint remains disabled even if the token is retained.
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
- **View as user** is a per-tab interface preview: it hides configuration, user-management pages, diagnostics and moderation tools, including direct admin-page URLs. **Exit user view** restores the administrator interface. It does not impersonate another account or change backend permissions; the displayed data still belongs to the signed-in account. Test real permission boundaries with a separate non-admin account.
## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots
- `GET /requests/{id}/actions?limit=10` recent action logs
## Troubleshooting
### Login fails
- For a fresh installation, open `/setup` and use `SETUP_TOKEN`, or sign in with the environment-seeded administrator. Existing installations use the accounts already in the database; changing `ADMIN_PASSWORD` does not reset an existing account.
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
### Services show as down
- Check the URLs and API keys in Settings.
- Verify containers can reach each service (network/DNS).
### No recent requests
- Confirm Seerr credentials in Settings.
- Run a full sync from Settings -> Requests.
### Docker images not updating
- Run `docker compose up --build` again.
- If needed, run `docker compose down` first, then rebuild.
Licensed under [MIT](LICENSE). Third-party dependency licences remain applicable.
+28
View File
@@ -0,0 +1,28 @@
# Security
## Reporting a vulnerability
Do not post passwords, access tokens, encryption keys, database exports, backup
files or live exploit details in public issues, discussions or container logs.
Use the repository hosting platform's private vulnerability-reporting feature
if the release owner has enabled it. Otherwise contact the maintainer privately
through the platform where you obtained this release before sending sensitive
details. This repository does not currently advertise a dedicated reporting
address; the release owner must establish one before a broad public launch.
Include the image tag/digest, affected version, a minimal reproduction using
synthetic data, and the security impact. Remove deployment credentials and
personal data from attachments. Do not test against systems you do not own or
have permission to assess.
## Deployment precautions
Follow [the public installation guide](docs/PUBLIC_RELEASE.md): use HTTPS for
public access, independent random secrets, a protected persistent data volume,
and the exact browser-facing origin. Keep the original signing/encryption keys
when upgrading. Do not disable origin checks or run as root to work around a
deployment failure.
Use a reviewed immutable release image and retain a tested backup. Check the
release's declared architecture support and migration notes. The project has
not declared an LTS support window or a guaranteed security-response SLA.
-4
View File
@@ -1,4 +0,0 @@
__pycache__/
*.pyc
.venv/
.env
+8 -1
View File
@@ -5,6 +5,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .installation_origin import managed_runtime
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
@@ -47,8 +48,14 @@ def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
secure = bool(settings.auth_cookie_secure)
if managed_runtime():
from .services.public_urls import magent_public_url
# Follow the persisted operator-selected URL immediately, including
# first login after setup; a restart is not required to protect cookies.
secure = magent_public_url().startswith("https://")
return {
"secure": bool(settings.auth_cookie_secure),
"secure": secure,
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -15,9 +15,9 @@ def _availability_message(result: Any) -> str:
or (isinstance(items, list) and len(items) > 0)
)
return (
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
"Jellyfin returned possible matches. Magent still needs to check the exact title and file."
if available
else "Grizzlyflix did not find this title in its library search."
else "Jellyfin did not find this title in its library search."
)
+258
View File
@@ -0,0 +1,258 @@
"""Persistent secrets for fresh image-only container installations.
Runs before importing application settings. Existing environment-managed
deployments are unchanged. Secrets are never printed during normal startup.
"""
import base64
import binascii
from contextlib import closing
import json
import os
from pathlib import Path
import re
import secrets
import sqlite3
import stat
import sys
import tempfile
from urllib.parse import urlsplit
from .installation_origin import normalize_application_origin
DATA_DIRECTORY = Path("/app/data")
STATE_FILENAME = "bootstrap-secrets.json"
SECRET_NAMES = ("JWT_SECRET", "SETTINGS_ENCRYPTION_KEY", "SETUP_TOKEN")
MAX_STATE_BYTES = 4096
class BootstrapError(ValueError):
"""An operator-actionable error that never includes a secret value."""
def managed_mode(environment: dict) -> bool:
value = environment.get("MAGENT_MANAGED_SECRETS", "false").strip().lower()
if value == "auto":
# Existing explicitly keyed installations retain their environment and
# JWT-derived encryption behaviour. Fresh image-only installs opt in.
return not bool(environment.get("JWT_SECRET", "").strip())
if value not in {"true", "false", "1", "0", "yes", "no", ""}:
raise BootstrapError("MAGENT_MANAGED_SECRETS must be auto, true or false.")
return value in {"true", "1", "yes"}
def _data_paths(environment: dict, directory: Path) -> tuple[Path, Path]:
directory = directory.absolute()
if not directory.is_dir() or any(part.is_symlink() for part in (directory, *directory.parents)):
raise BootstrapError("Managed installation requires a real, writable /app/data volume; symlinks are not allowed.")
if os.name == "posix":
metadata = directory.stat()
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) & 0o022:
raise BootstrapError("Managed data volume must belong to the runtime user and not be writable by other users.")
database = directory / "magent.db"
configured = Path(environment.get("SQLITE_PATH") or str(database)).absolute()
if configured != database:
raise BootstrapError("Managed installation requires SQLITE_PATH=/app/data/magent.db; retain manual keys for custom paths.")
if os.path.lexists(database) and (database.is_symlink() or not database.is_file()):
raise BootstrapError("Managed database must be a regular file, not a symlink or directory.")
return directory / STATE_FILENAME, database
def _read_state(path: Path) -> dict:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
with os.fdopen(descriptor, "rb") as handle:
metadata = os.fstat(handle.fileno())
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_STATE_BYTES:
raise BootstrapError("Managed secrets file must be a small regular file.")
if os.name == "posix" and (
metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o600
):
raise BootstrapError("Managed secrets file must belong to the runtime user with permissions 0600.")
state = json.loads(handle.read(MAX_STATE_BYTES + 1))
except FileNotFoundError:
raise
except (OSError, ValueError, UnicodeError) as exc:
if isinstance(exc, BootstrapError):
raise
raise BootstrapError("Cannot read managed secrets. Restore the original file; keys will not be regenerated.") from None
if not isinstance(state, dict) or set(state) != {"version", *SECRET_NAMES} or type(state["version"]) is not int or state["version"] != 1:
raise BootstrapError("Invalid managed secrets format. Restore the original file; keys will not be regenerated.")
for key in SECRET_NAMES:
if not isinstance(state[key], str):
raise BootstrapError("Invalid managed secret values. Restore the original file.")
for key in ("JWT_SECRET", "SETUP_TOKEN"):
if not re.fullmatch(r"[A-Za-z0-9_-]{64}", state[key]) or len(set(state[key])) < 2:
raise BootstrapError("Invalid managed token. Restore the original file.")
try:
decoded = base64.b64decode(state["SETTINGS_ENCRYPTION_KEY"], altchars=b"-_", validate=True)
except (ValueError, binascii.Error):
raise BootstrapError("Invalid managed encryption key. Restore the original file.") from None
if len(decoded) != 32 or base64.urlsafe_b64encode(decoded).decode() != state["SETTINGS_ENCRYPTION_KEY"]:
raise BootstrapError("Invalid managed encryption key. Restore the original file.")
if state["JWT_SECRET"] == state["SETUP_TOKEN"]:
raise BootstrapError("Managed signing and setup tokens must be independent.")
return state
def _sync_directory(directory: Path) -> None:
if os.name == "posix":
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _create_state(path: Path) -> dict:
state = {
"version": 1,
"JWT_SECRET": secrets.token_urlsafe(48),
"SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(secrets.token_bytes(32)).decode(),
"SETUP_TOKEN": secrets.token_urlsafe(48),
}
descriptor, temporary_name = tempfile.mkstemp(prefix=".magent-secrets-", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(state, handle, separators=(",", ":"))
handle.flush()
os.fsync(handle.fileno())
try:
# Publish an entirely written file without replacing another
# initializer's state. Both callers subsequently read the winner.
os.link(temporary, path)
_sync_directory(path.parent)
except FileExistsError:
pass
finally:
temporary.unlink(missing_ok=True)
return _read_state(path)
def _saved_origin(database: Path) -> str:
if not database.exists():
return ""
try:
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
if not connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'").fetchone():
return ""
row = connection.execute("SELECT value FROM settings WHERE key='magent_application_url'").fetchone()
return str(row[0] or "") if row else ""
except sqlite3.Error:
raise BootstrapError("Cannot read the saved application address. Check the existing database; no keys were changed.") from None
def _configure_origin(environment: dict, database: Path) -> None:
value = environment.get("MAGENT_APPLICATION_URL", "")
saved = _saved_origin(database)
if saved:
value = saved
if not value:
# No network address is trusted automatically. The token-authorized
# first-admin transaction will save the explicitly confirmed origin.
environment.setdefault("CORS_ALLOW_ORIGIN", "http://localhost:3000")
environment.setdefault("AUTH_COOKIE_SECURE", "false")
return
try:
parsed = urlsplit(value)
valid = (
bool(value) and not any(c.isspace() or ord(c) < 33 or ord(c) == 127 for c in value)
and parsed.scheme in {"http", "https"} and parsed.hostname
and parsed.username is None and parsed.password is None and not parsed.path
and "?" not in value and "#" not in value and "\\" not in value and "*" not in value
and (parsed.port is None or 1 <= parsed.port <= 65535)
)
except ValueError:
valid = False
if not valid:
raise BootstrapError("Set MAGENT_APPLICATION_URL to the exact http(s) browser origin, with no path or trailing slash.")
if not saved and environment.get("CORS_ALLOW_ORIGIN") not in (None, "", value):
raise BootstrapError("CORS_ALLOW_ORIGIN must match MAGENT_APPLICATION_URL for a managed install.")
value = normalize_application_origin(value)
environment["MAGENT_APPLICATION_URL"] = value
environment["CORS_ALLOW_ORIGIN"] = value
secure = environment.get("AUTH_COOKIE_SECURE", "").strip().lower()
if not secure:
environment["AUTH_COOKIE_SECURE"] = str(parsed.scheme == "https").lower()
elif secure not in {"true", "false", "1", "0"}:
raise BootstrapError("AUTH_COOKIE_SECURE must be true or false.")
elif parsed.scheme == "https" and secure in {"false", "0"}:
raise BootstrapError("HTTPS managed installations require AUTH_COOKIE_SECURE=true.")
elif parsed.scheme == "http" and secure in {"true", "1"}:
raise BootstrapError("Secure cookies require an HTTPS application URL.")
def prepare_environment(environment: dict, directory: Path = DATA_DIRECTORY) -> dict:
prepared = dict(environment)
if not managed_mode(prepared):
return prepared
if not prepared.get("JWT_SECRET", "").strip():
prepared.pop("JWT_SECRET", None)
path, database = _data_paths(prepared, directory)
_configure_origin(prepared, database)
if prepared.get("API_DOCS_ENABLED", "false").strip().lower() not in {"", "false", "0"}:
raise BootstrapError("API_DOCS_ENABLED is fixed to false for managed installations.")
try:
state = _read_state(path)
except FileNotFoundError:
# Never add independent encryption to an existing JWT-derived database
# or invent replacement keys after a lost secrets file.
if any(os.path.lexists(str(database) + suffix) for suffix in ("", "-wal", "-shm", "-journal")):
raise BootstrapError("Existing database has no managed secrets file. Restore its original keys or use the existing manual deployment.") from None
if any(prepared.get(key) for key in SECRET_NAMES):
raise BootstrapError("Fresh managed installs generate their own keys. Remove manual key variables or disable managed mode.") from None
state = _create_state(path)
for key in SECRET_NAMES:
if prepared.get(key) and prepared[key] != state[key]:
raise BootstrapError(f"{key} conflicts with the persistent managed value. Keys will not be replaced.")
prepared[key] = state[key]
prepared["SQLITE_PATH"] = str(database)
prepared["API_DOCS_ENABLED"] = "false"
prepared["MAGENT_MANAGED_SECRETS"] = "true"
prepared["MAGENT_RUNTIME_MANAGED"] = "1"
return prepared
def setup_token(environment: dict, directory: Path = DATA_DIRECTORY) -> str:
if not managed_mode(environment):
raise BootstrapError("Managed secrets are disabled. Use the SETUP_TOKEN from your deployment configuration.")
path, database = _data_paths(environment, directory)
state = _read_state(path) # This read-only command never generates keys.
if database.is_symlink() or not database.is_file():
raise BootstrapError("Database is not initialized. Wait for the container to become healthy.")
try:
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)) as connection:
row = connection.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
admin = connection.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
except sqlite3.Error:
raise BootstrapError("Cannot verify setup state. No setup token will be displayed.") from None
if row is None or row[0] != 0 or admin is not None:
raise BootstrapError("Initial administrator setup is no longer available. Sign in with the existing administrator.")
return state["SETUP_TOKEN"]
def main() -> int:
try:
if sys.argv[1:] == ["setup-token"]:
print(setup_token(dict(os.environ)))
return 0
if len(sys.argv) < 2:
raise BootstrapError("Pass the container startup command, or setup-token from the operator console.")
environment = prepare_environment(dict(os.environ))
if managed_mode(environment):
print("Managed installation secrets loaded. For first setup, run in the container console: "
"python -m app.container_bootstrap setup-token", flush=True)
os.execvpe(sys.argv[1], sys.argv[1:], environment)
except (BootstrapError, OSError):
# Never include unexpected I/O details or environment values in logs.
error = sys.exc_info()[1]
message = str(error) if isinstance(error, BootstrapError) else "Cannot access managed installation files or start the runtime. Check volume permissions and original keys."
print(f"Magent startup: {message}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
"""Origin validation shared by first-install setup and container startup."""
import os
from urllib.parse import urlsplit
def managed_runtime() -> bool:
# Set by the entrypoint, never by an HTTP header or a database setting.
return os.environ.get("MAGENT_RUNTIME_MANAGED") == "1"
def normalize_application_origin(value: str) -> str:
if not isinstance(value, str) or not value or any(
c.isspace() or ord(c) < 33 or ord(c) == 127 or c in '<>"\\*?#' for c in value
):
raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.")
try:
parsed = urlsplit(value)
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
or parsed.username is not None or parsed.password is not None
or parsed.path not in {"", "/"} or parsed.netloc.endswith(":")):
raise ValueError
port = parsed.port
if port is not None and not 1 <= port <= 65535:
raise ValueError
host = parsed.hostname.encode("idna").decode("ascii").lower()
if ":" in host:
host = f"[{host}]"
suffix = f":{port}" if port is not None and port != (443 if parsed.scheme == "https" else 80) else ""
return f"{parsed.scheme}://{host}{suffix}"
except (ValueError, UnicodeError):
raise ValueError("Enter an exact http(s) site address without a path, credentials, query or fragment.") from None
+7 -4
View File
@@ -8,7 +8,6 @@ from typing import Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .config import settings
@@ -60,7 +59,7 @@ from .runtime import get_runtime_settings
from .metrics import record_api, start_metrics
from .request_limits import InstallationBodyLimitMiddleware
from .secret_storage import validate_secret_storage_configuration
from .services.request_origins import is_allowed_request_origin
from .services.request_origins import ConfiguredOriginCORSMiddleware, can_claim_initial_origin, is_allowed_request_origin
logger = logging.getLogger(__name__)
_background_tasks: list[asyncio.Task[None]] = []
@@ -74,7 +73,7 @@ app = FastAPI(
)
app.add_middleware(
CORSMiddleware,
ConfiguredOriginCORSMiddleware,
allow_origins=[settings.cors_allow_origin],
allow_credentials=True,
allow_methods=["*"],
@@ -114,7 +113,11 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
request.state.request_id = request_id
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
origin = str(request.headers.get("origin") or "")
if origin and not is_allowed_request_origin(origin):
initial_origin_claim = (
request.method.upper() == "POST" and request.url.path == "/setup/bootstrap"
and can_claim_initial_origin()
)
if origin and not is_allowed_request_origin(origin) and not initial_origin_claim:
record_api(request, 403, 0.0)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=403)
+6
View File
@@ -680,6 +680,12 @@ async def list_settings() -> Dict[str, Any]:
@router.put("/settings")
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
from ..installation_origin import managed_runtime, normalize_application_origin
if managed_runtime() and "magent_application_url" in payload:
try:
payload = {**payload, "magent_application_url": normalize_application_origin(payload["magent_application_url"])}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updates = 0
touched_logging = False
changed_keys: List[str] = []
+13 -1
View File
@@ -8,6 +8,8 @@ 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
from ..installation_origin import normalize_application_origin
from ..services.request_origins import can_claim_initial_origin
router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
@@ -17,6 +19,7 @@ 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)
application_url: str | None = Field(default=None, max_length=2048)
class SetupProgress(StrictRequest):
@@ -42,8 +45,17 @@ def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
headers={"Retry-After": str(retry_after)},
)
try:
application_url = payload.application_url
if application_url is not None:
application_url = normalize_application_origin(application_url)
origin = request.headers.get("origin", "")
if not origin or application_url != normalize_application_origin(origin):
raise HTTPException(status_code=403, detail="The site address must match the address open in your browser.")
elif can_claim_initial_origin():
raise HTTPException(status_code=400, detail="Confirm the application URL to create the administrator.")
setup_service.bootstrap_administrator(
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value()
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value(),
application_url=application_url,
)
except setup_service.InvalidSetupTokenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
+16
View File
@@ -34,6 +34,7 @@ from pydantic import TypeAdapter
from ..config import Settings, settings
from ..db import _db_path
from ..installation_origin import managed_runtime, normalize_application_origin
from ..schema_migrations import MIGRATIONS
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
@@ -465,6 +466,13 @@ def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
if pending.exists():
raise BackupError("A restore is already staged; cancel it before uploading another")
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
destination_origin = None
if managed_runtime():
from .public_urls import magent_public_url
try:
destination_origin = normalize_application_origin(magent_public_url())
except ValueError:
raise BackupError("Configure a valid destination application address before restoring a backup") from None
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
stage = Path(temporary)
stage.chmod(0o700)
@@ -476,6 +484,14 @@ def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
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))
if destination_origin is not None:
# The backup's hostname must not replace this installation's
# trusted browser origin or change its cookie policy.
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES ('magent_application_url',?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(destination_origin, _now()),
)
# 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,))
+3 -3
View File
@@ -213,11 +213,11 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
subject = f"Ready to try again? Magent issue #{item['id']}"
body_text = (
"Your repair looks ready to test.\n\n"
f"{item.get('title') or 'Your reported issue'}\n\n"
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
"Please try the affected content in Jellyfin. Is it fixed?\n\n"
f"YES — it works: {issue_url}#yes\n"
f"NO — still broken: {issue_url}#no\n\n"
"Confirm your answer in Magent. You may need to sign in first.\n"
@@ -227,7 +227,7 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
body_html = (
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">MAGENT</p>'
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
+9 -9
View File
@@ -16,12 +16,12 @@ def description(entry):
def render_confirmation(username, url):
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from Grizzlyflix."
return {'subject': 'Confirm your Grizzlyflix newsletter subscription',
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from your media library."
return {'subject': 'Confirm your Magent newsletter subscription',
'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
'body_html': document(title='Your next watch starts here.', intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">A weekly look at new movies and TV updates, with posters and links to watch.</p>',
action='Confirm newsletter subscription', url=url, kicker='NEW ON GRIZZLYFLIX',
action='Confirm newsletter subscription', url=url, kicker='NEW IN YOUR LIBRARY',
footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
@@ -58,17 +58,17 @@ def render(content, images, public_url, playback_url, unsubscribe_url, *, previe
body.append(f'''<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;border-bottom:1px solid #363338"><tr>
<td width="92" valign="top" style="padding:18px 12px 18px 0">{poster}</td><td valign="top" style="padding:18px 0;overflow-wrap:anywhere">
<h3 style="margin:0 0 8px;font-size:16px;line-height:1.4;color:#eee8f2">{esc(entry['title'])}</h3><p style="font-size:12px;line-height:1.6;color:#a69fac;margin:0 0 10px">{esc(details)}</p>{copy}
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Grizzlyflix &#8599;</a></td></tr></table>''')
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Jellyfin &#8599;</a></td></tr></table>''')
lines += [entry['title'], details, watch, '']
if not titles:
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in Grizzlyflix.</p>')
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in your media library.</p>')
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
footer = f'You subscribed to the Grizzlyflix newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
footer = f'You subscribed to the Magent newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
subject = ('[Test] ' if test else '') + content['subject']
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Grizzlyflix: {playback_url}', '',
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Jellyfin: {playback_url}', '',
f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
f'Email preferences: {public_url}/profile#newsletters']),
'body_html': document(title='Whats new on Grizzlyflix',
'body_html': document(title='Whats new in your library',
intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
content=''.join(body), action='Explore Grizzlyflix', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
content=''.join(body), action='Explore Jellyfin', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
'inline_images': attachments}
+1 -1
View File
@@ -322,7 +322,7 @@ def complete_weekly(config, content, now: datetime, failure=''):
conn.execute('''INSERT OR IGNORE INTO newsletter_editions
(id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
(identity, f"Whats new on Grizzlyflix · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
(identity, f"Whats new in your library · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
if not empty:
+1 -1
View File
@@ -133,7 +133,7 @@ async def create_draft(user, days):
end = datetime.now(timezone.utc)
config = store.settings()
content = await collect(end - timedelta(days=days), end, config['limit_titles'])
return store.create_edition(content, f"Whats new on Grizzlyflix · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
return store.create_edition(content, f"Whats new in your library · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
def require_edition(identity, revision=None):
+3
View File
@@ -1,6 +1,7 @@
"""Configured public email links, independent of request Host/forwarded headers."""
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
from ..installation_origin import managed_runtime
def valid_public_url(value):
@@ -21,6 +22,8 @@ def magent_public_url(legacy_url=''):
runtime = get_runtime_settings()
proxy = getattr(runtime, 'magent_proxy_base_url', None)
application = getattr(runtime, 'magent_application_url', None)
if managed_runtime():
return valid_public_url(application)
if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
return valid_public_url(proxy)
if str(application or '').strip():
+22
View File
@@ -6,8 +6,10 @@ Never infer a trusted origin from request Host or forwarded headers.
"""
from urllib.parse import urlsplit
from starlette.middleware.cors import CORSMiddleware
from ..config import settings
from ..installation_origin import managed_runtime
from .public_urls import magent_public_url, valid_public_url
@@ -36,6 +38,26 @@ def is_allowed_request_origin(origin: str) -> bool:
candidate = _origin(origin)
if candidate is None:
return False
if managed_runtime():
# The operator confirms this address using the first-install token.
# No localhost fallback remains trusted after a managed installation.
return candidate == _origin(magent_public_url(), configured_url=True)
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
return True
return candidate == _origin(magent_public_url(), configured_url=True)
def can_claim_initial_origin() -> bool:
if not managed_runtime() or magent_public_url():
return False
from .setup import get_public_setup_status
return get_public_setup_status()["needs_admin"]
class ConfiguredOriginCORSMiddleware(CORSMiddleware):
"""Keep CORS response/preflight policy aligned with managed origin checks."""
def is_allowed_origin(self, origin: str) -> bool:
if managed_runtime():
return is_allowed_request_origin(origin)
return super().is_allowed_origin(origin)
+10 -1
View File
@@ -14,6 +14,7 @@ from typing import Literal
from .. import db
from ..config import settings
from ..security import hash_password, validate_password_policy
from ..installation_origin import normalize_application_origin
SetupStep = Literal["administrator", "apps", "preferences", "review"]
@@ -135,7 +136,7 @@ def consume_bootstrap_attempt(client_ip: str) -> int | None:
return None
def bootstrap_administrator(setup_token: str, username: str, password: str) -> None:
def bootstrap_administrator(setup_token: str, username: str, password: str, *, application_url: str | None = None) -> 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(
@@ -150,6 +151,8 @@ def bootstrap_administrator(setup_token: str, username: str, password: str) -> N
if len(password) > 1024:
raise ValueError("Password must contain no more than 1024 characters.")
password = validate_password_policy(password)
if application_url is not None:
application_url = normalize_application_origin(application_url)
if not is_setup_required() or db.has_admin_user():
raise SetupUnavailableError("Initial administrator setup is no longer available.")
@@ -168,6 +171,12 @@ def bootstrap_administrator(setup_token: str, username: str, password: str) -> N
(username, password_hash, datetime.now(timezone.utc).isoformat()),
)
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
if application_url is not None:
conn.execute(
"""INSERT INTO settings (key, value, updated_at) VALUES ('magent_application_url', ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(application_url, datetime.now(timezone.utc).isoformat()),
)
def update_setup_step(step: SetupStep) -> dict:
+13 -13
View File
@@ -582,9 +582,9 @@ def _build_repair_activity(
message = (
f"{collector} now reports the replacement file as collected. "
+ (
"It is also available in Grizzlyflix."
"It is also available in Jellyfin."
if jellyfin_found
else "Grizzlyflix is indexing the updated file now."
else "Jellyfin is indexing the updated file now."
)
)
state = "complete" if jellyfin_found else "indexing"
@@ -658,7 +658,7 @@ def _build_repair_activity(
"label": "Updated media available",
"state": available_step_state,
"detail": (
"The repaired title is available in Grizzlyflix."
"The repaired title is available in Jellyfin."
if jellyfin_found and collection_complete
else (
"The media server is indexing the replacement."
@@ -922,22 +922,22 @@ def _build_presentation(
available_label = "Partially available"
available_state = "partial"
available_state_label = "Partly ready"
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
available_summary = f"{available} of {total} episodes are ready to watch in Jellyfin."
elif jellyfin_found:
available_label = "Available to watch"
available_state = "complete"
available_state_label = "Ready"
available_summary = "This title is ready to watch in Grizzlyflix."
available_summary = "This title is ready to watch in Jellyfin."
elif arr_state == "available":
available_label = "Adding to Grizzlyflix"
available_label = "Adding to Jellyfin"
available_state = "active"
available_state_label = "Indexing"
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
available_summary = "The download is complete. Jellyfin is indexing this title now."
else:
available_label = "Media server"
available_state = "waiting"
available_state_label = "Waiting"
available_summary = "This title has not reached Grizzlyflix yet."
available_summary = "This title has not reached Jellyfin yet."
display_download = dict(download)
if fully_available:
@@ -1026,13 +1026,13 @@ def _apply_repair_presentation(
search = (arr_details.get("search") or {}).get("state")
pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]}
if imported:
label = "Replacement collected — updating Grizzlyflix"
meaning = "The replacement has been imported. Waiting for Grizzlyflix to index the updated file."
label = "Replacement collected — updating Jellyfin"
meaning = "The replacement has been imported. Waiting for Jellyfin to index the updated file."
snapshot.state = NormalizedState.importing
pipeline["download"].update(state="complete", summary="The replacement has been imported.", torrents=[], visible=False)
pipeline["available"].update(label="Updating Grizzlyflix", state="active", stateLabel="Indexing", summary=meaning)
pipeline["available"].update(label="Updating Jellyfin", state="active", stateLabel="Indexing", summary=meaning)
snapshot.presentation["nextStep"] = {
"title": "Wait for the updated file", "description": "This page will update when Grizzlyflix confirms the replacement.", "actionIds": [],
"title": "Wait for the updated file", "description": "This page will update when Jellyfin confirms the replacement.", "actionIds": [],
}
elif unavailable:
label = "Repair status temporarily unavailable"
@@ -1073,7 +1073,7 @@ def _apply_repair_presentation(
if has_unaffected and catalog_found and isinstance(jellyfin_item, dict) and jellyfin_item.get("Id"):
link = f"{public_url.rstrip('/')}/web/index.html#!/details?id={quote(str(jellyfin_item['Id']))}" if public_url else None
pipeline["available"].update(label="Partially available", state="partial", stateLabel="Repair in progress",
summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Grizzlyflix to index the repaired episodes.", link=link)
summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Jellyfin to index the repaired episodes.", link=link)
snapshot.raw["jellyfin"].update(partial=True, link=link)
+4 -4
View File
@@ -448,7 +448,7 @@ class OperationMessageTests(unittest.TestCase):
)
self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}),
"Grizzlyflix did not find this title in its library search.",
"Jellyfin did not find this title in its library search.",
)
def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
@@ -868,7 +868,7 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(available_stage["state"], "complete")
self.assertEqual(available_stage["stateLabel"], "Ready")
self.assertEqual(available_stage["label"], "Available to watch")
self.assertEqual(available_stage["summary"], "This title is ready to watch in Grizzlyflix.")
self.assertEqual(available_stage["summary"], "This title is ready to watch in Jellyfin.")
self.assertEqual(available_stage["link"], "https://media.test/title/3909")
def test_partially_available_content_keeps_missing_download_attention(self) -> None:
@@ -940,10 +940,10 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(download_stage["state"], "complete")
self.assertEqual(available_stage["state"], "active")
self.assertEqual(available_stage["stateLabel"], "Indexing")
self.assertEqual(available_stage["label"], "Adding to Grizzlyflix")
self.assertEqual(available_stage["label"], "Adding to Jellyfin")
self.assertEqual(
available_stage["summary"],
"The download is complete. Grizzlyflix is indexing this title now.",
"The download is complete. Jellyfin is indexing this title now.",
)
+26
View File
@@ -126,6 +126,32 @@ class BackupTests(unittest.TestCase):
with closing(sqlite3.connect(self.database)) as restored:
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
def test_managed_restore_preserves_destination_application_origin(self):
db.set_setting("magent_application_url", "https://source.example.test")
content = self.export()
db.set_setting("magent_application_url", "https://destination.example.test")
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(backups.apply_pending_restore())
self.assertEqual(db.get_setting("magent_application_url"), "https://destination.example.test")
def test_manual_restore_retains_legacy_application_url_behavior(self):
db.set_setting("magent_application_url", "https://source.example.test")
content = self.export()
db.set_setting("magent_application_url", "https://destination.example.test")
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": ""}):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertTrue(backups.apply_pending_restore())
self.assertEqual(db.get_setting("magent_application_url"), "https://source.example.test")
def test_managed_restore_without_destination_origin_does_not_stage(self):
content = self.export()
with patch.dict("os.environ", {"MAGENT_RUNTIME_MANAGED": "1"}), \
patch("backend.app.services.public_urls.magent_public_url", return_value=""):
with self.assertRaisesRegex(backups.BackupError, "destination application address"):
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
self.assertIsNone(backups.backup_status()["pending_restore"])
def test_process_interruption_is_recovered_on_next_startup(self):
class ProcessStopped(BaseException):
pass
+519
View File
@@ -0,0 +1,519 @@
"""Managed installation regression tests; use only disposable local files."""
import base64
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing, redirect_stderr, redirect_stdout
import io
import json
import os
from pathlib import Path
import sqlite3
import stat
import tempfile
from threading import Barrier
import unittest
from unittest.mock import patch
from backend.app import container_bootstrap as bootstrap
class ContainerBootstrapTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.data = self.root / "data"
self.data.mkdir(mode=0o700)
self.state_path = self.data / bootstrap.STATE_FILENAME
self.database = self.data / "magent.db"
self.environment = {
"MAGENT_MANAGED_SECRETS": "true",
"MAGENT_APPLICATION_URL": "https://magent.example.test",
}
def prepare(self, **changes):
return bootstrap.prepare_environment({**self.environment, **changes}, self.data)
def state(self):
return json.loads(self.state_path.read_text(encoding="utf-8"))
def create_database(self, *, completed=0, admin=False):
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE installation_setup (id INTEGER PRIMARY KEY, completed INTEGER)")
connection.execute("INSERT INTO installation_setup VALUES (1, ?)", (completed,))
connection.execute("CREATE TABLE users (role TEXT)")
if admin:
connection.execute("INSERT INTO users VALUES ('ADMIN')")
def create_symlink(self, path, target, *, directory=False):
try:
path.symlink_to(target, target_is_directory=directory)
except (OSError, NotImplementedError) as exc:
self.skipTest(f"This platform cannot create test symlinks: {type(exc).__name__}")
def test_fresh_install_generates_independent_valid_random_secrets(self):
before = dict(self.environment)
prepared = self.prepare()
state = self.state()
self.assertEqual(self.environment, before)
self.assertEqual(set(state), {"version", *bootstrap.SECRET_NAMES})
self.assertEqual(state["version"], 1)
for name in ("JWT_SECRET", "SETUP_TOKEN"):
self.assertRegex(state[name], r"^[A-Za-z0-9_-]{64}$")
self.assertNotEqual(state["JWT_SECRET"], state["SETUP_TOKEN"])
self.assertEqual(len(base64.urlsafe_b64decode(state["SETTINGS_ENCRYPTION_KEY"])), 32)
for name in bootstrap.SECRET_NAMES:
self.assertEqual(prepared[name], state[name])
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
self.assertFalse(self.database.exists())
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
@unittest.skipUnless(os.name == "posix", "POSIX filesystem ownership/permissions")
def test_state_has_private_permissions_and_runtime_ownership(self):
self.prepare()
metadata = self.state_path.stat()
self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o600)
self.assertEqual(metadata.st_uid, os.geteuid())
def test_separate_installations_get_different_secrets(self):
first = self.prepare()
other = self.root / "other"
other.mkdir(mode=0o700)
second = bootstrap.prepare_environment(self.environment, other)
for name in bootstrap.SECRET_NAMES:
self.assertNotEqual(first[name], second[name])
def test_restart_and_existing_database_reuse_exact_file_and_values(self):
first = self.prepare()
original = self.state_path.read_bytes()
original_modified = self.state_path.stat().st_mtime_ns
self.create_database(admin=True)
with patch.object(bootstrap.secrets, "token_bytes", side_effect=AssertionError("Must not regenerate")), \
patch.object(bootstrap.secrets, "token_urlsafe", side_effect=AssertionError("Must not regenerate")):
second = self.prepare()
self.assertEqual(first, second)
self.assertEqual(self.state_path.read_bytes(), original)
self.assertEqual(self.state_path.stat().st_mtime_ns, original_modified)
def test_disabled_mode_is_an_unchanged_copy_without_filesystem_access(self):
for value in (None, "false", "0", "no", "", " FALSE "):
with self.subTest(mode=value):
environment = {"JWT_SECRET": "legacy-key", "MAGENT_APPLICATION_URL": "invalid"}
if value is not None:
environment["MAGENT_MANAGED_SECRETS"] = value
result = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
self.assertEqual(result, environment)
self.assertIsNot(result, environment)
self.assertFalse(self.state_path.exists())
def test_invalid_managed_mode_fails_before_writing(self):
with self.assertRaises(bootstrap.BootstrapError):
self.prepare(MAGENT_MANAGED_SECRETS="perhaps")
self.assertFalse(self.state_path.exists())
def test_auto_mode_generates_fresh_install_keys_without_explicit_jwt(self):
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
self.assertTrue(self.state_path.exists())
self.assertEqual(prepared["MAGENT_MANAGED_SECRETS"], "true")
self.assertEqual(prepared["MAGENT_RUNTIME_MANAGED"], "1")
for name in bootstrap.SECRET_NAMES:
self.assertEqual(prepared[name], self.state()[name])
def test_auto_mode_preserves_explicit_jwt_manual_install_without_filesystem_access(self):
environment = {
"MAGENT_MANAGED_SECRETS": "auto",
"JWT_SECRET": "legacy-explicit-signing-key",
"SQLITE_PATH": "/existing/custom-database.db",
"API_DOCS_ENABLED": "true",
"MAGENT_APPLICATION_URL": "https://legacy.example.test",
"CORS_ALLOW_ORIGIN": "https://legacy.example.test",
}
prepared = bootstrap.prepare_environment(environment, self.root / "does-not-exist")
self.assertEqual(prepared, environment)
self.assertIsNot(prepared, environment)
self.assertNotIn("SETTINGS_ENCRYPTION_KEY", prepared)
self.assertNotIn("MAGENT_RUNTIME_MANAGED", prepared)
self.assertFalse(self.state_path.exists())
def test_auto_mode_whitespace_jwt_is_treated_as_unset(self):
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto", "JWT_SECRET": " "}, self.data)
self.assertEqual(prepared["JWT_SECRET"], self.state()["JWT_SECRET"])
def test_absent_application_url_uses_fixed_defaults_without_claiming_an_origin(self):
prepared = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
self.assertFalse(prepared.get("MAGENT_APPLICATION_URL"))
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
self.assertEqual(prepared["API_DOCS_ENABLED"], "false")
self.assertEqual(prepared["SQLITE_PATH"], str(self.database.absolute()))
def test_empty_application_url_is_deferred_to_setup(self):
prepared = self.prepare(MAGENT_APPLICATION_URL="")
self.assertEqual(prepared["MAGENT_APPLICATION_URL"], "")
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://localhost:3000")
self.assertTrue(self.state_path.exists())
def test_managed_api_docs_cannot_be_enabled(self):
for value in ("true", "1", "yes", "on", "invalid"):
with self.subTest(value=value), self.assertRaisesRegex(bootstrap.BootstrapError, "API_DOCS_ENABLED"):
self.prepare(API_DOCS_ENABLED=value)
self.assertFalse(self.state_path.exists())
def test_saved_public_url_controls_restart_without_key_regeneration(self):
original = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
state_bytes = self.state_path.read_bytes()
self.create_database(admin=True)
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://saved.example.test')")
restarted = bootstrap.prepare_environment({"MAGENT_MANAGED_SECRETS": "auto"}, self.data)
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "https://saved.example.test")
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "https://saved.example.test")
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "true")
self.assertEqual(self.state_path.read_bytes(), state_bytes)
for name in bootstrap.SECRET_NAMES:
self.assertEqual(restarted[name], original[name])
def test_saved_public_url_wins_over_stale_deployment_url_on_restart(self):
self.prepare()
self.create_database(admin=True)
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'http://magent.lan:3000')")
restarted = self.prepare(CORS_ALLOW_ORIGIN="https://magent.example.test")
self.assertEqual(restarted["MAGENT_APPLICATION_URL"], "http://magent.lan:3000")
self.assertEqual(restarted["CORS_ALLOW_ORIGIN"], "http://magent.lan:3000")
self.assertEqual(restarted["AUTH_COOKIE_SECURE"], "false")
def test_invalid_saved_url_fails_closed_without_changing_keys(self):
self.prepare()
original = self.state_path.read_bytes()
self.create_database(admin=True)
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
connection.execute("INSERT INTO settings VALUES ('magent_application_url', 'https://user:secret@evil.test')")
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(self.state_path.read_bytes(), original)
def test_existing_database_or_recovery_sidecar_never_generates_replacement_keys(self):
for suffix in ("", "-wal", "-shm", "-journal"):
with self.subTest(suffix=suffix):
path = Path(str(self.database) + suffix)
path.write_bytes(b"existing installation data")
try:
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(path.read_bytes(), b"existing installation data")
self.assertFalse(self.state_path.exists())
finally:
path.unlink()
def test_lost_keys_after_initialization_are_not_recreated(self):
self.prepare()
self.create_database()
self.state_path.unlink()
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertFalse(self.state_path.exists())
def test_fresh_manual_secrets_conflict_without_writing_state(self):
for name in bootstrap.SECRET_NAMES:
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError):
self.prepare(**{name: "synthetic-manual-secret"})
self.assertFalse(self.state_path.exists())
def test_matching_environment_values_are_accepted_but_conflicts_never_replace_file(self):
first = self.prepare()
original = self.state_path.read_bytes()
keys = {name: first[name] for name in bootstrap.SECRET_NAMES}
self.assertEqual(self.prepare(**keys), first)
for name in bootstrap.SECRET_NAMES:
with self.subTest(name=name), self.assertRaises(bootstrap.BootstrapError) as raised:
self.prepare(**{name: "conflicting-private-value"})
self.assertNotIn("conflicting-private-value", str(raised.exception))
self.assertEqual(self.state_path.read_bytes(), original)
def test_custom_database_location_is_rejected_without_touching_it(self):
custom = self.root / "other.db"
with self.assertRaises(bootstrap.BootstrapError):
self.prepare(SQLITE_PATH=str(custom))
self.assertFalse(custom.exists())
self.assertFalse(self.state_path.exists())
def test_missing_or_symlink_data_directory_is_rejected(self):
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.prepare_environment(self.environment, self.root / "missing")
linked = self.root / "linked-data"
self.create_symlink(linked, self.data, directory=True)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.prepare_environment(self.environment, linked)
self.assertFalse(self.state_path.exists())
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
def test_shared_writable_data_directory_is_rejected(self):
self.data.chmod(0o777)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertFalse(self.state_path.exists())
def test_malformed_json_oversized_and_invalid_schema_never_get_replaced(self):
self.prepare()
valid = self.state()
invalid_states = [
b"not-json", b"\xff", b"x" * (bootstrap.MAX_STATE_BYTES + 1), b"[]", b"{}",
json.dumps({**valid, "version": True}).encode(),
json.dumps({**valid, "version": 2}).encode(),
json.dumps({**valid, "unexpected": "value"}).encode(),
json.dumps({**valid, "JWT_SECRET": None}).encode(),
json.dumps({**valid, "JWT_SECRET": "a" * 64}).encode(),
json.dumps({**valid, "JWT_SECRET": "short"}).encode(),
json.dumps({**valid, "SETUP_TOKEN": valid["JWT_SECRET"]}).encode(),
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": "invalid-key"}).encode(),
json.dumps({**valid, "SETTINGS_ENCRYPTION_KEY": base64.urlsafe_b64encode(b"short").decode()}).encode(),
]
for index, payload in enumerate(invalid_states):
with self.subTest(case=index):
self.state_path.write_bytes(payload)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(self.state_path.read_bytes(), payload)
def test_state_directory_is_not_replaced(self):
self.state_path.mkdir()
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertTrue(self.state_path.is_dir())
def test_state_symlink_is_not_followed_or_replaced(self):
self.prepare()
target = self.root / "original-secrets.json"
self.state_path.rename(target)
original = target.read_bytes()
self.create_symlink(self.state_path, target)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(target.read_bytes(), original)
self.assertTrue(self.state_path.is_symlink())
@unittest.skipUnless(os.name == "posix", "POSIX filesystem permissions")
def test_publicly_readable_secrets_are_rejected_without_fixing_or_overwriting_them(self):
self.prepare()
original = self.state_path.read_bytes()
self.state_path.chmod(0o644)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o644)
self.assertEqual(self.state_path.read_bytes(), original)
@unittest.skipUnless(hasattr(os, "mkfifo"), "POSIX named pipes")
def test_named_pipe_state_is_rejected_without_blocking(self):
os.mkfifo(self.state_path, 0o600)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
self.assertTrue(stat.S_ISFIFO(self.state_path.stat().st_mode))
def test_https_sets_matching_cors_and_secure_cookies(self):
prepared = self.prepare()
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], self.environment["MAGENT_APPLICATION_URL"])
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "true")
def test_explicit_http_lan_origin_disables_secure_cookie_flag_only(self):
prepared = self.prepare(MAGENT_APPLICATION_URL="http://192.0.2.10:3000")
self.assertEqual(prepared["CORS_ALLOW_ORIGIN"], "http://192.0.2.10:3000")
self.assertEqual(prepared["AUTH_COOKIE_SECURE"], "false")
def test_invalid_origin_fails_without_creating_keys(self):
origins = (
"not-a-url", "https://magent.example.test/", "https://magent.example.test/path",
"//magent.example.test", "ftp://magent.example.test", "http:/magent.example.test",
"https://user:password@magent.example.test", "https://@magent.example.test",
"https://magent.example.test?", "https://magent.example.test#",
"https://magent.example.test:0", "https://magent.example.test:65536",
"https://*.example.test", "https://magent.\ttest", "https://magent.example.test\\path",
" https://magent.example.test", "https://magent.example.test\x00",
)
for origin in origins:
with self.subTest(origin=repr(origin)), self.assertRaises(bootstrap.BootstrapError):
self.prepare(MAGENT_APPLICATION_URL=origin)
self.assertFalse(self.state_path.exists())
def test_cors_mismatch_or_cookie_scheme_conflict_fails_without_keys(self):
cases = (
{"CORS_ALLOW_ORIGIN": "https://elsewhere.example.test"},
{"AUTH_COOKIE_SECURE": "false"},
{"AUTH_COOKIE_SECURE": "0"},
{"AUTH_COOKIE_SECURE": "maybe"},
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "true"},
{"MAGENT_APPLICATION_URL": "http://magent.lan:3000", "AUTH_COOKIE_SECURE": "1"},
)
for changes in cases:
with self.subTest(changes=changes), self.assertRaises(bootstrap.BootstrapError):
self.prepare(**changes)
self.assertFalse(self.state_path.exists())
def test_racing_initializers_publish_and_return_one_complete_state(self):
barrier = Barrier(8)
def initialize(_):
barrier.wait(timeout=10)
return self.prepare()
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(initialize, range(8)))
for result in results:
self.assertEqual(result, results[0])
state = self.state()
for name in bootstrap.SECRET_NAMES:
self.assertEqual(state[name], results[0][name])
self.assertEqual(list(self.data.glob(".magent-secrets-*")), [])
def test_token_command_requires_managed_mode_and_does_not_create_state(self):
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token({}, self.data)
self.assertFalse(self.state_path.exists())
with self.assertRaises((bootstrap.BootstrapError, FileNotFoundError)):
bootstrap.setup_token(self.environment, self.data)
self.assertFalse(self.state_path.exists())
self.assertFalse(self.database.exists())
def test_token_command_does_not_create_an_uninitialized_database(self):
self.prepare()
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertFalse(self.database.exists())
def test_token_command_returns_only_initial_token_using_readonly_closed_connection(self):
prepared = self.prepare()
self.create_database()
before = {path.name: path.read_bytes() for path in self.data.iterdir()}
connections = []
real_connect = sqlite3.connect
def connect(*args, **kwargs):
self.assertTrue(kwargs.get("uri"))
self.assertTrue(args[0].endswith("?mode=ro"))
connection = real_connect(*args, **kwargs)
with self.assertRaises(sqlite3.OperationalError):
connection.execute("INSERT INTO users VALUES ('admin')")
connections.append(connection)
return connection
with patch.object(bootstrap.sqlite3, "connect", side_effect=connect):
token = bootstrap.setup_token(self.environment, self.data)
self.assertEqual(token, prepared["SETUP_TOKEN"])
self.assertEqual({path.name: path.read_bytes() for path in self.data.iterdir()}, before)
for connection in connections:
with self.assertRaises(sqlite3.ProgrammingError):
connection.execute("SELECT 1")
def test_token_command_refuses_once_any_admin_exists_even_before_setup_completion(self):
self.prepare()
self.create_database(admin=True)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
def test_token_command_refuses_completed_setup_even_without_admin(self):
self.prepare()
self.create_database(completed=1)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
def test_token_command_refuses_unknown_or_invalid_database_state(self):
self.prepare()
for payload in (b"", b"not a SQLite database"):
with self.subTest(payload=payload):
self.database.write_bytes(payload)
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertEqual(self.database.read_bytes(), payload)
self.database.unlink()
self.create_database()
with closing(sqlite3.connect(self.database)) as connection:
with connection:
connection.execute("DELETE FROM installation_setup")
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
def test_existing_database_symlink_is_rejected_even_with_valid_state(self):
self.prepare()
self.create_database()
target = self.root / "other.db"
self.database.rename(target)
original = target.read_bytes()
self.create_symlink(self.database, target)
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertEqual(target.read_bytes(), original)
def test_existing_database_directory_is_rejected_even_with_valid_state(self):
self.prepare()
self.database.mkdir()
with self.assertRaises(bootstrap.BootstrapError):
self.prepare()
with self.assertRaises(bootstrap.BootstrapError):
bootstrap.setup_token(self.environment, self.data)
self.assertTrue(self.database.is_dir())
def test_startup_passes_keys_to_runtime_without_printing_them(self):
prepared = self.prepare()
stdout, stderr = io.StringIO(), io.StringIO()
with patch.dict(os.environ, self.environment, clear=True), \
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord", "-c", "config"]), \
patch.object(bootstrap, "prepare_environment", return_value=prepared), \
patch.object(bootstrap.os, "execvpe") as execute, \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 0)
execute.assert_called_once_with("supervisord", ["supervisord", "-c", "config"], prepared)
self.assertIn("setup-token", stdout.getvalue())
self.assertEqual(stderr.getvalue(), "")
for name in bootstrap.SECRET_NAMES:
self.assertNotIn(prepared[name], stdout.getvalue() + stderr.getvalue())
def test_disabled_startup_does_not_print_managed_install_instructions(self):
stdout, stderr = io.StringIO(), io.StringIO()
environment = {"JWT_SECRET": "manual-test-value"}
with patch.dict(os.environ, environment, clear=True), \
patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
patch.object(bootstrap.os, "execvpe") as execute, \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 0)
execute.assert_called_once_with("supervisord", ["supervisord"], environment)
self.assertEqual(stdout.getvalue() + stderr.getvalue(), "")
def test_cli_explicit_token_command_prints_only_token_not_other_keys(self):
prepared = self.prepare()
self.create_database()
retrieve = bootstrap.setup_token
stdout, stderr = io.StringIO(), io.StringIO()
with patch.dict(os.environ, self.environment, clear=True), \
patch.object(bootstrap.sys, "argv", ["bootstrap", "setup-token"]), \
patch.object(bootstrap, "setup_token", side_effect=lambda env: retrieve(env, self.data)), \
patch.object(bootstrap.os, "execvpe") as execute, \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 0)
execute.assert_not_called()
self.assertEqual(stdout.getvalue(), prepared["SETUP_TOKEN"] + "\n")
self.assertEqual(stderr.getvalue(), "")
self.assertNotIn(prepared["JWT_SECRET"], stdout.getvalue())
self.assertNotIn(prepared["SETTINGS_ENCRYPTION_KEY"], stdout.getvalue())
def test_cli_unexpected_io_failure_never_logs_sensitive_exception_details(self):
stdout, stderr = io.StringIO(), io.StringIO()
with patch.object(bootstrap.sys, "argv", ["bootstrap", "supervisord"]), \
patch.object(bootstrap, "prepare_environment", side_effect=OSError("private-secret-material")), \
redirect_stdout(stdout), redirect_stderr(stderr):
self.assertEqual(bootstrap.main(), 1)
self.assertEqual(stdout.getvalue(), "")
self.assertNotIn("private-secret-material", stderr.getvalue())
self.assertIn("Check volume permissions", stderr.getvalue())
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Unit checks for the release smoke harness; no Docker or network required."""
from email.message import Message
from email.parser import BytesParser
from email.policy import default
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
HELPER_PATH = Path(__file__).resolve().parents[2] / "scripts" / "container_smoke.py"
SPEC = importlib.util.spec_from_file_location("magent_container_smoke", HELPER_PATH)
smoke = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(smoke)
def response_headers(**changes):
headers = Message()
for key, value in {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'nonce-test-nonce' 'strict-dynamic'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
**changes,
}.items():
headers[key] = value
return headers
class ContainerPackagingHarnessTests(unittest.TestCase):
def test_backup_multipart_preserves_binary_content_and_required_fields(self):
content = b"MAGENT-BACKUP\x00\x01\xff\r\n\x00encrypted"
body, content_type = smoke.backup_restore_upload(content, "synthetic backup passphrase")
parsed = BytesParser(policy=default).parsebytes(
f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + body,
)
fields = {part.get_param("name", header="content-disposition"): part
for part in parsed.iter_parts()}
self.assertEqual(set(fields), {"passphrase", "confirmation", "file"})
self.assertEqual(fields["passphrase"].get_payload(decode=True), b"synthetic backup passphrase")
self.assertEqual(fields["confirmation"].get_payload(decode=True), b"RESTORE")
self.assertEqual(fields["file"].get_payload(decode=True), content)
self.assertEqual(fields["file"].get_filename(), "smoke.magent-backup")
def test_http_rejects_conflicting_body_encodings_without_network(self):
with patch.object(smoke.request, "urlopen") as urlopen:
with self.assertRaisesRegex(AssertionError, "only one encoding"):
smoke.http("/test", payload={}, raw=b"binary")
urlopen.assert_not_called()
def page(self, *, nonce="test-nonce", source="/_next/static/app.js", extra=""):
return (
f'<script nonce="{nonce}" src="{source}"></script>'
f'<script nonce="{nonce}">self.__next_f.push([])</script>'
'<link rel="stylesheet" href="/_next/static/app.css">'
f"{extra}"
).encode()
def test_static_assets_and_every_bootstrap_script_are_validated(self):
seen = []
def fake_http(path):
seen.append(path)
if path == "/login":
return self.page(), response_headers()
return b"static content", response_headers(**{"Content-Type": "application/javascript"})
with patch.object(smoke, "http", side_effect=fake_http):
assets = set()
self.assertEqual(smoke.check_page("/login", assets), "test-nonce")
self.assertEqual(assets, {"/_next/static/app.js", "/_next/static/app.css"})
self.assertEqual(seen, ["/login", "/_next/static/app.css", "/_next/static/app.js"])
smoke.check_page("/login", assets)
self.assertEqual(seen[-1], "/login")
self.assertEqual(len(seen), 4)
def test_nonce_mismatch_fails_before_fetching_assets(self):
with patch.object(smoke, "http", return_value=(self.page(nonce="wrong"), response_headers())):
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
smoke.check_page("/login", set())
def test_missing_nonce_policy_is_rejected(self):
headers = response_headers(**{"Content-Security-Policy": "script-src 'self'"})
with patch.object(smoke, "http", return_value=(self.page(), headers)):
with self.assertRaisesRegex(AssertionError, "missing script nonce policy"):
smoke.check_page("/login", set())
def test_development_eval_policy_is_rejected(self):
headers = response_headers(**{
"Content-Security-Policy": "script-src 'nonce-test-nonce' 'strict-dynamic' 'unsafe-eval'",
})
with patch.object(smoke, "http", return_value=(self.page(), headers)):
with self.assertRaisesRegex(AssertionError, "development eval"):
smoke.check_page("/login", set())
def test_html_fallback_for_static_asset_is_rejected(self):
with patch.object(smoke, "http", return_value=(self.page(), response_headers())):
with self.assertRaisesRegex(AssertionError, "Asset returned HTML"):
smoke.check_page("/login", set())
def test_missing_executable_script_nonce_is_rejected(self):
page = self.page(extra='<script src="/_next/static/missing-nonce.js"></script>')
with patch.object(smoke, "http", return_value=(page, response_headers())):
with self.assertRaisesRegex(AssertionError, "script blocked by its CSP nonce"):
smoke.check_page("/login", set())
def test_inert_json_scripts_do_not_require_executable_nonce(self):
page = self.page(extra='<script type="application/ld+json">{"name":"Magent"}</script>')
with patch.object(smoke, "http", return_value=(page, response_headers())):
cache = {"/_next/static/app.js", "/_next/static/app.css"}
self.assertEqual(smoke.check_page("/login", cache), "test-nonce")
def test_external_scripts_are_not_followed_by_smoke_harness(self):
page = self.page(extra='<script nonce="test-nonce" src="https://external.invalid/app.js"></script>')
with patch.object(smoke, "http", return_value=(page, response_headers())):
with self.assertRaisesRegex(AssertionError, "Unexpected external executable asset"):
smoke.check_page("/login", {"/_next/static/app.js", "/_next/static/app.css"})
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import unittest
from unittest.mock import patch
from scripts.check_environment_docs import (
Setting,
check_documentation,
python_environment_names,
settings_inventory,
)
class EnvironmentDocumentationTests(unittest.TestCase):
def test_reference_covers_repository_variables_and_defaults(self):
errors, count = check_documentation()
self.assertGreater(count, 100)
self.assertEqual(errors, [], "\n".join(errors))
def test_settings_parser_preserves_implicit_names_alias_order_and_defaults(self):
source = '''
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
app_name: str = "Example"
service_url: str = Field(default=None, validation_alias=AliasChoices("SERVICE_URL", "OLD_URL"))
enabled: bool = Field(default=False, validation_alias="ENABLED")
interval: int = Field(default=60)
build_number: str = Field(default=BUILD_NUMBER)
'''
self.assertEqual(settings_inventory(source), [
Setting(("APP_NAME",), '"Example"'),
Setting(("SERVICE_URL", "OLD_URL"), "null"),
Setting(("ENABLED",), "false"),
Setting(("INTERVAL",), "60"),
Setting(("BUILD_NUMBER",), "@BUILD_NUMBER"),
])
def test_python_scanner_handles_reads_writes_and_bootstrap_mapping(self):
source = '''
os.getenv("METRICS_ENABLED", "false")
os.environ.get("WORKERS_ENABLED", "true")
environment.get("MANAGED_SECRETS", "auto")
prepared["GENERATED_KEY"] = "not-a-real-key"
other.get("NOT_AN_ENVIRONMENT_VARIABLE")
environment.get("lowercase-internal-key")
'''
self.assertEqual(python_environment_names(source), {
"METRICS_ENABLED", "WORKERS_ENABLED", "MANAGED_SECRETS", "GENERATED_KEY",
})
def test_scanning_never_executes_source_or_imports_settings(self):
source = '\ufeffraise RuntimeError("must not execute")\nos.getenv("SAFE_TO_SCAN")\n'
self.assertEqual(python_environment_names(source), {"SAFE_TO_SCAN"})
def test_reference_guard_reports_missing_variables_and_stale_defaults(self):
document = '| `RETRY_SECONDS` | `30` | Retry interval |'
source = 'class Settings(BaseSettings):\n retry_seconds: int = 60\n'
with patch("scripts.check_environment_docs.Path.read_text", side_effect=[document, source]), \
patch("scripts.check_environment_docs.runtime_environment_names", return_value={"NEW_FLAG"}):
errors, count = check_documentation()
self.assertEqual(count, 2)
self.assertIn("Undocumented environment variable: NEW_FLAG", errors)
self.assertTrue(any("Stale source default for RETRY_SECONDS" in error for error in errors))
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -39,6 +39,9 @@ class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
content = email.await_args.kwargs
self.assertEqual(content["subject"], f"Ready to try again? Magent issue #{item['id']}")
self.assertIn("affected content in Jellyfin", content["body_text"])
self.assertNotIn("grizzlyflix", content["body_html"].lower())
self.assertIn("YES — it works", content["body_html"])
self.assertIn("NO — still broken", content["body_html"])
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
+379
View File
@@ -0,0 +1,379 @@
"""Managed first-install origin claims use a token, not request routing headers."""
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
import sqlite3
import tempfile
from threading import Barrier
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from backend.app import auth, db, main
from backend.app.config import settings
from backend.app.installation_origin import normalize_application_origin
from backend.app.security import create_access_token
from backend.app.services import setup
from backend.app.services.public_urls import magent_public_url
from backend.app.services.request_origins import can_claim_initial_origin, is_allowed_request_origin
PUBLIC_ORIGIN = "https://watch.example.test"
LOCAL_ORIGIN = "http://localhost:3000"
SETUP_TOKEN = "managed-origin-operator-token-for-tests-only-1234567890"
ADMIN_PASSWORD = "Managed-origin-password-for-tests!123"
class ApplicationOriginNormalizationTests(unittest.TestCase):
def test_exact_origins_are_canonicalized(self):
for value, expected in (
(PUBLIC_ORIGIN, PUBLIC_ORIGIN),
("HTTPS://WATCH.EXAMPLE.TEST:443/", PUBLIC_ORIGIN),
("http://magent.lan:80/", "http://magent.lan"),
("http://192.0.2.10:3000", "http://192.0.2.10:3000"),
("http://[fd00::10]:3000/", "http://[fd00::10]:3000"),
):
with self.subTest(value=value):
self.assertEqual(normalize_application_origin(value), expected)
def test_non_origins_and_ambiguous_values_are_rejected(self):
for value in (
"", "null", "*", "magent.lan", "//magent.lan", "https:/magent.lan",
"ftp://magent.lan", "https://user@magent.lan", "https://user:secret@magent.lan",
"https://magent.lan/path", "https://magent.lan/../", "https://magent.lan?",
"https://magent.lan#", "https://magent.lan?token=1", "https://magent.lan#fragment",
"https://*.magent.lan", "https://magent.lan:0", "https://magent.lan:65536",
"https://magent.lan:", "https://magent.lan\\path", " https://magent.lan",
"https://magent.\tlan", "https://magent.lan\x00",
):
with self.subTest(value=repr(value)), self.assertRaises(ValueError):
normalize_application_origin(value)
class ManagedSetupOriginTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temporary.cleanup)
self.enterContext(patch.dict(os.environ, {"MAGENT_RUNTIME_MANAGED": "1"}))
for name, value in {
"sqlite_path": str(Path(temporary.name) / "managed-setup.db"),
"sqlite_journal_mode": "DELETE",
"jwt_secret": "managed-origin-test-signing-key-at-least-32-characters",
"settings_encryption_key": None,
"setup_token": SETUP_TOKEN,
"admin_username": "unused-environment-admin",
"admin_password": "",
"cors_allow_origin": LOCAL_ORIGIN,
"magent_application_url": None,
"magent_proxy_enabled": False,
"magent_proxy_base_url": None,
"auth_cookie_domain": None,
"auth_cookie_secure": False,
}.items():
self.enterContext(patch.object(settings, name, value))
setup.initialize_setup_state()
db.init_db()
# No production startup: no external workers, listeners or real data.
self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN)
self.addCleanup(self.client.close)
def bootstrap(self, *, origin=PUBLIC_ORIGIN, headers=None, **changes):
request_headers = {} if origin is None else {"Origin": origin}
request_headers.update(headers or {})
return self.client.post("/setup/bootstrap", headers=request_headers, json={
"setup_token": SETUP_TOKEN,
"username": "first-admin",
"password": ADMIN_PASSWORD,
"application_url": PUBLIC_ORIGIN,
**changes,
})
def assert_unclaimed(self):
self.assertFalse(db.has_admin_user())
self.assertIsNone(db.get_setting("magent_application_url"))
self.assertEqual(setup.get_setup_state()["step"], "administrator")
def preflight(self, origin):
return self.client.options("/auth/login", headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type",
})
def admin_headers(self, origin=PUBLIC_ORIGIN):
return {
"Origin": origin,
"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}",
}
def test_fresh_managed_install_does_not_trust_any_origin_before_claim(self):
self.assertTrue(can_claim_initial_origin())
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
with self.subTest(origin=origin):
self.assertFalse(is_allowed_request_origin(origin))
self.assert_unclaimed()
def test_valid_operator_claim_creates_admin_and_persists_url_atomically(self):
response = self.bootstrap(application_url="HTTPS://WATCH.EXAMPLE.TEST:443/")
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
self.assertIsNotNone(db.verify_user_password("first-admin", ADMIN_PASSWORD))
self.assertEqual(setup.get_setup_state()["step"], "apps")
self.assertFalse(can_claim_initial_origin())
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN))
def test_missing_or_null_application_url_does_not_claim(self):
response = self.bootstrap(application_url=None)
self.assertEqual(response.status_code, 400, response.text)
response = self.client.post("/setup/bootstrap", headers={"Origin": PUBLIC_ORIGIN}, json={
"setup_token": SETUP_TOKEN, "username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 400, response.text)
self.assert_unclaimed()
def test_missing_browser_origin_does_not_claim(self):
response = self.bootstrap(origin=None)
self.assertEqual(response.status_code, 403, response.text)
self.assert_unclaimed()
def test_wrong_token_cannot_claim_even_when_url_matches_evil_origin(self):
for origin in (PUBLIC_ORIGIN, "https://evil.example.test"):
with self.subTest(origin=origin):
response = self.bootstrap(origin=origin, application_url=origin, setup_token="wrong-token")
self.assertEqual(response.status_code, 403, response.text)
self.assertNotIn(SETUP_TOKEN, response.text)
self.assert_unclaimed()
def test_different_origin_and_spoofed_routing_headers_cannot_claim(self):
response = self.bootstrap(origin="https://evil.example.test", headers={
"Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test",
"X-Forwarded-Proto": "https", "Sec-Fetch-Site": "same-origin",
})
self.assertEqual(response.status_code, 403, response.text)
self.assert_unclaimed()
def test_invalid_application_urls_never_claim_or_echo_secrets(self):
with patch.object(setup, "consume_bootstrap_attempt", return_value=None):
for value in ("", "https://user:secret@watch.example.test", PUBLIC_ORIGIN + "/path",
PUBLIC_ORIGIN + "?", "javascript:alert(1)", "//watch.example.test"):
with self.subTest(value=value):
response = self.bootstrap(application_url=value)
self.assertEqual(response.status_code, 400, response.text)
self.assertNotIn(SETUP_TOKEN, response.text)
self.assertNotIn(ADMIN_PASSWORD, response.text)
self.assert_unclaimed()
def test_other_state_changing_endpoints_do_not_inherit_bootstrap_origin_exception(self):
for path, method in (("/auth/login", "post"), ("/auth/jellyfin/login", "post"),
("/setup/complete", "post"), ("/setup/state", "put"),
("/setup/bootstrap/", "post"), ("/admin/settings", "put")):
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
with self.subTest(path=path, origin=origin):
response = getattr(self.client, method)(path, json={}, headers={"Origin": origin}, follow_redirects=False)
self.assertEqual(response.status_code, 403, response.text)
self.assertEqual(response.json()["detail"], "Cross-origin state change rejected")
self.assert_unclaimed()
def test_existing_admin_prevents_reclaim_and_url_replacement(self):
self.assertEqual(self.bootstrap().status_code, 201)
response = self.bootstrap(username="second-admin")
self.assertEqual(response.status_code, 409, response.text)
response = self.bootstrap(origin="https://evil.example.test", application_url="https://evil.example.test")
self.assertEqual(response.status_code, 403, response.text)
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
self.assertEqual(len(db.get_all_users()), 1)
def test_completed_install_cannot_reopen_origin_claim_after_admin_removal(self):
self.assertEqual(self.bootstrap().status_code, 201)
setup.complete_setup()
with db._connect() as connection:
connection.execute("DELETE FROM users")
connection.execute("DELETE FROM settings WHERE key='magent_application_url'")
self.assertFalse(can_claim_initial_origin())
self.assertEqual(self.bootstrap().status_code, 403)
self.assertFalse(db.has_admin_user())
def test_setting_write_failure_rolls_back_admin_and_setup_progress(self):
with db._connect() as connection:
connection.execute("""CREATE TRIGGER reject_origin BEFORE INSERT ON settings
WHEN NEW.key = 'magent_application_url'
BEGIN SELECT RAISE(ABORT, 'synthetic origin storage failure'); END""")
with self.assertRaises(sqlite3.IntegrityError):
setup.bootstrap_administrator(SETUP_TOKEN, "first-admin", ADMIN_PASSWORD, application_url=PUBLIC_ORIGIN)
self.assert_unclaimed()
def test_concurrent_claims_keep_the_winning_admin_and_origin_together(self):
barrier = Barrier(4)
def synchronized_hash(_):
barrier.wait(timeout=10)
return "test-only-precomputed-hash"
def claim(number):
try:
setup.bootstrap_administrator(
SETUP_TOKEN, f"owner-{number}", ADMIN_PASSWORD,
application_url=f"https://owner-{number}.example.test",
)
return number
except setup.SetupUnavailableError:
return None
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
with ThreadPoolExecutor(max_workers=4) as executor:
winners = [number for number in executor.map(claim, range(4)) if number is not None]
self.assertEqual(len(winners), 1)
self.assertEqual([user["username"] for user in db.get_all_users()], [f"owner-{winners[0]}"])
self.assertEqual(db.get_setting("magent_application_url"), f"https://owner-{winners[0]}.example.test")
def test_first_https_login_uses_secure_cookie_without_restart(self):
self.assertEqual(self.bootstrap().status_code, 201)
self.assertFalse(settings.auth_cookie_secure)
response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 200, response.text)
cookie = next(value for value in response.headers.get_list("set-cookie")
if value.startswith(settings.auth_cookie_name + "="))
self.assertIn("Secure", cookie)
self.assertIn("HttpOnly", cookie)
self.assertEqual(self.client.get("/auth/me").status_code, 200)
def test_http_lan_claim_uses_non_secure_cookie_despite_static_secure_default(self):
origin = "http://magent.lan:3000"
self.assertEqual(self.bootstrap(origin=origin, application_url=origin).status_code, 201)
with patch.object(settings, "auth_cookie_secure", True):
self.assertFalse(auth._cookie_settings()["secure"])
self.assertTrue(auth._cookie_settings()["httponly"])
as_client = TestClient(main.app, base_url=origin)
try:
response = as_client.post("/auth/login", headers={"Origin": origin}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(as_client.get("/auth/me").status_code, 200)
finally:
as_client.close()
self.assertEqual(response.status_code, 200, response.text)
cookie = next(value for value in response.headers.get_list("set-cookie")
if value.startswith(settings.auth_cookie_name + "="))
self.assertNotIn("Secure", cookie)
self.assertIn("HttpOnly", cookie)
def test_login_origin_policy_and_saved_url_survive_setup_reinitialization(self):
self.assertEqual(self.bootstrap().status_code, 201)
setup.initialize_setup_state()
db.init_db()
self.assertFalse(can_claim_initial_origin())
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
for origin in (LOCAL_ORIGIN, "https://evil.example.test"):
response = self.client.post("/auth/login", headers={"Origin": origin}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 403, response.text)
response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={
"username": "first-admin", "password": ADMIN_PASSWORD,
})
self.assertEqual(response.status_code, 200, response.text)
def test_managed_origin_policy_changes_with_saved_settings(self):
self.assertEqual(self.bootstrap().status_code, 201)
db.set_setting("magent_application_url", "http://magent.lan:3000")
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN))
self.assertTrue(is_allowed_request_origin("http://magent.lan:3000"))
self.assertFalse(auth._cookie_settings()["secure"])
def test_unclaimed_install_does_not_grant_cors_to_any_browser_origin(self):
for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"):
with self.subTest(origin=origin):
response = self.preflight(origin)
self.assertEqual(response.status_code, 400, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
response = self.client.get("/setup/status", headers={"Origin": origin})
self.assertEqual(response.status_code, 200, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
self.assert_unclaimed()
def test_claim_immediately_updates_cors_preflights_and_response_headers(self):
self.assertEqual(self.bootstrap().status_code, 201)
response = self.preflight(PUBLIC_ORIGIN)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN)
self.assertEqual(response.headers["access-control-allow-credentials"], "true")
self.assertIn("Origin", response.headers["vary"])
response = self.client.get("/setup/status", headers={"Origin": PUBLIC_ORIGIN})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN)
self.assertEqual(response.headers["access-control-allow-credentials"], "true")
def test_claimed_install_denies_localhost_and_foreign_cors_preflights_and_reads(self):
self.assertEqual(self.bootstrap().status_code, 201)
for origin in (LOCAL_ORIGIN, "https://evil.example.test", PUBLIC_ORIGIN + "/", "null"):
with self.subTest(origin=origin):
response = self.preflight(origin)
self.assertEqual(response.status_code, 400, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
response = self.client.get("/setup/status", headers={"Origin": origin})
self.assertEqual(response.status_code, 200, response.text)
self.assertNotIn("access-control-allow-origin", response.headers)
def test_admin_cannot_blank_or_malform_managed_url_or_partially_save_other_settings(self):
self.assertEqual(self.bootstrap().status_code, 201)
for value in (None, "", " ", False, 123, [], {}, "javascript:alert(1)", "//watch.example.test",
PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?", "https://user:secret@watch.example.test"):
with self.subTest(value=value):
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={
"site_login_message": "must-not-be-written",
"magent_application_url": value,
})
self.assertEqual(response.status_code, 400, response.text)
self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN)
self.assertIsNone(db.get_setting("site_login_message"))
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(can_claim_initial_origin())
def test_admin_url_update_is_canonical_and_immediately_replaces_cors_origin(self):
self.assertEqual(self.bootstrap().status_code, 201)
next_origin = "https://new.example.test"
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={
"magent_application_url": "HTTPS://NEW.EXAMPLE.TEST:443/",
})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(db.get_setting("magent_application_url"), next_origin)
self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 400)
response = self.preflight(next_origin)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["access-control-allow-origin"], next_origin)
response = self.client.get("/setup/status", headers={"Origin": next_origin})
self.assertEqual(response.headers["access-control-allow-origin"], next_origin)
response = self.client.put("/admin/settings", headers=self.admin_headers(), json={"site_login_message": "stale"})
self.assertEqual(response.status_code, 403, response.text)
response = self.client.put("/admin/settings", headers=self.admin_headers(next_origin), json={"site_login_message": "new"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(db.get_setting("site_login_message"), "new")
def test_proxy_settings_cannot_replace_managed_origin_or_lower_cookie_security(self):
self.assertEqual(self.bootstrap().status_code, 201)
db.set_setting("magent_proxy_enabled", "true")
db.set_setting("magent_proxy_base_url", "http://proxy.example.test")
self.assertEqual(magent_public_url(), PUBLIC_ORIGIN)
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
self.assertFalse(is_allowed_request_origin("http://proxy.example.test"))
self.assertTrue(auth._cookie_settings()["secure"])
self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 200)
self.assertEqual(self.preflight("http://proxy.example.test").status_code, 400)
def test_unclaimed_managed_url_ignores_legacy_proxy_and_link_fallback(self):
db.set_setting("magent_proxy_enabled", "true")
db.set_setting("magent_proxy_base_url", "https://proxy.example.test")
self.assertEqual(magent_public_url("https://legacy.example.test"), "")
self.assertTrue(can_claim_initial_origin())
self.assertFalse(is_allowed_request_origin("https://proxy.example.test"))
self.assertEqual(self.preflight("https://proxy.example.test").status_code, 400)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -91,6 +91,9 @@ class NewsletterConsentTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase
result = await service.subscribe(self.user)
self.assertEqual(result['state'], 'pending')
rendered = sender.call_args.args[1]
self.assertEqual(rendered['subject'], 'Confirm your Magent newsletter subscription')
self.assertIn('NEW IN YOUR LIBRARY', rendered['body_html'])
self.assertNotIn('grizzlyflix', rendered['body_html'].lower())
self.assertNotIn('Arrival', rendered['body_html'])
url = re.search(r'https://[^\s]+', rendered['body_text']).group(0)
self.assertEqual(urlsplit(url).path, '/newsletter-subscription')
@@ -239,6 +242,7 @@ class NewsletterEditionTests(NewsletterFixture, unittest.TestCase):
store.complete_weekly(claimed, content(), END+timedelta(hours=1))
store.enqueue_due((END+timedelta(hours=1)).timestamp())
self.assertEqual(len(store.overview()['editions']), 1)
self.assertEqual(store.overview()['editions'][0]['subject'], 'Whats new in your library · 11 Sep 2026')
self.assertEqual(store.overview()['total'], 0)
self.assertEqual(store.settings()['next_send_at'], (END+timedelta(days=7)).timestamp())
self.assertTrue(config['enabled'])
@@ -380,6 +384,30 @@ class NewsletterCatalogTests(unittest.IsolatedAsyncioTestCase):
class NewsletterDeliveryTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
async def test_new_draft_has_generic_subject_and_preserves_custom_intro(self):
custom_intro = 'News from our own media community.'
store.save_settings({**self.config, 'intro': custom_intro}, datetime.now(timezone.utc))
with patch.object(service, 'collect', new=AsyncMock(return_value=content())):
draft = await service.create_draft(self.user, 7)
self.assertTrue(draft['subject'].startswith('Whats new in your library · '))
self.assertEqual(draft['intro'], custom_intro)
def test_generic_email_template_preserves_custom_subject_and_intro(self):
custom_subject = 'Grizzlyflix weekend discoveries'
custom_intro = 'Welcome to our own <media> community.'
rendered = template.render(
{**content(), 'subject': custom_subject, 'intro': custom_intro}, {},
self.config['public_url'], self.runtime.jellyfin_public_url,
'https://beta.example.test/profile#newsletters',
)
self.assertEqual(rendered['subject'], custom_subject)
self.assertIn(custom_subject, rendered['body_text'])
self.assertIn(custom_intro, rendered['body_text'])
self.assertIn('Welcome to our own &lt;media&gt; community.', rendered['body_html'])
self.assertIn('Watch on Jellyfin', rendered['body_html'])
self.assertIn('Explore Jellyfin', rendered['body_html'])
self.assertNotIn('grizzlyflix', rendered['body_html'].lower())
async def test_weekly_worker_collects_once_and_delivers_to_confirmed_subscriber(self):
now = datetime.now(timezone.utc)
self.subscribe((now-timedelta(days=14)).timestamp())
+6
View File
@@ -0,0 +1,6 @@
# Optional source build; retain compose.yml's storage and security defaults.
# docker compose -f compose.yml -f compose.build.yml up -d --build
services:
magent:
image: magent:local
build: .
+27
View File
@@ -0,0 +1,27 @@
# Fresh installs: paste this file into a Portainer Docker Standalone stack.
# Configure the site address and connected apps in Magent's setup wizard.
# No Dockerfile, source checkout, .env file or shared default password is needed.
# Existing installations must keep their original data mount and keys.
services:
magent:
image: rephl3xnz/magent:latest
ports:
# LAN access by default. Restrict with a firewall; use HTTPS for public use.
# Only the frontend is published; it also serves /api.
- "3000:3000"
volumes:
# Contains the database, settings, cache and private generated keys.
- magent-data:/app/data
restart: unless-stopped
stop_grace_period: 30s
# For security, leave these settings unchanged unless you understand the risks.
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
volumes:
magent-data:
-37
View File
@@ -1,37 +0,0 @@
name: magent-beta
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_NAME: Magent Beta
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true"
SITE_BANNER_MESSAGE: "Beta environment"
SITE_BANNER_TONE: warning
ports:
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
- "127.0.0.1:8100:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
+16 -4
View File
@@ -1,13 +1,22 @@
services:
magent:
image: rephl3xnz/magent:latest
# Select a published immutable release tag or digest in .env.
image: ${MAGENT_IMAGE:?Set MAGENT_IMAGE to a published release tag or digest}
env_file:
- ./.env
environment:
JWT_SECRET: ${JWT_SECRET:?Generate an independent JWT_SECRET before starting}
SETTINGS_ENCRYPTION_KEY: ${SETTINGS_ENCRYPTION_KEY:?Set the original or newly generated Fernet key}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:?Set the exact browser-facing origin}
MAGENT_APPLICATION_URL: ${MAGENT_APPLICATION_URL:?Set the browser-facing application URL}
ports:
- "3000:3000"
- "127.0.0.1:8000:8000"
# Keep the API internal; the frontend serves /api on this same port.
- "${MAGENT_BIND_ADDRESS:-127.0.0.1}:${MAGENT_HTTP_PORT:-3000}:3000"
volumes:
- ./data:/app/data
# Fresh installs only: existing installs must retain their original mount.
- magent-data:/app/data
restart: unless-stopped
stop_grace_period: 30s
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
@@ -15,3 +24,6 @@ services:
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
volumes:
magent-data:
-23
View File
@@ -1,23 +0,0 @@
name: magent-production
services:
magent:
build: .
env_file:
- ./.env
environment:
AUTH_COOKIE_SECURE: "true"
AUTH_COOKIE_SAMESITE: strict
ports:
- "10.30.1.32:3200:3000"
- "127.0.0.1:8200:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
-19
View File
@@ -1,19 +0,0 @@
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
ports:
- "3000:3000"
- "127.0.0.1:8000:8000"
volumes:
- ./data:/app/data
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
init: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
-5
View File
@@ -1,5 +0,0 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101012"><title>Coming soon | Magent — Grizzlyflix</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#101012;color:#f4f0ff;font-family:Arial,Helvetica,sans-serif}main{min-height:100svh;padding:60px 24px 28px;text-align:center;display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at 50% 20%,#282139,transparent 60%)}.brand{color:#c7bdff;letter-spacing:.3em;font-size:14px;font-weight:700;margin-bottom:36px}.badge{color:#8be7f1;border:1px solid #6eddec66;border-radius:30px;padding:10px 22px;font-size:12px;letter-spacing:.15em}h1{font-size:clamp(40px,7vw,88px);line-height:1.08;letter-spacing:-.045em;margin:28px 0 22px}h1 span{color:#c7bdff}p{max-width:560px;color:#bcb8c9;line-height:1.65;font-size:18px;margin:0}.steps{display:grid;grid-template-columns:repeat(3,1fr);width:min(580px,100%);margin:40px 0 24px;border:1px solid #ffffff20;border-radius:16px;background:#ffffff04}.steps div{padding:22px 12px;display:grid;gap:8px}.steps div+div{border-left:1px solid #ffffff15}.steps small{color:#8be7f1}.note{font-size:14px;color:#a9a4b5}footer{margin-top:60px;color:#a9a4b5;font-size:12px;display:flex;flex-wrap:wrap;justify-content:center;gap:14px}a{color:#c7bdff;text-underline-offset:3px}a:focus-visible{outline:2px solid #8be7f1;outline-offset:5px}
</style></head><body><main><div class="brand">GRIZZLYFLIX</div><div class="badge">COMING SOON</div><h1>Your next watch.<br><span>Made simpler.</span></h1><p>The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p><div class="steps" aria-label="Request journey"><div><small>01</small><strong>Request</strong></div><div><small>02</small><strong>Track</strong></div><div><small>03</small><strong>Watch</strong></div></div><p class="note">Were getting everything ready. Check back soon.</p><footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer></main></body></html>
+3
View File
@@ -0,0 +1,3 @@
# Process supervision shares the application's Python runtime; do not install
# the OS supervisor package and a second system Python into the release image.
supervisor==4.3.0
+6 -2
View File
@@ -14,11 +14,13 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=10
stopasgroup=true
killasgroup=true
[program:frontend]
directory=/app/frontend
command=/usr/bin/npm start -- --hostname 0.0.0.0 --port 3000
environment=NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production"
command=/usr/local/bin/node /app/frontend/server.js
environment=HOSTNAME="0.0.0.0",PORT="3000",NEXT_PUBLIC_API_BASE="/api",BACKEND_INTERNAL_URL="http://127.0.0.1:8000",NODE_ENV="production",NEXT_TELEMETRY_DISABLED="1"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
@@ -26,3 +28,5 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=20
stopasgroup=true
killasgroup=true
+266
View File
@@ -0,0 +1,266 @@
# Environment variable reference
The public [Portainer stack](../compose.yml) needs **no environment variables**.
Its image supplies the runtime defaults; use the first-run setup wizard to set
the application URL, connect services and configure notifications. The normal
stack does not need a Dockerfile, source checkout or `.env` file.
This reference also covers advanced/manual deployments, compatibility aliases,
image-build inputs and repository-only tooling. A variable being listed here
does **not** mean that it belongs in the public Compose file.
## Managed installation defaults and precedence
- The image defaults `MAGENT_MANAGED_SECRETS` to `auto`. With no manually supplied
`JWT_SECRET`, startup generates independent signing, encryption and setup keys
once, then reloads them from `/app/data/bootstrap-secrets.json`. An existing
explicit signing key selects the legacy/manual path. Do not switch an existing
installation's key management, volume or keys just to match a fresh template.
- Managed installations fix `SQLITE_PATH` to `/app/data/magent.db` and
`API_DOCS_ENABLED` to `false`; these are not setup choices. Preserve the entire
`/app/data` volume, including the private keys. Startup fails rather than
silently replacing missing keys beside an existing database.
- Set the browser-facing application URL in setup. Managed CORS accepts the
configured same origin; do not configure `*` or invent an external API origin.
Before a URL is saved, only the token-authorized first-administrator setup can
claim the initial origin; arbitrary public requests do not establish trust.
HTTPS is required for public hosting. A private LAN can use explicit HTTP.
- Source defaults below describe `backend/app/config.py` before managed bootstrap
or saved configuration is applied. Saved, supported application settings take
precedence over their environment fallback. Security/bootstrap settings are
deployment controls, not ordinary editable settings. Container listener ports
are fixed by its process supervisor, not by application settings.
- For manual deployments, environment variables are read at process startup.
Restart/recreate after changing them. A `.env` file is loaded by the relevant
Compose template's `env_file`, not automatically discovered by the application.
Keep manual credentials stable across upgrades and offline restores.
Defaults use JSON notation: `null` means unset, `""` means an empty string,
`true`/`false` are booleans, and `@BUILD_NUMBER`/`@CHANGELOG` are bundled build
metadata. Do not literally enter `null` or the `@...` labels into Portainer.
Aliases in one row refer to the same setting; if multiple aliases are present,
the first listed alias wins. Use only one. Never place secrets into browser-
visible `NEXT_PUBLIC_*` variables, URLs, screenshots or public support reports.
## Core, authentication and storage
| Variable / aliases | Source default | Purpose and managed-install behaviour |
| --- | --- | --- |
| `APP_NAME` | `"Magent"` | Backend application name. |
| `CORS_ALLOW_ORIGIN` | `"http://localhost:3000"` | Legacy exact allowed browser origin. Managed installs use the URL confirmed in setup automatically; no Compose override needed. |
| `SQLITE_PATH` | `"data/magent.db"` | SQLite database file. Managed container path is fixed to `/app/data/magent.db`; manual source deployments retain their existing path. |
| `SQLITE_JOURNAL_MODE` | `"DELETE"` | SQLite journal mode; retain the default unless deliberately configuring storage behaviour. |
| `JWT_SECRET` | `""` | Secret signing key. Generated/persisted in managed mode; manual mode requires a strong non-default value of at least 32 characters. |
| `JWT_EXP_MINUTES` | `120` | Authentication token lifetime in minutes. |
| `JWT_ISSUER` | `"magent"` | Expected JWT issuer. Changing it invalidates existing tokens. |
| `JWT_AUDIENCE` | `"magent-web"` | Expected JWT audience. Changing it invalidates existing tokens. |
| `SETTINGS_ENCRYPTION_KEY` | `null` | Secret Fernet key for stored integration credentials. Generated/persisted in managed mode. Preserve an existing manual key (or the existing legacy signing-key-derived configuration). |
| `API_DOCS_ENABLED` | `false` | OpenAPI/interactive API documentation. Forced off in managed mode; leave off for public deployment. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Login rate-limit window in seconds. |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP` | `15` | Login attempts allowed per client IP per window. |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER` | `5` | Login attempts allowed per user identifier per window. |
| `PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS` | `300` | Password-reset rate-limit window in seconds. |
| `PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP` | `6` | Password-reset attempts allowed per IP per window. |
| `PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER` | `3` | Password-reset attempts allowed per account identifier per window. |
| `ADMIN_USERNAME` | `"admin"` | Legacy bootstrap username when supplying an initial administrator password. Wizard-created administrators choose their own name. |
| `ADMIN_PASSWORD` | `""` | Optional manual first-administrator password, minimum 12 characters. Leave empty for token-protected setup; never use a shared default password. |
| `SETUP_TOKEN` | `""` | Secret one-time administrator-creation credential. Generated in managed mode; retrieve using `python -m app.container_bootstrap setup-token` in the container console. Not shown once the first administrator exists/setup is complete. |
| `AUTH_COOKIE_NAME` | `"magent_auth"` | HttpOnly session-cookie name. |
| `AUTH_COOKIE_SECURE` | `false` | Legacy cookie Secure flag; public HTTPS deployments require `true`. Managed mode derives the correct behaviour from its confirmed application URL. |
| `AUTH_COOKIE_SAMESITE` | `"strict"` | Authentication cookie SameSite policy; retain `strict` unless deliberately evaluating a different deployment model. |
| `AUTH_COOKIE_DOMAIN` | `null` | Cookie domain override; unset creates safer host-only cookies. |
| `AUTH_STATE_COOKIE_NAME` | `"magent_logged_in"` | Non-secret UI login-state marker. The frontend expects its normal default. |
## Logs, request cache and issue follow-up
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `LOG_LEVEL` | `"INFO"` | Application log verbosity. |
| `LOG_FORMAT` | `"text"` | `text` for readable logs or `json` for structured collection. |
| `LOG_FILE` | `"data/magent.log"` | Rotating application log; resolves to `/app/data/magent.log` in the combined image. |
| `LOG_FILE_MAX_BYTES` | `20000000` | Maximum active log size before rotation, in bytes. |
| `LOG_FILE_BACKUP_COUNT` | `10` | Number of rotated log files to keep. |
| `LOG_HTTP_CLIENT_LEVEL` | `"INFO"` | Outbound integration HTTP logging verbosity. |
| `LOG_BACKGROUND_SYNC_LEVEL` | `"INFO"` | Scheduled background-sync log verbosity. |
| `REQUESTS_SYNC_TTL_MINUTES` | `1440` | Request-cache freshness period in minutes. |
| `REQUESTS_STAGE_REFRESH_MINUTES` | `15` | Background request-stage refresh interval, from 1 to 1440 minutes. Short intervals increase integration traffic. |
| `REQUESTS_POLL_INTERVAL_SECONDS` | `300` | Interval for checking whether a full request sync is due. |
| `REQUESTS_DELTA_SYNC_INTERVAL_MINUTES` | `5` | Incremental new/changed request polling interval. |
| `REQUESTS_FULL_SYNC_TIME` | `"00:00"` | Daily full request-cache rebuild time, `HH:MM`. |
| `REQUESTS_CLEANUP_TIME` | `"02:00"` | Daily request-history cleanup time, `HH:MM`. |
| `REQUESTS_CLEANUP_DAYS` | `90` | Request-history retention period in days. |
| `REQUESTS_DATA_SOURCE` | `"prefer_cache"` | Request data-source strategy; prefer cached results by default. |
| `ISSUE_CONFIRMATION_CONTACT_ATTEMPTS` | `2` | Confirmation emails after an issue is fixed; `0` closes without sending these emails. |
| `ISSUE_CONFIRMATION_INTERVAL_VALUE` | `3` | Time between confirmation attempts and final closure wait, in the unit below. |
| `ISSUE_CONFIRMATION_INTERVAL_UNIT` | `"days"` | Follow-up interval unit: `days`, `weeks` or `months`. |
| `ARTWORK_CACHE_MODE` | `"remote"` | Artwork delivery mode; remote sources by default, local caching when configured. |
## Site appearance and login
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `SITE_BUILD_NUMBER` | `@BUILD_NUMBER` | Bundled release build number; normally leave unchanged. |
| `SITE_CHANGELOG` | `@CHANGELOG` | Bundled release changelog; normally leave unchanged. |
| `SITE_BANNER_ENABLED` | `false` | Enable the signed-in sitewide announcement banner. |
| `SITE_BANNER_MESSAGE` | `null` | Banner message. |
| `SITE_BANNER_TONE` | `"info"` | Banner preset tone when custom colours are unset. |
| `SITE_BANNER_BACKGROUND_COLOR` | `null` | Optional six-digit hexadecimal banner background colour, e.g. `#123456`. |
| `SITE_BANNER_BORDER_COLOR` | `null` | Optional six-digit hexadecimal banner border colour. |
| `SITE_LOGIN_MESSAGE` | `null` | Separate message on the logged-out login page. |
| `SITE_LOGIN_SHOW_JELLYFIN_LOGIN` | `true` | Show the Jellyfin login option. |
| `SITE_LOGIN_SHOW_LOCAL_LOGIN` | `true` | Show local Magent login. |
| `SITE_LOGIN_SHOW_FORGOT_PASSWORD` | `true` | Show password recovery. |
| `SITE_LOGIN_SHOW_SIGNUP_LINK` | `true` | Show invite signup. |
| `SITE_NAV_SHOW_REQUESTS` | `true` | Show requests navigation. |
## Application URLs, proxy and TLS
Configure these supported settings in setup/admin rather than adding environment
entries to the public stack. Do not assume these settings reconfigure Docker
port mappings or the bundled supervisor: it listens on frontend `3000` and
backend `8000`, with only `3000` published. Terminate public HTTPS at a reverse
proxy. Only explicitly trusted proxy addresses may supply forwarded headers.
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `MAGENT_APPLICATION_URL` | `null` | Canonical browser-facing origin used by links and request-origin protection. Confirm it in setup; no initial environment value is required in managed mode. |
| `MAGENT_APPLICATION_PORT` | `3000` | Preferred application port metadata for direct/local deployments; does not change the image listener or host port mapping. |
| `MAGENT_API_URL` | `null` | Optional canonical API URL metadata. Normal users access same-origin `/api`; do not expose backend port 8000 publicly. |
| `MAGENT_API_PORT` | `8000` | Preferred backend port metadata; does not change the image listener. |
| `MAGENT_BIND_HOST` | `"0.0.0.0"` | Direct/local-hosting bind configuration; container supervisor retains its configured listeners. |
| `MAGENT_PROXY_ENABLED` | `false` | Enable configured proxy-aware URL handling. |
| `MAGENT_PROXY_BASE_URL` | `null` | Optional configured reverse-proxy public base URL. |
| `MAGENT_PROXY_TRUST_FORWARDED_HEADERS` | `true` | Permit forwarding metadata only from trusted proxies. |
| `MAGENT_PROXY_TRUSTED_PROXIES` | `"127.0.0.1,::1"` | Comma-separated trusted proxy addresses/networks; loopback by default. Do not broadly trust arbitrary clients. |
| `MAGENT_PROXY_FORWARDED_PREFIX` | `null` | Optional reverse-proxy path prefix metadata. This is not a promise that every root-based frontend asset supports arbitrary subpaths. |
| `MAGENT_SSL_BIND_ENABLED` | `false` | Direct-hosting TLS configuration; does not replace the bundled image's external HTTPS proxy. |
| `MAGENT_SSL_CERTIFICATE_PATH` | `null` | Direct-hosting TLS certificate PEM path. |
| `MAGENT_SSL_PRIVATE_KEY_PATH` | `null` | Secret direct-hosting TLS private-key PEM path. |
| `MAGENT_SSL_CERTIFICATE_PEM` | `null` | Direct-hosting certificate PEM content. |
| `MAGENT_SSL_PRIVATE_KEY_PEM` | `null` | Secret direct-hosting private-key PEM content. |
## Notification services
Provider credentials are secrets. Configure them in the authenticated setup/admin
UI, where sensitive saved values are encrypted; do not share webhook URLs or bot
tokens in support logs. The master switch and each provider switch both apply.
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `MAGENT_NOTIFY_ENABLED` | `false` | Master notification switch. |
| `MAGENT_NOTIFY_EMAIL_ENABLED` | `false` | Enable SMTP email notifications. |
| `MAGENT_NOTIFY_EMAIL_SMTP_HOST` | `null` | SMTP hostname/address. |
| `MAGENT_NOTIFY_EMAIL_SMTP_PORT` | `587` | SMTP port, commonly 587 with STARTTLS or 465 with implicit TLS. |
| `MAGENT_NOTIFY_EMAIL_SMTP_USERNAME` | `null` | SMTP login username. |
| `MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD` | `null` | Secret SMTP password/app password. |
| `MAGENT_NOTIFY_EMAIL_FROM_ADDRESS` | `null` | Sender email address. |
| `MAGENT_NOTIFY_EMAIL_FROM_NAME` | `null` | Sender display name. |
| `MAGENT_NOTIFY_EMAIL_USE_TLS` | `true` | Use SMTP STARTTLS. |
| `MAGENT_NOTIFY_EMAIL_USE_SSL` | `false` | Use implicit SMTP TLS instead of STARTTLS. |
| `MAGENT_NOTIFY_DISCORD_ENABLED` | `false` | Enable Discord notifications. |
| `MAGENT_NOTIFY_DISCORD_WEBHOOK_URL` | `null` | Secret Discord webhook URL, also usable for feedback routing. |
| `MAGENT_NOTIFY_TELEGRAM_ENABLED` | `false` | Enable Telegram notifications. |
| `MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN` | `null` | Secret Telegram bot token. |
| `MAGENT_NOTIFY_TELEGRAM_CHAT_ID` | `null` | Telegram destination chat/group/user ID. |
| `MAGENT_NOTIFY_PUSH_ENABLED` | `false` | Enable push-provider notifications. |
| `MAGENT_NOTIFY_PUSH_PROVIDER` | `"ntfy"` | Provider selector such as `ntfy`, `gotify`, `pushover` or `webhook`. |
| `MAGENT_NOTIFY_PUSH_BASE_URL` | `null` | Push-service base URL. |
| `MAGENT_NOTIFY_PUSH_TOPIC` | `null` | Push topic/channel name. |
| `MAGENT_NOTIFY_PUSH_TOKEN` | `null` | Secret push-service token/API key. |
| `MAGENT_NOTIFY_PUSH_USER_KEY` | `null` | Provider recipient key, such as a Pushover user key. |
| `MAGENT_NOTIFY_PUSH_DEVICE` | `null` | Optional target device selector. |
| `MAGENT_NOTIFY_WEBHOOK_ENABLED` | `false` | Enable generic webhook notifications. |
| `MAGENT_NOTIFY_WEBHOOK_URL` | `null` | Generic notification webhook URL; may contain secret credentials. |
| `MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS` | `false` | Explicitly allow private-network notification destinations. Keep disabled unless deliberately trusting an internal endpoint. |
| `DISCORD_WEBHOOK_URL` | `null` | Legacy Discord feedback webhook fallback. Prefer the notification provider setting above. |
## Media integrations
URLs must be reachable from the Magent container. `localhost` names the Magent
container itself, not another application or the Docker host. Configure these
through setup/admin; API keys and passwords are secret. Aliases are retained for
existing installations.
| Variable / aliases | Source default | Purpose |
| --- | --- | --- |
| `JELLYSEERR_URL`, `JELLYSEERR_BASE_URL` | `null` | Seerr/Jellyseerr request-service base URL. |
| `JELLYSEERR_API_KEY`, `JELLYSEERR_KEY` | `null` | Seerr API key. |
| `JELLYSTAT_URL`, `JELLYSTAT_BASE_URL` | `null` | Jellystat statistics-service base URL, including its base path if used. |
| `JELLYSTAT_API_KEY` | `null` | Jellystat API key. |
| `JELLYFIN_URL`, `JELLYFIN_BASE_URL` | `null` | Jellyfin server URL for authentication, user sync and library lookups. |
| `JELLYFIN_API_KEY`, `JELLYFIN_KEY` | `null` | Jellyfin administrative API key. |
| `JELLYFIN_PUBLIC_URL` | `null` | Browser-facing Jellyfin URL for watch/open buttons. |
| `JELLYFIN_SYNC_TO_ARR` | `true` | Automatically add existing Jellyfin items to Sonarr/Radarr tracking where supported. |
| `SONARR_URL`, `SONARR_BASE_URL` | `null` | Sonarr TV-service base URL. |
| `SONARR_API_KEY`, `SONARR_KEY` | `null` | Sonarr API key. |
| `SONARR_QUALITY_PROFILE_ID` | `null` | Default Sonarr quality profile; requests use Seerr's default if Magent has none configured. |
| `SONARR_ROOT_FOLDER` | `null` | Sonarr TV root folder. |
| `SONARR_QBITTORRENT_CATEGORY` | `"sonarr"` | Legacy qBittorrent category setting, retained for compatibility and hidden from the ordinary settings UI. |
| `RADARR_URL`, `RADARR_BASE_URL` | `null` | Radarr movie-service base URL. |
| `RADARR_API_KEY`, `RADARR_KEY` | `null` | Radarr API key. |
| `RADARR_QUALITY_PROFILE_ID` | `null` | Default Radarr quality profile; requests use Seerr's default if Magent has none configured. |
| `RADARR_ROOT_FOLDER` | `null` | Radarr movie root folder. |
| `RADARR_QBITTORRENT_CATEGORY` | `"radarr"` | Legacy qBittorrent category setting, retained for compatibility and hidden from the ordinary settings UI. |
| `BAZARR_URL`, `BAZARR_BASE_URL` | `null` | Bazarr subtitle-service base URL. |
| `BAZARR_API_KEY`, `BAZARR_KEY` | `null` | Bazarr API key. |
| `BAZARR_DEFAULT_LANGUAGE` | `"en"` | Default subtitle search language code. |
| `PROWLARR_URL`, `PROWLARR_BASE_URL` | `null` | Prowlarr indexer-service base URL. |
| `PROWLARR_API_KEY`, `PROWLARR_KEY` | `null` | Prowlarr API key. |
| `QBIT_URL`, `QBITTORRENT_URL`, `QBITTORRENT_BASE_URL` | `null` | qBittorrent base URL for download status. |
| `QBIT_USERNAME`, `QBITTORRENT_USERNAME` | `null` | qBittorrent login username. |
| `QBIT_PASSWORD`, `QBITTORRENT_PASSWORD` | `null` | Secret qBittorrent password. |
## Additional runtime and image controls
These variables are read outside `Settings`. Defaults below identify their actual
consumer; builder values and supervisor values are not user-editable app settings.
| Variable | Default / scope | Purpose |
| --- | --- | --- |
| `MAGENT_MANAGED_SECRETS` | Image: `auto` | Automatically use persistent generated secrets when no manual signing key is supplied. `true` explicitly opts in; `false` retains manual management. Existing keys must be preserved. |
| `MAGENT_RUNTIME_MANAGED` | Internal bootstrap output: `1` for managed mode | Internal marker set by bootstrap for backend/frontend origin and cookie handling. It is not a supported user override; do not set it in Compose. |
| `BACKGROUND_TASKS_ENABLED` | `true` | Run background schedulers/workers. `false` is useful for isolated tests, not normal service operation. |
| `BRANDING_SOURCE` | `bundled` | Use bundled logo/favicon; `data` prefers custom assets under persistent data. |
| `MAGENT_METRICS_ENABLED` | Disabled (empty) | `true` enables the separate Prometheus metrics listener. Do not expose it publicly. |
| `MAGENT_METRICS_PORT` | `9108` | Metrics listener port when enabled. |
| `MAGENT_METRICS_BIND` | `127.0.0.1` | Metrics listener bind address when enabled. |
| `MAGENT_COMING_SOON` | Disabled (unset) | Frontend root-route holding page enabled only by the exact value `true`. |
| `BACKEND_INTERNAL_URL` | Build/supervisor: `http://127.0.0.1:8000`; source fallback: `http://backend:8000` | Next.js internal API/branding rewrite destination, baked when building its configuration. Runtime overrides do not rebuild a published image's rewrites. |
| `NEXT_PUBLIC_API_BASE` | `/api` | Browser API prefix. Public frontend values are baked at build time; never place credentials here. |
| `NODE_ENV` | Image: `production` | Node/Next runtime mode. Development allows development-only CSP eval; do not override in public production. |
| `NEXT_TELEMETRY_DISABLED` | Image/builder: `1` | Disable Next.js telemetry. |
| `HOSTNAME` | Supervisor: `0.0.0.0` | Bundled standalone frontend listen address, explicitly set by supervisor. |
| `PORT` | Supervisor: `3000` | Bundled standalone frontend port, explicitly set by supervisor. |
| `PYTHONDONTWRITEBYTECODE` | Image: `1` | Do not write Python bytecode into the read-only image. |
| `PYTHONUNBUFFERED` | Image: `1` | Flush Python stdout/stderr without buffering. |
| `MAGENT_UID` | Build argument: `1000` | Image runtime user's UID. Not a runtime environment switch; rebuilding with a different UID also requires matching volume/tmpfs ownership. |
| `MAGENT_GID` | Build argument: `1000` | Image runtime user's GID; same ownership caveat as UID. |
## Manual Compose and test tooling
These optional variables belong to the advanced manual template or isolated
verification tools, not the zero-input public Compose install.
| Variable | Default / scope | Purpose |
| --- | --- | --- |
| `MAGENT_IMAGE` | Required by `docker-compose.hub.yml` | Explicit published image tag/digest for manual-secret installations. |
| `MAGENT_BIND_ADDRESS` | `127.0.0.1` | Manual template frontend bind address. |
| `MAGENT_HTTP_PORT` | `3000` | Manual template frontend host port. |
| `PYTHON_BIN` | `python3` | Interpreter used by the backend quality gate. |
| `MAGENT_IMAGE_MAX_MB` | `350` | Container smoke-test unpacked image size budget in MiB. |
| `MAGENT_SMOKE_MANAGED` | `false` | Test generated managed secrets when true, or synthetic manual keys when false. |
| `GITHUB_RUN_ID` | `local` | Optional CI identifier for disposable smoke-test resources. |
## Keeping this reference complete
Run `python scripts/check_environment_docs.py`. The dependency-free check parses
the Settings AST (including every alias and source default), scans explicit
runtime environment reads, Docker build/runtime declarations, Compose variables
and repository tooling. It never imports application settings, reads `.env`,
contacts a service or prints secret values. Backend unit tests run the same check
so newly declared variables and changed Settings defaults require documentation.
This is the inventory of variables explicitly consumed/declared by Magent's
source, not an enumeration of all knobs understood by Python, Node, Docker,
OpenSSL or third-party libraries. Unsupported third-party/internal flags should
not be used to bypass the packaged deployment defaults.
+179
View File
@@ -0,0 +1,179 @@
# Install with Portainer
For a **fresh installation**, paste [compose.yml](../compose.yml) into a new
Portainer stack and deploy with **no environment variables**. The stack names
`image: rephl3xnz/magent:latest` directly. Portainer pulls that prebuilt Docker Hub image; Magent
creates its private keys, database and persistent storage, then guides you
through administrator and app setup. Users do not need a Dockerfile, source
checkout, Python installation, `.env` file or another database container.
**Release availability:** the image with zero-input managed bootstrap and
wizard-configured origins is published on Docker Hub. `latest` is mutable, not
an immutable release identifier; record the deployed digest and review release
notes before updating. If an existing stack still uses an older image, updating
the Compose text alone does not pull the new image: explicitly re-pull it when
redeploying, while retaining the same persistent volume.
## Requirements
- A Portainer-managed **Docker Standalone** environment running Linux containers.
This template is for one Magent instance, not Docker Swarm or multiple replicas
sharing SQLite.
- A compatible published image for your CPU architecture. Only `linux/amd64`
has been validated; do not assume ARM64 support.
- A stable address you will actually use in your browser. Use HTTPS before
exposing Magent publicly or inviting internet users.
- Existing media services, if you want to connect them. This stack installs
Magent, not Jellyfin, Seerr or the Arr applications.
## Deploy and finish setup
1. In your Docker environment, open **Stacks**, choose **Add stack**, name it
`magent`, and select **Web editor**. Paste the complete root `compose.yml`.
Uploading that file is an alternative. See
[Portainer's stack instructions](https://docs.portainer.io/user/docker/stacks/add).
2. Leave the stack's **Environment variables** section empty. Keep the
runtime-security block unchanged. No signing keys, database path, API-docs
flag, application URL or CORS value needs entering into the stack.
3. Choose **Deploy the stack** and wait for the Magent container to become
healthy. The image's non-root user owns a fresh named volume automatically.
4. Open that container's **Console**, choose command `/bin/ash` and user
**`magent`** (UID `1000`), connect, and run:
```sh
python -m app.container_bootstrap setup-token
```
This deliberately displays a private first-install token only in your
administrative console, not the normal container logs. Keep the output
private: anyone with that token and access to an unclaimed installation can
create its first administrator. The command refuses to reveal it once an
administrator exists.
The setup page's **Get setup token** button also shows these instructions and
offers **Copy command**. On browsers without clipboard access, select and
copy the displayed command manually. This help dialog does not generate or
reveal a token over the public web interface.
5. Open Magent at the browser address you intend to use, such as
`http://192.168.1.50:3000` on a trusted LAN or `https://magent.example.com`
through your configured HTTPS proxy. Use the **Docker host's reachable
address**, not Portainer's address if Portainer runs elsewhere. The fresh
installation opens `/setup`. Confirm the application URL shown there,
enter the token and create your administrator with a unique password. The
confirmed URL must match the browser origin, including any non-default port,
with no path, trailing slash, credentials or query string. To choose another
address, open setup at that address first. Token-authorized creation saves
the URL and administrator together; arbitrary visitors cannot claim a trusted
origin merely by loading a page. Then connect/test the apps you use, select
preferences and finish. Optional apps can be skipped. There is no shared
default administrator password.
6. Make an encrypted backup from **Settings → Advanced tools → Backup & restore**
and save its passphrase separately. Test recovery before relying on the
installation.
There is no need to manually generate keys or remove an environment setup token
in this mode. First-admin creation stops accepting the token after an account
has been created. Restarting or recreating the container retains the same
database and keys; it does not reopen first-admin signup.
Managed CORS and cookie security follow the saved URL automatically. SQLite is
fixed at `/app/data/magent.db`, and API documentation is disabled; neither is a
setup choice. All environment options, including advanced legacy overrides, are
listed in the [complete environment reference](ENVIRONMENT.md).
App connection URLs must be reachable **from Magent's container**. `localhost`
refers to Magent itself, not the Docker host or another application. Use LAN/DNS
addresses or explicitly attach applications to an appropriate shared Docker
network. Never mount the Docker socket into Magent.
## Ports, HTTPS and security defaults
The default publishes host port `3000` on all interfaces so a browser on the LAN
can reach a typical Portainer deployment. Restrict that port with your host and
network firewall. Do not port-forward this plain-HTTP endpoint to the internet.
Only port 3000 is published; browser API requests use `/api` on the same origin.
The default port mapping is the literal `3000:3000`; this template has no variable
substitutions. An operator needing another host port can deliberately edit only
the left-hand port, then use/confirm that address in setup. For a reverse proxy
running directly on the Docker host, `127.0.0.1:3000:3000` restricts the listener
to host loopback. A containerized proxy instead needs an explicitly shared Docker
network or reachable host interface; its own loopback is not the Docker host.
For public service, configure DNS and an HTTPS reverse proxy, set
the application URL to the external `https://` origin in setup/admin.
Managed installations derive matching CORS and Secure cookies from that saved
URL, while an explicitly confirmed private HTTP origin permits HTTP cookies.
They do not install a certificate or reverse proxy. See the
[reverse-proxy guide](PUBLIC_RELEASE.md#https-public-urls-and-reverse-proxies).
Keep the configured address consistent: visiting an IP when the configured
origin is a domain can make sign-in fail the origin check.
The following Docker runtime controls stay enabled in the Compose file:
- `read_only: true` protects the image filesystem; only the data volume and
designated temporary areas are writable.
- `cap_drop: ["ALL"]` and `security_opt: ["no-new-privileges:true"]` restrict
process privileges.
- `init: true` handles child-process reaping and signal forwarding.
- `tmpfs` supplies restricted temporary writable areas for process state and
frontend cache without making the image writable.
**Leave this security block unchanged.** These are Docker engine settings, not
application environment variables; deleting them does not make them implicit
image defaults. No privileged container or Docker socket access is required.
Advanced/manual environment installs remain supported separately; see
[ENVIRONMENT.md](ENVIRONMENT.md). Adding an unreferenced variable to Portainer's
variable list alone does not inject it into this no-variable template. Do not
add manual secrets or attempt to override fixed managed defaults in a working
managed installation.
HTTP is intended for trusted-LAN setup/testing, not a fully secure deployment.
Some browser features (such as clipboard access and report/newsletter operations
using secure-context APIs) require HTTPS. Configure HTTPS for ongoing use.
## Persistence, backups and upgrades
Keep the stack name stable. Docker creates a project-scoped `magent-data` named
volume mounted at `/app/data`. It holds the database, cached artwork, branding
and `/app/data/bootstrap-secrets.json`, which contains the generated signing key,
settings-encryption key and setup token. Its restrictive permissions do not
protect it from the Docker host administrator: restrict Portainer/host access
and use encrypted host storage. Treat the complete volume as sensitive.
Never delete that file to fix a startup or login problem. Without the original
encryption key, a raw database copy's encrypted credentials cannot be recovered.
Keep a secure, consistent offline backup of the **whole volume** by stopping
only Magent while taking the copy. Preserve the generated secrets with that
copy. A portable `.magent-backup` export is different: it excludes deployment
keys and re-encrypts settings for the destination's keys during restore. See
[backup and recovery](installation-and-recovery.md).
Managed application-backup restore also retains the destination's confirmed
application URL, not the backup source's URL. Its origin/cookie policy therefore
continues to match the destination site. An offline full-volume restore is a
different procedure and must preserve that volume's original keys.
Before updating, save the current image tag/digest, stack definition and backup.
Update the existing stack with a fresh pull of `rephl3xnz/magent:latest`, retaining
its name and volume. A plain container restart does not pull an updated image.
For a controlled release or rollback, edit only the `image:` line to a compatible
published immutable tag or digest; this is optional, not an installation input.
Verify health, login,
app connections and invites afterwards. Rollback may require the matching data
backup if a migration is not backward compatible.
Do not remove volumes when deleting/recreating a stack unless you intend to
erase the installation. Changing the stack name or mounting an empty volume
does not migrate your data.
## Existing installations
This is a **fresh-install template**, not an automatic migration from manual
secrets. Existing installations must retain their own data mount, database path,
`JWT_SECRET`, `SETTINGS_ENCRYPTION_KEY` and deployment settings. Continue using
your saved stack or [docker-compose.hub.yml](../docker-compose.hub.yml). Do not
switch an existing bind mount to a fresh named volume or enable managed secrets
to replace established keys. Follow an explicitly reviewed migration if you
choose to change secret management later.
The project retains its Dockerfile for maintainers to build release images.
End users deploying this stack do not download or run it.
+263
View File
@@ -0,0 +1,263 @@
# Public installation and release guide
Magent runs as one non-root Linux container containing the Python API and the
Next.js frontend. Connect your own media services in the setup wizard; no
pre-existing Magent account or database is required. Optional
integrations may be skipped. Media files remain in your existing media services.
The lightweight Dockerfile builds the frontend separately and copies only its
standalone runtime, static assets and public files into the final image. Build
tools, the full development dependency tree and package-manager caches are not
runtime requirements. The image still needs both Python and Node to serve the
API and frontend; it is not a static website.
Both build and runtime stages use Alpine Linux, so native dependencies are built
for its musl runtime rather than copied from an incompatible glibc image.
The non-root UID/GID and `/app/data` layout are unchanged. Existing installations
do not need new databases, replacement keys or new data volumes for this change.
Magent's own source code is licensed under the [MIT License](../LICENSE).
Bundled dependencies retain their own licenses; Magent's license does not
relicense third-party software or grant rights to third-party branding.
## Fresh installation
For the simplest **Portainer** installation, use the image-only root
[compose.yml](../compose.yml) and follow the [Portainer guide](PORTAINER.md).
That fresh-install path pulls `rephl3xnz/magent:latest` from Docker Hub with no
environment inputs, automatically persists its private keys and requires no
Dockerfile or `.env`. Confirm the browser-facing application URL in the
token-authorized first-administrator wizard; matching CORS and cookie security
follow the saved URL automatically. Managed SQLite stays at
`/app/data/magent.db`, and API documentation stays disabled.
The managed-install image is published on Docker Hub. Repository changes alone
do not update an existing deployment: explicitly pull the image when upgrading,
retaining the same persistent volume. `latest` is mutable, so record the resolved
digest before an upgrade; older cached images may lack this setup workflow.
The Compose runtime-security block stays enabled and must be left unchanged.
See the [complete environment reference](ENVIRONMENT.md) for all supported
variables and the difference between managed, manual and tooling controls.
### Advanced/manual secrets
The procedure below remains supported for operators who manage their own
deployment keys. It uses `docker-compose.hub.yml`, not the zero-input root
`compose.yml`; its required environment inputs are specific to this manual path.
Prerequisites: Docker Engine or Docker Desktop running Linux containers, Docker
Compose v2, and an image release published for your host's architecture. Use the
Compose and environment examples from the **same release** as the image.
1. Put `docker-compose.hub.yml` and a copy of `.env.example` named `.env` in a
private deployment directory. Keep the directory and Compose project name
stable: the project owns the persistent named volume. On Linux restrict `.env`
to the deployment account, for example `chmod 600 .env`; on Windows restrict
its file permissions. Do not commit it, paste it into support reports or
expose it through a web server.
2. Set `MAGENT_IMAGE` to the release's published immutable tag, for example
`rephl3xnz/magent:prod-<actual-commit>`, or its published
`rephl3xnz/magent@sha256:<actual-digest>`. Replace the angle-bracket placeholders.
There is deliberately no implicit `latest` fallback. Confirm the selected
release contains the setup/backup features before following this guide.
3. Generate three **independent** values. Run these on a trusted machine; the
commands print secrets, so do not share their output or put it in CI logs:
```bash
# JWT_SECRET
python -c "import secrets; print(secrets.token_urlsafe(48))"
# SETUP_TOKEN (not the JWT secret)
python -c "import secrets; print(secrets.token_urlsafe(48))"
# SETTINGS_ENCRYPTION_KEY (Fernet-compatible, using only Python's standard library)
python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
```
Put each value in its matching `.env` field. Leave `ADMIN_PASSWORD` blank to
create the administrator through the wizard. There is no shared default
administrator password. Save the signing and encryption keys in a separate
secure backup; generating replacement keys is **not** an upgrade step.
4. For a local trial, keep `MAGENT_BIND_ADDRESS=127.0.0.1`,
`MAGENT_HTTP_PORT=3000`, the localhost URLs and `AUTH_COOKIE_SECURE=false`.
For internet access, configure HTTPS as described below **before** inviting
users. If changing the local port, update both browser-facing URLs too.
5. Validate without printing expanded secrets, pull and start:
```bash
docker compose -p magent -f docker-compose.hub.yml config --quiet
docker compose -p magent -f docker-compose.hub.yml pull magent
docker compose -p magent -f docker-compose.hub.yml up -d --no-build magent
docker compose -p magent -f docker-compose.hub.yml ps
```
6. Open `http://localhost:3000` on the Docker host (or your configured HTTPS
address). The fresh database opens `/setup`. Enter the setup token, create
your administrator, connect the apps you use, review preferences and finish.
App URLs must be reachable **from the container**; `localhost` means Magent
itself, not another container or the Docker host. Use your internal DNS,
reachable LAN addresses or service names on an explicitly shared Docker
network.
7. Remove `SETUP_TOKEN` from `.env` and recreate only Magent using the same
`up -d --no-build magent` command. Setup progress and accounts remain in the
volume. Make and test a backup before relying on the installation.
The template creates a project-scoped named volume at `/app/data`. The image's
data directory is prepared for UID/GID `1000:1000`, avoiding the fresh root-owned
bind-directory problem. The container runs with a read-only root filesystem,
dropped capabilities and private temporary writable areas. The backend's port
8000 is not published: browser API calls use `/api` on the frontend port.
Do not run `docker compose down --volumes` unless you intend to erase the data.
## HTTPS, public URLs and reverse proxies
For the **managed Portainer path**, visit the intended HTTPS address and confirm
it in first-administrator setup. Later application URL changes belong in admin
configuration. CORS and Secure cookies follow the saved URL; no environment
values are required. Setup does not provision DNS, certificates or a proxy.
For the **advanced/manual path**, set these deployment values to **your own**
origin, without a path or trailing slash:
```dotenv
CORS_ALLOW_ORIGIN=https://magent.example.com
MAGENT_APPLICATION_URL=https://magent.example.com
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=strict
```
The supplied image already routes browser `/api/*` calls to its internal API.
Do not expose port 8000, set a public API hostname or override the internal
backend URL for this combined-image deployment. Runtime environment changes do
not rebuild the frontend's compiled routing configuration.
With a reverse proxy running directly on the **same host**, use a loopback
bind and route the whole hostname, including `/api`, to `127.0.0.1:3000` (or your
chosen host port). The manual template defaults to loopback; in the root
Portainer template deliberately change its port mapping to `127.0.0.1:3000:3000`
for this topology. For example, a host-running Caddy instance can use:
```caddyfile
magent.example.com {
reverse_proxy 127.0.0.1:3000
}
```
For a containerized proxy, loopback inside that proxy is not the Docker host.
Attach the proxy and Magent to an intentionally shared Docker network and proxy
to `magent:3000`, or configure a reachable host address explicitly. If the proxy
is on another machine, bind the frontend host port to the Docker host's private
interface address and restrict access with a firewall to the trusted proxy. In
the root Portainer template edit the port mapping directly; the manual Hub
template instead supports `MAGENT_BIND_ADDRESS`.
Avoid exposing all interfaces merely to work around routing.
Provide valid TLS, suitable DNS and upload limits of at least 34 MiB for backup
restore. Do not cache login, setup, authenticated pages or API responses at the
proxy/CDN. Keep the exact public origin configured: the request-origin guard
does not trust arbitrary `Host` or forwarded headers. Keep `API_DOCS_ENABLED=false`
for public service. TLS terminates at your proxy, not inside this image.
## Existing installations and upgrades
**Do not replace an existing deployment with the fresh named-volume template.**
Keep its existing project name, mount, database path, environment, signing key
and encryption key. Switching from `./data:/app/data` to a new named volume makes
an existing installation look empty; it does not migrate data. Never complete
fresh setup to fix a missing mount.
For existing Linux bind mounts, confirm the exact intended data directory and
its files are writable by UID/GID `1000:1000`. Back up first and correct only that
application directory if needed; do not recursively change an entire shared
stack, host directory or filesystem. Do not work around permissions by running
Magent as root. Custom UID/GID images require matching ownership.
Before upgrading:
1. Save the old image tag **and digest**, Compose definition and protected
environment/key backup. Create a consistent data backup and verify recovery.
2. Read the target release's migration notes and select its immutable image.
Pull it before interrupting service. Validate the actual saved deployment
definition, including a Portainer stack's saved environment if used; editing
a separate host Compose file does not update Portainer's copy.
3. Recreate **only Magent**, retaining the original data mount and secrets. For
this template use `docker compose -p magent -f docker-compose.hub.yml up -d
--no-deps --no-build magent`. Use your actual project/file for other stacks.
4. Verify health, real local-account login, any enabled Jellyfin login, requests,
app connections and invite behaviour. Do not trigger imports, notifications
or destructive repair actions merely as a smoke test.
The zero-input Portainer template deliberately uses `latest`. On that path,
review the new release and pull its updated image when updating the existing
stack; restarting alone leaves the current image in use. Save the previous
digest because `latest` can move. Operators preferring controlled releases may
replace its literal `image:` value with a published immutable tag/digest without
changing the volume or generated keys.
For rollback, select the recorded image and recreate only Magent. Database
migrations may prevent older versions from reading newer data: follow the
release's compatibility notes and restore the matching backup if required.
Restoring old data discards changes since that backup. Never restore a whole
shared stack file over unrelated service changes.
## Backups and recovery
For managed Portainer installations, retain `bootstrap-secrets.json` alongside
the database in consistent offline volume backups; the generated encryption key
is essential to recovering a raw database copy. It is deliberately excluded
from portable encrypted application exports. See [Portainer persistence](PORTAINER.md#persistence-backups-and-upgrades).
See [installation and recovery](installation-and-recovery.md) for the encrypted
admin backup/export flow and its size limits. It supports configuration,
database and optional artwork cache. The backup passphrase cannot be recovered.
Keep it separately and test a restore to a disposable instance of the same
version.
For an offline volume backup, stop **only Magent** and snapshot/copy its complete
data volume, including SQLite journal/WAL sidecars. Start it again after the
consistent copy completes. Keep `.env` and the original encryption/signing keys
separately protected: encrypted settings in a raw database copy cannot be
recovered without the original encryption key (or original signing key for
older installations that derived their encryption key from it). The portable
encrypted application backup re-encrypts settings for its destination instead;
these are different recovery procedures.
Managed portable restore keeps the destination's confirmed application URL as
well as its signing/encryption keys. It does not replace the destination's
trusted origin with the source backup's URL; review integration addresses and
sign in using the destination URL after recovery.
Never rotate keys, replace the database or delete volumes as a response to an
unhealthy container. Inspect health/log errors without posting credentials.
## Public release gates
The release branch is a source snapshot, not a published image. Build and
verify the exact commit before publishing Docker Hub tags.
This guide and the lightweight Dockerfile do not by themselves certify a
release. Before publishing a new immutable tag or moving `latest`:
- Build from the reviewed release commit with the root Dockerfile and its pinned
base digests. Do not send `.env`, live data, local caches or development
credentials in the build context. Record source revision, final image digest,
compressed download size and unpacked image size separately.
- Run backend and frontend checks, the production build and a **container** smoke
test using the read-only Compose settings, a fresh named volume and non-root
user. Confirm setup survives recreation, login works and the UI hydrates
without CSP errors. Test backup/restore and an upgrade against a disposable
copy, preserving its keys; never use live accounts/data for destructive tests.
- Check same-origin POST protection: empty-form requests to both
`/api/auth/login` and `/api/auth/jellyfin/login` with the configured origin must
reach validation (422); an unrelated origin must be rejected (403). These are
necessary checks, not substitutes for a successful authenticated login.
- Inspect the final image for development packages, build caches, unexpected
credentials, unnecessary privileged execution and known dependency/base-image
vulnerabilities. Review dependency notices and licensing before redistribution.
- Declare only architectures actually built **and smoke-tested**. A Linux/amd64
test is not an ARM64 test; an amd64 image running under emulation is not native
ARM64 validation. Do not advertise a multi-architecture release until its
manifest and each advertised platform have been verified. Docker Desktop runs
the Linux image; this is not a native Windows-container or macOS build.
- Publish migration/rollback notes, supported image tags/digests and a support
and private security-reporting route. Include the MIT license and required
dependency notices, and confirm rights to any bundled branding assets.
Publish only with the release owner's approval; building locally does not
publish or deploy an image.
@@ -1,68 +0,0 @@
# Historical production cutover notes — superseded
These notes describe the temporary AMS-DEV01 setup, not the current production
deployment. Do not run these cutover or rollback instructions against the live
service. See [current production instructions](../../PRODUCTION.md).
Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and
`docker-compose.production.yml`. The legacy `prod` deployment and beta are not
overwritten. Main runs CI verification; production activation is deliberately
manual during the initial cutover.
Only API connection URLs/credentials and SMTP configuration are exported by
`scripts/prepare_production_settings.py`. It reads the source's effective settings,
uses an explicit allowlist, refuses existing output directories, and creates
private files. It never copies a database, users, invite codes, issues, history,
tokens, sessions, branding or notification templates. A new bootstrap admin and
JWT secret are generated. Retrieve the bootstrap credentials from the protected
`bootstrap-admin.json` on the server; never commit them.
The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables
`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic
imports and repair emails. The cover is not an authentication/security boundary;
normal API authentication remains in force. Administrators can use `/login`.
Run `docker compose -f docker-compose.production.yml up -d --build` from the
production directory. Caddy should proxy this hostname to `10.30.1.32:3200`;
Next forwards `/api` internally. The backend health port is localhost-only at
8200. Do not alter beta's route or other Caddy sites.
Before public activation, validate Caddy config, save its existing configuration,
verify HTTPS, admin login, connection diagnostics and the empty-client-data state.
Do not send SMTP tests without approval. Keep the old upstream for rollback.
At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`,
then recreate the container. External service records can then be imported through
normal synchronization; no beta client data is migrated. Review quality profiles,
root folders, invite policy and notification rules in admin settings before use.
## Initial cutover — 7 September 2026
- Public HTTPS cover and `/api/health` verified after cutover.
- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`.
- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`.
- Only the `magent.grizzlyflix.co.nz` upstream changed, from
`10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged.
- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`.
Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then
`sudo systemctl reload caddy`. Review subsequent edits before restoring the
whole file; the old application was not stopped or deleted.
- Initial database: one newly generated bootstrap admin; zero invites, issues,
cached requests, actions or snapshots. Login smoke-testing subsequently creates
normal admin login activity only.
- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on
AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active.
- No SMTP message was sent as part of validation. Background jobs remain paused.
## Cover resilience update
The application host subsequently became unreachable over TCP from Caddy (both
3100 and 3200 timed out, despite responding to ping). The cover is now served
directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from
`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`.
It needs no application server, JavaScript, API or external assets.
Other paths retain the production reverse proxy. Full launch now also requires
removing the `@landing`/static `handle` block from the production Caddy site once
upstream connectivity is stable; the environment switch alone is insufficient.
Pre-static configuration backup:
`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`.
-24
View File
@@ -1,24 +0,0 @@
# Duplicate account repair
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a shared-ID conflict. An individual user's management overlay also links to this view with their username prefilled.
The preview recommends the Magent row that already owns the Jellyfin link, or the oldest row if none does. Administrators can select a different row from the group. Confirmation requires an explicit acknowledgement that the rows belong to the same person.
Eligibility requires a single current Jellyfin ID, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin or Seerr sign-in account resolving to that identity. Different stored IDs, other servers, orphaned reservations, ownership outside the group, and unavailable services block repair. Similar names alone are insufficient.
The transaction:
- Archives the account records, settings, subscriptions and identity links in `user_duplicate_repairs`, recording the administrator and timestamp. This internal archive includes credential fields and is never returned through the preview API.
- Keeps the selected Magent ID, email and profile, and uses the current verified Jellyfin username.
- Preserves the most restrictive feature permissions, automatic-search setting, invitation access, any block and the earliest expiry.
- Consolidates username references for requests, issues, comments, invitations and login activity. Seerr request IDs and Seerr author IDs remain unchanged.
- Retains email delivery history, cancels outstanding deliveries from retired rows, and does not inherit their subscriptions. The retained account's own subscriptions remain subject to the normal identity and access checks. Sending emails block repair until they finish.
- Invalidates existing password-reset links, removes the extra active Magent rows, and confirms the retained account's verified service links. Affected users may need to sign in again.
Jellyfin, Seerr and Jellystat accounts, media and upstream history are not modified. There is no unattended bulk merge or self-service undo. An explicitly authorized operator can use `scripts/reconcile_verified_accounts.py --apply --output <new-private-directory>`; it takes a SQLite backup and archives each repair. Without `--apply` it previews only. The archive supports administrative investigation; conflicting identities require separate review.
Both preview and confirmation recheck live service mappings. A transaction rechecks local identity state, permissions, subscriptions and connection settings before writing. Stale previews fail with HTTP 409. Account creation/import checks normalized usernames under a SQLite write lock to prevent concurrent case/whitespace duplicates from recurring.
Validation: temporary-database tests cover history, permissions, consent, rollback, concurrent creation, stale previews and ownership conflicts. `scripts/review_duplicate_accounts_ui.cjs` checks desktop/mobile UI and confirmation using intercepted API fixtures only.
Seerr sync/resync now reconciles against Jellyfin IDs without deleting the directory. Daily imports and verified login reuse linked accounts; account creation also guards against duplicate Seerr IDs.
-22
View File
@@ -1,22 +0,0 @@
# Personal report emails
## On-demand personal reports
Users can email the month shown on **My stats > Monthly report**, including the
current month to date or any available previous month. Delivery uses the confirmed
profile email and the existing private report generator. The API does not accept
another user, recipient, or delivery kind. The request UUID is idempotent, and
manual/test report requests share a five-minute per-user cooldown.
**Profile > Your reports, your choice** offers on-demand-only delivery or on-demand
plus automatic monthly emails. New confirmations default to on-demand-only;
existing confirmed subscribers retain their previous automatic monthly preference.
Turning automatic delivery off cancels pending scheduled emails, but preserves
verified-email consent and explicitly requested emails. A full unsubscribe, email
change, identity change or blocked account prevents pending personal delivery.
On-demand requests work while the monthly schedule is paused, provided SMTP,
Jellystat and the delivery worker are available. Delivery status is available to
the requesting user and in the admin recap history. New-arrivals newsletters are
unchanged and keep their independent subscriptions and schedule.
+20 -4
View File
@@ -2,6 +2,22 @@
## Fresh installation
For a new Portainer installation without a Dockerfile or `.env`, use the
[single-file stack guide](PORTAINER.md). It generates persistent deployment
secrets and provides a console-only setup-token command. The root stack pulls
`rephl3xnz/magent:latest` with no environment inputs. Confirm the application URL
alongside the token when creating your first administrator; managed CORS and
cookie security follow that URL automatically. SQLite is fixed at
`/app/data/magent.db`, and API documentation remains disabled. Leave the Compose
runtime-security defaults unchanged. The updated image still needs publishing
before `latest` provides this behaviour; repository changes alone do not deploy
or publish it. Record deployed digests because `latest` is mutable.
The **manual-secret and source-build instructions below** remain supported for
other deployments. All environment options and defaults are documented in
[ENVIRONMENT.md](ENVIRONMENT.md); they are not required inputs to the managed
Portainer template.
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
@@ -12,9 +28,9 @@ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().d
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.
For a manual-secret installation, set the correct browser-facing `CORS_ALLOW_ORIGIN`, `MAGENT_APPLICATION_URL`, cookie HTTPS settings and host paths before starting. Changing the application URL in its wizard does not replace its explicit environment CORS policy. Managed Portainer installations instead confirm their origin during token-authorized first-admin setup, and automatically follow that saved origin for CORS and cookie security. Neither mode changes reverse-proxy configuration or creates certificates.
After `docker compose up -d --build`, visit the frontend. A new database redirects to `/setup`:
After `docker compose -f compose.yml -f compose.build.yml up -d --build` for a source 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.
@@ -48,13 +64,13 @@ The frontend and backend accept up to 34 MiB for the whole multipart request, in
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.
4. Restart the application using your normal deployment process, for example `docker compose restart magent` for a source build, or restart the container in Portainer. 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.
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. For managed installations, restore also preserves the destination's confirmed application URL rather than adopting the backup source's URL; its CORS/cookie policy therefore stays aligned with the destination site. Configure and verify that destination URL before staging a restore. Review 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
-93
View File
@@ -1,93 +0,0 @@
# Jellystat in Magent Beta
Magent's **My Stats** page (`/insights`) reads personal viewing history from an existing Jellystat instance. Jellystat owns playback collection, history and retention. Magent does not install Jellystat, collect sessions or keep a second playback database.
## Setup
1. Run Jellystat and connect it to the same Jellyfin server Magent uses. Let its initial sync finish.
2. Create an API key in Jellystat's settings.
3. In Magent, open **Configuration → Jellystat**, enter its internal URL and API key, save, and test the connection. Include any reverse-proxy base path in the URL.
4. Sign in using Jellyfin. Existing Jellyfin accounts can also be linked by **Configuration → Jellyfin → Import Jellyfin users**. First use of My Stats resolves an existing Jellyfin account against Jellyfin's user directory using its exact username.
Alternatively, set these backend environment variables:
```dotenv
JELLYSTAT_URL=http://jellystat:3000
JELLYSTAT_API_KEY=your-jellystat-api-key
```
`JELLYSTAT_BASE_URL` is also accepted. Docker deployments already load the backend environment through `.env`. These are server settings; no `NEXT_PUBLIC_` variables or browser credentials are needed. Saved Configuration values override environment values.
## What users see
- Past 7, 30, 90 or 365 days of watch time, distinct movies and episodes played, and total plays.
- Watch-time chart, current/longest streak within the chosen period, active days, favourite titles, players and streaming methods.
- Latest 20 plays in the chosen period and personal request totals from Magent's Seerr cache.
- Clear setup, account-link, no-history and temporary-unavailability states.
The page is personal for admins as well as ordinary users. There is no arbitrary user-ID parameter or server-wide history endpoint in this version. Monthly reports are available from My Stats; admin reporting and newsletters can build on this integration later.
## Data semantics and boundaries
History comes from Jellystat's `POST /api/getUserHistory`, with the backend's linked Jellyfin ID in `userid`, and a fixed date filter. `GET /api/getLibraries` supplies movie-library classification and the connection test. Authentication uses the `x-api-token` header. The adapter follows the [upstream API routes](https://github.com/CyferShepard/Jellystat/blob/main/backend/routes/api.js) and [playback model](https://github.com/CyferShepard/Jellystat/blob/main/backend/models/jf_playback_activity.js); the installed instance exposes its API at `/swagger`.
- Playback duration is in seconds and displayed as minutes. Positive-duration history entries count as plays, including unfinished watches. Repeat plays add time without inflating distinct movie/episode counts.
- Episodes are identified by `EpisodeId`. Movies are identified by their movie library. Mixed libraries or deleted library metadata may leave an item classified as other media; that time still contributes to totals.
- Ranges cover a rolling number of days. Charts and streaks use UTC and Jellystat's `ActivityDateInserted`, so the first/last chart days can be partial. A streak day requires at least one minute. Streaks are bounded by the selected period. Long charts group days for readability.
- Requests use their creation date and the authenticated account's canonical Seerr ID. Exact usernames are only used for legacy requests without an owner ID; conflicting IDs never fall back to a name.
- Pages are fetched at 200 rows per request, up to 50 pages, with a 30-second total timeout. Excess history asks the user to choose a shorter period; it is never presented as a complete partial total.
- A normalized, per-identity response is cached in memory for up to 60 seconds, with a 128-entry bound. The cache is separated by Jellystat URL/key, Jellyfin URL, user ID and period. HTTP responses are marked `no-store`.
- Browser output excludes raw Jellystat responses, usernames from playback data, user/device IDs, IP addresses, tokens and media stream details. Unexpected account IDs in upstream history are rejected.
- The only new database table is the stable Magent-to-Jellyfin identity mapping. It is scoped to the configured Jellyfin URL and does not automatically transfer ownership after account replacement. A changed Jellyfin URL needs identity resolution again.
## Validation
Backend coverage is in `backend/tests/test_insights.py` and `backend/tests/test_insights_media.py`. It checks API contracts, pagination, ownership, credential masking, cache separation, time units, dates, repeat plays, media classification, artwork authorization, transcoding attribution and empty/error states.
After building the frontend, `scripts/review_insights_ui.cjs` checks the page and configuration using fixture-only requests. Set `REVIEW_BASE`, `REVIEW_PLAYWRIGHT`, and optionally `REVIEW_DIR` to save screenshots outside the repository. Live Jellystat verification requires configuring the actual instance.
## Monthly reports
Open **My Stats → Monthly reports** (`/insights/reports`). The default is the most recent complete calendar month. The month picker covers the current month and the previous 23 months. Reports include viewing and request totals, changes against the preceding month, daily viewing, active days, longest streak, favourite titles, players, transcoding and the latest 20 plays in that month. The chart and streaming cards are shared with the Stats overview.
- Completed months compare full UTC calendar months, even when their lengths differ. The current month compares the same elapsed time in the previous month, capped at that month's end when it is shorter. The page identifies reports that are still in progress.
- Periods include their start and exclude their end. Midnight activity belongs to exactly one month; leap years and December/January boundaries use calendar arithmetic. Missing prior activity has no percentage increase, rather than an infinite or invented percentage.
- Reports use the same stored Jellyfin identity resolution as My Stats, including administrator-confirmed links. They accept only a month, never a browser-supplied user ID or server scope. Requests use the authenticated account's Seerr ID under the existing ownership rules.
- `GET /insights/reports/monthly` returns the report; `GET /insights/reports/monthly.csv` downloads its summary, comparisons, daily totals, leading titles, players, streaming methods, transcoding and request counts. Both require authentication and return `Cache-Control: no-store`. CSV text cells are escaped and formula-like values are prefixed to prevent spreadsheet execution. Exports omit account IDs, artwork tokens and upstream credentials.
- Reports are generated on demand from retained Jellystat history and Magent's available request cache. They are not immutable historical snapshots. Request statuses are current, and historical totals can change with retention or library metadata.
- One bounded history read covers the selected and comparison months. Playback summaries are cached for 60 seconds in at most 128 entries, separated by identity, connection and month. Request totals are refreshed independently. Upstream errors or history limits fail the report without presenting a partial result.
`backend/tests/test_monthly_reports.py` covers calendar boundaries, matched partial periods, ownership, cache isolation, comparisons and safe CSV exports. `scripts/review_monthly_reports_ui.cjs` checks the report controls and layouts with fixtures only.
## Personal monthly email recaps
New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription.
**Settings → Monthly email recaps** (`/admin/recaps`) controls the monthly schedule, personal preview, test emails and delivery history. Email links inherit the application URL from Hosting & proxy (or the proxy base URL when enabled); this address is shown read-only on the recap page. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email.
New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token.
Users choose **Email me my monthly recap** in Profile and confirm ownership of their profile email through a link that expires in 24 hours. The confirmation email contains no viewing data. Opening a confirmation or unsubscribe link only checks it; the user must press the action button. Unsubscribe works without signing in and is also available in Profile. Link tokens travel in URL fragments, then in a redacted JSON `token` field. Confirmation tokens are stored as hashes and consumed on use. Unsubscribe tokens are random, scoped to the current subscription and rotated on a new opt-in.
Subscriptions are bound to the Magent account, confirmed email and stored Jellyfin source/user ID. The background worker does not infer links from emails or playback names. Email changes (even if later changed back), blocked accounts, deleted/replaced identity links and changed Jellyfin sources invalidate consent. Expired and deleted accounts are excluded. The worker checks the current binding again immediately before handing a message to SMTP; unsubscribing cancels queued/preparing messages. Email already handed to the mail server cannot be recalled.
The schedule uses a selected day from 128 and an hour in **UTC**, defaulting to day 2 at 09:00. Starting, resuming or changing a schedule begins at its next future occurrence; it does not immediately email an old report. Each occurrence covers the preceding complete UTC calendar month and includes subscribers confirmed by that scheduled time. After an outage, only the latest due occurrence is caught up. Earlier missed months and late subscribers are not backfilled. Pausing cancels queued scheduled deliveries. `BACKGROUND_TASKS_ENABLED=false` also disables recap automation. The worker checks the durable queue every 30 seconds.
SQLite stores the schedule, consent and delivery metadata in `email_recap_settings`, `email_recap_subscriptions` and `email_recap_deliveries`. Report bodies are generated at delivery and are not stored in the queue. Keep the existing Magent database persistent across deployments and back it up with the application's other data. The migration is additive; no existing user is opted in and no identity is merged.
- A unique account/month key prevents duplicate scheduled recaps across workers, refreshes and restarts. Test requests carry an idempotency key and have a five-minute account cooldown. Confirmation requests also have a five-minute account cooldown.
- Queue claims are transactional. Report preparation has a three-minute timeout. Known temporary SMTP rejections and temporary history failures retry after five minutes, then thirty minutes, with at most three attempts. Permanent failures and history limits stop without sending a partial report.
- The worker records SMTP acceptance separately from connection teardown. A failed QUIT after acceptance does not cause a retry. A disconnect while submitting DATA, or an interrupted worker that had begun sending, is marked **Needs review** and is not automatically resent. Inspect the mail server for the stable `magent-recap-<delivery ID>` Message-ID before deciding whether any follow-up is needed. A stable Message-ID helps investigation; SMTP does not promise deduplication. See [RFC 5321 §4.5.3.2.6](https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.2.6) and the [Python SMTP exception definitions](https://docs.python.org/3/library/smtplib.html).
- Delivery history contains recipient, month, type, attempts, timestamps and a sanitized outcome. It reports mail-server acceptance, not inbox placement or read receipts. No automatic retry button is offered for uncertain deliveries.
The APIs are `/profile/email-recaps`, `/admin/email-recaps`, `/admin/email-recaps/preview`, `/admin/email-recaps/test`, and the public token-only `/email-recaps/check` and `/email-recaps/confirm` actions. Admin APIs enforce the administrator role; personal APIs use the signed-in account. Payloads reject recipient/user overrides.
`backend/tests/test_email_recaps.py` covers consent, identity changes, scheduling boundaries, concurrent claims, duplicate suppression, retries, interruption recovery, SMTP acceptance, access control and escaping. Its SMTP capture listens only on localhost and never delivers external mail. `scripts/review_email_recaps_ui.cjs` intercepts every API request and checks desktop/mobile layouts, preview isolation, preferences, scheduling, delivery history and public links. Set `REVIEW_EMAIL_FIXTURE` to a JSON file returned by `recap_email.render_recap`, with `month` and `email` added, plus the usual `REVIEW_BASE`, `REVIEW_PLAYWRIGHT` and optional `REVIEW_DIR`.
## Artwork and transcoding
Recently watched uses Jellystat's `NowPlayingItemId`, which identifies the movie or series, to load a Jellyfin primary poster. Magent proxies the image through an authenticated endpoint using a short-lived signature bound to the viewer, item and Jellyfin connection. Jellyfin credentials stay on the backend. Missing or deleted artwork falls back to a media tile. Thumbnail responses are privately cached.
How you streamed includes audio transcoding minutes and hardware-assisted video transcoding minutes. These are playback durations attributed to the recorded transcode flags, not GPU busy time or encoder runtime. Audio and video durations can overlap. Video must actually be transcoded for hardware-assisted minutes to count; a hardware label on an audio-only conversion does not count as GPU video work. Direct-play records ignore residual transcoding metadata, and missing details remain unknown.
Jellyfin exposes separate [video/audio passthrough flags and hardware type](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Session/TranscodingInfo.cs), with [hardware type names](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/Entities/HardwareAccelerationType.cs). The existing Jellystat history does not contain GPU utilization or GPU busy-time samples, so Magent does not calculate those figures.
-13
View File
@@ -1,13 +0,0 @@
# Manual release selection
Manual TV searches query Sonarr by each missing monitored episode ID, with three concurrent searches and batches of 20. Season packs returned by those searches remain visible. Larger requests offer the next batch. Movie searches use the Radarr movie ID. Both manual and automatic searches retain the assigned quality profile; admin defaults apply when creating requests.
Results include rejection reasons instead of silently filtering everything out. Approved releases can be selected normally. The **Ignore profile limits** permission defaults off for non-admin users and is available in User management > Manage users and Manage this user > Feature access. Administrators retain access.
Permitted users enable the override in the release picker and explicitly confirm each out-of-profile download. Quality, size, language and custom-format/profile rejections can be overridden. Other rejection reasons remain blocked. Downloads go through Sonarr/Radarr's native manual release endpoint without modifying quality profiles or bypassing the collector.
Selections carry a ten-minute signed receipt bound to the user, request, collector, media item and release. The backend rechecks the current permission and explicit override consent on download. Expired collector caches require another search; arbitrary client-provided download URLs are not pushed upstream.
Validation covers per-episode batching, profile preservation, default-off and bulk/individual permissions, permission revocation with an existing login, forged selections, rejection classification, desktop/mobile confirmation and blocked results.
Upstream reference: [Sonarr ReleaseController](https://github.com/Sonarr/Sonarr/blob/develop/src/Sonarr.Api.V3/Indexers/ReleaseController.cs) exposes episode-specific interactive search and the collector's manual grab operation.
-34
View File
@@ -1,34 +0,0 @@
# Grizzlyflix newsletters
Open **Config → Newsletters** (`/admin/newsletters`) to create an edition from the last 7, 14 or 30 days of Jellyfin additions. Select up to 24 titles, feature up to three picks, edit the subject and add a plain-text announcement. Episodes are grouped by show. The editor offers the newest 60 titles in the chosen period and displays the total found.
Save and preview the edition, then send a test to your own confirmed newsletter email. Choose **Send now** or an explicit UTC date and time within the next 90 days. Scheduled editions retain their saved content. To change a scheduled edition, cancel it and create another draft. Cancellation stops pending delivery; messages already accepted by SMTP cannot be recalled.
The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects the 12 newest titles from the previous seven days. The default announcement applies to future weekly editions and new drafts. Starting or changing settings moves the schedule to its next future occurrence. Empty weeks are skipped. After downtime, only the latest due week is generated; Magent does not backfill every missed week. Pausing cancels pending automatic editions; custom schedules continue independently.
## Setup and subscriptions
- Configure Jellyfin and its **public** address for Watch links, plus the existing SMTP email settings. Background automation must be enabled. Jellystat is not required for newsletters.
- Email links inherit the application URL from Hosting & proxy, or the proxy base URL when reverse proxy mode is enabled. Weekly schedule displays the effective address read-only. Existing email-specific addresses remain a fallback only when hosting has not been configured. Watch links use Jellyfin's public playback URL. No schedule or subscriptions are enabled by migration.
- Users opt in at **Profile → New on Grizzlyflix**. This consent is separate from monthly viewing recaps. A current email already confirmed for monthly recaps can be reused after the user explicitly subscribes to newsletters. Otherwise a confirmation email is sent, with a 24-hour expiry and a five-minute resend limit.
- Each subscriber needs a stored Jellyfin account link. Email or identity changes, blocking and account removal invalidate consent. Confirmation and unsubscribe tokens are specific to newsletters. Opening a public link checks it; changing the preference requires pressing its confirmation button.
## Content and artwork
Arrivals use Jellyfin's `DateCreated`, not premiere dates. How Jellyfin assigns this timestamp depends on the server's library configuration and imported metadata. Magent scans descending pages, validates the date range and rejects incomplete or changing results. A 5,000-item bound prevents an unbounded library scan; shorten the period if the editor reports this limit.
Immediately before preparation, Magent checks the stored Jellyfin server and user identities. Each item lookup supplies both the recipient's `UserId` and a `ParentId` from that user's permitted views. This matters because Jellyfin 10.11 skips its default library filter when `Ids` is supplied. Restricted or removed titles are excluded and episode counts reflect only permitted episodes. Administrators preview the full selection; their test email uses their own library access. Announcements are shared text and should be written for the whole audience.
This behaviour was checked against Jellyfin 10.11.11's [item queries](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Jellyfin.Api/Controllers/ItemsController.cs), [library query scoping](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Emby.Server.Implementations/Library/LibraryManager.cs) and [user views](https://github.com/jellyfin/jellyfin/blob/v10.11.11/Jellyfin.Api/Controllers/UserViewsController.cs).
Posters are fetched on the server, decoded and resized to bounded JPEGs. Emails embed them as CID attachments; recipients do not need a Magent session to load them. Admin previews embed data images. Missing artwork uses a placeholder. API keys never appear in email or artwork URLs. Watch links open the movie or show in the configured public Jellyfin web client and require its normal sign-in.
## Delivery behaviour
Subscriptions, editions, immutable versions and delivery history use independent `newsletter_*` SQLite tables. Both newsletter and monthly recap queues share atomic claim/lease handling and the existing SMTP transport.
An edition queues once per eligible subscriber, with durable deduplication. Subscribers must have confirmed before the edition's send time. Tests require a request UUID, retain the requested saved version and have a five-minute cooldown. Account, subscription and cancellation checks run again immediately before SMTP DATA.
Temporary preparation or SMTP failures retry after five and thirty minutes, up to three attempts. If acceptance becomes uncertain after DATA begins, history shows **Needs review** and automatic retries stop. **Accepted by mail server** records SMTP acceptance, not inbox placement. An edition marked **Finished** has no pending deliveries; check individual history rows for sent, skipped or failed outcomes.
No test recipient overrides or bulk subscription actions are exposed. UI review scripts intercept all API requests. Backend tests use disposable SQLite databases and captured mail, never the live SMTP service.
-17
View File
@@ -1,17 +0,0 @@
# Original-language requests
New Requests displays a language notice when Seerr reports a known, non-English original language. This is title metadata, not proof that a particular release lacks an English audio track or includes subtitles. Unknown and English original languages do not produce the notice.
Users can leave the normal request settings or explicitly accept original-language audio. The choice resets when changing titles and is verified against fresh Seerr metadata during submission; browser-supplied profile IDs remain ignored.
For movies, consent creates or reuses a `Magent Original …` Radarr quality profile. It copies the current default's quality ordering, allowed qualities, cutoff, upgrade rules and custom-format scores, changing only the language to Original. The existing default is never edited. The copy is selected for this new Seerr request only. Magent's subsequent Search and auto-download action preserves a verified copy instead of resetting it to English. Copies are content-addressed so later default changes do not silently change earlier requests.
TV requests show the same notice and retain their configured Sonarr profile. The inspected Sonarr configuration has no language custom formats. This feature does not bypass custom-format rejection, indexer restrictions, availability or permissions; it does not guarantee that a download is available. Existing requests show a prominent audio panel above the pipeline. **Use <language> audio & search** explicitly updates and reads back the existing Radarr movie profile before searching. Seerr only permits editing pending requests, so an approved request retains its historical Seerr profile field; the live Radarr profile is authoritative for collection.
The first opted-in movie request creates a profile in Radarr. Failed request submission can leave an unused copy, which is reused on retry. Do not rename/edit managed copies if they should retain Magent's recognition during subsequent searches.
Radarr's API represents Original as language ID -2: [language source](https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Languages/Language.cs). Profile fields are defined in its [quality profile resource](https://github.com/Radarr/Radarr/blob/develop/src/Radarr.Api.V3/Profiles/Quality/QualityProfileResource.cs).
Validation: backend consent/profile isolation tests and `scripts/review_request_language_ui.cjs` with intercepted APIs; no live requests or downloads are created by these tests.
Manual actions automatically open their progress dialog. The final response distinguishes a queued download, a completed search with no observed download, a failed search and a search still running. Interactive searches expose rejection reasons. Radarr queue reads use the supported `movieIds` filter before pagination, preventing unrelated first-page records from hiding the actual download.
-27
View File
@@ -1,27 +0,0 @@
# User feature access
In **Configuration → User management → Manage users**, the Feature access checkboxes apply to all existing non-admin accounts. A mixed checkbox means some accounts have access. Only changed checkboxes are saved; search filters do not restrict the bulk operation. New accounts retain the default access described below.
Open a user and choose **Manage this user** to change individual permissions, contact email, role, automatic search/download, profile defaults or expiry. Request statistics remain on the main profile page. Administrators always have all features.
| Feature | Access controlled |
| --- | --- |
| My Stats | Viewing statistics, report exports, report email preferences and delivery |
| My Requests | Existing requests, progress, request actions and live request streams |
| New Requests | Media request options and submission |
| Issues | Issue lists, reporting, comments, resolution responses and issue repair actions |
| Invites | Creating, viewing and managing personal invitations; existing limits still apply |
Media search is shared by New Requests and the issue picker. Either permission allows search; only New Requests permits submission. The existing automatic search/download permission still applies in addition to feature access.
Navigation and direct-page access use the authenticated account's permissions. APIs enforce them independently on each request. Open request streams recheck access, and report emails recheck Stats access before sending. Removing a permission does not erase existing records or unsend emails. The switches apply inside Magent and do not change Jellyfin or Seerr permissions.
Existing users retain Stats, My Requests, New Requests and Issues access when upgrading. Invite access uses the existing `users.invite_management_enabled` column. Other overrides are stored by Magent user ID in `user_feature_permissions`; deletion of the account removes its overrides. The old site-wide navigation visibility setting is no longer used by the menus.
The red account section distinguishes:
- **Block Magent access:** prevent Magent sign-in and keep the account.
- **Disable Magent and Jellyfin access:** block Magent, attempt to disable the same-name Jellyfin account, disable issued invitations and attempt a notification email. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.
- **Delete Magent, Jellyfin and Seerr accounts:** remove Magent and local activity, attempt deletion of the same-name Jellyfin account and linked Seerr account, disable invitations and attempt notification. Media files and Jellystat history are retained. External actions can partially fail.
Validation: backend permission tests use temporary databases and real signed tokens. `scripts/review_feature_access_ui.cjs` checks desktop/mobile profiles, dialogs, bulk scope and denied routes with intercepted API fixtures. Set `PLAYWRIGHT_PACKAGE` when Playwright is installed outside the project, and optionally `REVIEW_BASE` to target a deployed frontend.
-59
View File
@@ -1,59 +0,0 @@
# Confirming user identities
Open **Users → Confirm user IDs**, or **Settings → User identities**. Administrator access is required.
1. Choose **Check all user IDs** to read the live Jellyfin and Seerr directories and check Jellystat user metadata.
2. Search by account name or ID, or filter by status. The results include raw Magent rows that the ordinary user directory may hide as duplicates.
3. Select accounts marked **Ready to review**. Check the Magent account, Jellyfin ID and Seerr ID in **Review selected links**.
4. Choose **Confirm and save links**. Magent checks the live mappings again before saving. If accounts, service settings or mappings changed, run a fresh check.
The canonical external identity is the Jellyfin server ID plus Jellyfin user ID. Seerr is matched through its explicit `jellyfinUserId`; Jellystat must return the same user ID. Existing Magent Jellyfin or Seerr links take precedence. For existing Jellyfin sign-in accounts without a stored ID, a unique normalized Jellyfin username provides a **suggestion requiring administrator review**. Emails and email prefixes never establish an identity.
Confirmation saves the Jellyfin link, Seerr user ID, Jellyfin server ID, timestamp and confirming administrator. Normal name-based sync cannot replace confirmed links. My Stats uses the saved Jellyfin ID for playback and the Seerr ID for requests. Changes to the authentication token format are outside this feature.
Conflicts, duplicate accounts, ambiguous case/whitespace names, absent IDs and unavailable services cannot be confirmed. This workflow does not merge, delete or create user accounts in any platform. It does not rewrite playback or requests. Conflicting mappings need investigation before reconciliation.
Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links. They do not enumerate historical Jellystat-only users or playback records. Each run supports up to 3,000 identities, fetches complete Seerr pages, limits concurrent Jellystat requests to six, and stops checking Jellystat after 25 seconds. Unfinished checks remain unavailable, never verified. Results are not HTTP-cached and contain no credentials or raw playback history.
All selected accounts are saved in one transaction. The server derives the destination IDs from a fresh check and verifies the database snapshot before writing; the browser only supplies the reviewed revision and selected Magent row IDs.
### Resolve a missing link
Open **Users > Manage users > Review account links**, then **Check all user IDs**.
For an account marked **Missing link**, choose **Resolve missing link**. Select the
correct Jellyfin account by name and ID, then **Check selected account**. The
preview checks Seerr's explicit Jellyfin ID, Jellystat's matching ID, and every
Magent account (including hidden duplicates) for ownership conflicts.
Review the IDs and choose **Confirm and save link**. Magent rechecks live services
and the local directory before atomically saving both links and the administrator
audit record. A changed preview must be checked again. Existing confirmed or
conflicting stored identities cannot be replaced using this flow. Missing upstream
records must be corrected in their service before confirmation is available.
No accounts are created, merged or deleted; emails are not used to infer identity.
### User Management and repairs
Identity checks now live at **Config > User management > Account links & repairs**.
The old `/admin/identities` link redirects there. Choose **Review repair** on a
missing or conflicting account, select the authoritative Jellyfin identity, and
preview the current and proposed Magent links. Saving rechecks live service IDs,
all local owners, the server identity and concurrent changes. Repairs retain an
atomic before/after audit in `user_identity_repairs`. Changing a Jellyfin identity
revokes identity-bound email subscriptions; users must opt in again.
If a person has never had a Seerr account, explicitly choose the single-account
import option and preview again. Confirmation imports only that Jellyfin ID via
Seerr's supported API and rechecks its resulting Seerr ID before saving Magent.
Seerr and Magent cannot share a transaction: if an import succeeds but the local
save fails, the imported account is retained and the administrator must recheck.
No automatic deletion or rollback of upstream accounts is attempted.
For an existing Seerr account with a different Jellyfin ID, inspect its ID in the
preview and reconnect that existing account in Seerr using the account owner's
Jellyfin sign-in. Magent cannot rewrite Seerr's Jellyfin ID through the normal
admin user-update endpoint. Do not import another account to bypass a mismatch.
Duplicate Magent owners remain blocked until the ownership conflict is resolved;
this workflow does not merge users, permissions, requests or playback history.
-3
View File
@@ -1,3 +0,0 @@
node_modules/
.next/
.env
-28
View File
@@ -1,28 +0,0 @@
# Shared workspace layout
- Use `app/ui/PageHeading.tsx` for page titles. Keep the heading flat, with a short description and optional actions. Only record IDs belong in the optional eyebrow.
- Admin pages use `AdminShell`, which supplies the same heading and settings navigation.
- Authentication screens use `AuthLayout`; they do not render the signed-in navigation.
- `app/workspace.css` owns page width, gutters, title sizes and shared spacing. Feature styles own the content inside those pages. Do not add new page-specific hero panels or outer width overrides.
- Keep primary actions, secondary controls and destructive actions visually distinct. Do not fade or uppercase every span inside a button: cards also use buttons, often with nested text.
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
## Browser checks
Build the frontend before reviewing. The scripts in `scripts/` run using Node and Playwright:
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
- `review_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
+8 -8
View File
@@ -1102,18 +1102,18 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_telegram_chat_id: "-1001234567890",
magent_notify_push_base_url: "https://ntfy.example.com or https://gotify.example.com",
magent_notify_push_topic: "magent-alerts",
magent_notify_push_device: "iphone-zak",
magent_notify_push_device: "my-phone",
magent_notify_webhook_url: "https://automation.example.com/webhooks/magent",
jellyseerr_base_url: "https://requests.example.com or 10.30.1.81:5055",
jellyfin_base_url: "https://jelly.example.com or 10.40.0.80:8096",
jellyseerr_base_url: "https://requests.example.com or http://seerr:5055",
jellyfin_base_url: "https://jelly.example.com or http://jellyfin:8096",
jellystat_base_url: "http://jellystat:3000",
jellyfin_public_url: "https://jelly.example.com",
sonarr_base_url: "https://sonarr.example.com or 10.30.1.81:8989",
bazarr_base_url: "https://bazarr.example.com or 10.30.1.81:6767",
sonarr_base_url: "https://sonarr.example.com or http://sonarr:8989",
bazarr_base_url: "https://bazarr.example.com or http://bazarr:6767",
bazarr_default_language: "en",
radarr_base_url: "https://radarr.example.com or 10.30.1.81:7878",
prowlarr_base_url: "https://prowlarr.example.com or 10.30.1.81:9696",
qbittorrent_base_url: "https://qb.example.com or 10.30.1.81:8080",
radarr_base_url: "https://radarr.example.com or http://radarr:7878",
prowlarr_base_url: "https://prowlarr.example.com or http://prowlarr:9696",
qbittorrent_base_url: "https://qb.example.com or http://qbittorrent:8080",
site_login_message: "Sign-in information, an outage notice, or help for users…",
};
+1 -1
View File
@@ -1837,7 +1837,7 @@ export default function AdminInviteManagementPage() {
onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
placeholder="Welcome! Use this link to create your account."
/>
</label>
{inviteFlowStep === 2 && (
+1 -1
View File
@@ -368,7 +368,7 @@ export default function NewslettersAdminPage() {
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">A fresh edition</span>
<h2>Whats new on Grizzlyflix</h2>
<h2>Whats new in your library</h2>
<p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p>
</div>
<div className="newsletter-create">
+3 -3
View File
@@ -1,11 +1,11 @@
import "./style.css";
export const metadata = { title: "Coming soon | Magent — Grizzlyflix" };
export const metadata = { title: "Coming soon | Magent" };
export default function ComingSoonPage() {
return (
<main className="launch-cover">
<div className="launch-brand">GRIZZLYFLIX</div>
<div className="launch-brand">MAGENT</div>
<span className="launch-badge">COMING SOON</span>
<h1>
Your next watch.
@@ -26,7 +26,7 @@ export default function ComingSoonPage() {
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer>
<strong>Magent</strong>
<span>Grizzlyflix member portal</span>
<span>Your media member portal</span>
<a href="/login">Admin sign in</a>
</footer>
</main>
+2 -2
View File
@@ -6,7 +6,7 @@ export default function HowItWorksPage() {
<main className="friendly-guide">
<PageHeading
title="A little help getting started."
description="Magent looks after your requests. GrizzlyFlix is where you watch them."
description="Magent looks after your requests. Jellyfin is where you watch them."
/>
<nav aria-label="Quick links">
<a href="/welcome">Welcome page</a>
@@ -62,7 +62,7 @@ export default function HowItWorksPage() {
season pack.
</li>
<li>
<strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.
<strong>Available to watch:</strong> Jellyfin has added the content. Use the watch button to open it.
</li>
</ol>
<p>
+2 -2
View File
@@ -138,7 +138,7 @@ export default function LoginPage() {
setError("");
}}
>
Grizzlyflix
Jellyfin
</button>
<button
type="button"
@@ -164,7 +164,7 @@ export default function LoginPage() {
) : (
<form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">
{selectedMode === "jellyfin" ? "Use your Grizzlyflix / Jellyfin account." : "Use your Magent account."}
{selectedMode === "jellyfin" ? "Use your Jellyfin account." : "Use your Magent account."}
</p>
<label htmlFor="login-username">Username</label>
<input
@@ -699,7 +699,7 @@ export default function NewRequestClient() {
<div className="request-submit-bar">
<div>
<span>Delivery route</span>
<strong>Seerr {options.destination.collector} Grizzlyflix</strong>
<strong>Seerr {options.destination.collector} Jellyfin</strong>
<small>Your request uses the default quality set by your administrator.</small>
</div>
<button
@@ -106,7 +106,7 @@ export default function NewsletterLinkPage() {
<span>Magent</span>
</a>
<section className="account-panel">
<span className="recap-eyebrow">Grizzlyflix newsletters</span>
<span className="recap-eyebrow">Magent newsletters</span>
<h1>
{state === "enabled"
? "Youre on the list."
+4 -4
View File
@@ -218,7 +218,7 @@ const ISSUE_CATEGORIES: Array<{
id: "missing_content",
marker: "MISSING",
label: "Movie or episode is missing",
description: "A title, season, episode, or expected part is not available in Grizzlyflix.",
description: "A title, season, episode, or expected part is not available in Jellyfin.",
outcome: "The selected missing content will be sent back to Sonarr or Radarr.",
issueType: "missing_content",
titlePrefix: "Missing content",
@@ -254,7 +254,7 @@ const ISSUE_CATEGORIES: Array<{
id: "service_unavailable",
marker: "SERVER",
label: "Nothing will play",
description: "Grizzlyflix will not open or every title fails across the device or household.",
description: "Jellyfin will not open or every title fails across the device or household.",
outcome: "Magent will check Jellyfin and attach the result to the issue.",
issueType: "service_unavailable",
titlePrefix: "Media server unavailable",
@@ -285,7 +285,7 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
"Only fails on one device",
],
service_unavailable: [
"Grizzlyflix will not open",
"Jellyfin will not open",
"Every title fails",
"Login works but playback does not",
"Server error is shown",
@@ -1743,7 +1743,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setSelectedEpisodeIds([]);
}
}}
placeholder="Search the Grizzlyflix catalogue"
placeholder="Search the media catalogue"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -94,7 +94,7 @@ export default function NewsletterPreference() {
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Your next watch</span>
<h2 id="newsletter-preference-title">New on Grizzlyflix.</h2>
<h2 id="newsletter-preference-title">New in your library.</h2>
</div>
{data && (
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
+2 -2
View File
@@ -275,7 +275,7 @@ export default function ProfileInvitesPage() {
return (
<main className="card invites-page">
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
<PageHeading title="Invites" description="Invite someone to your media library and manage the links you share." />
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
@@ -418,7 +418,7 @@ export default function ProfileInvitesPage() {
onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
placeholder="Welcome! Use this link to create your account."
/>
</label>
{flowStep === 2 && (
+3 -3
View File
@@ -200,7 +200,7 @@ export default function ProfilePage() {
tone: "status",
message:
result.provider === "jellyfin"
? "Password updated for Grizzlyflix and Magent. Seerr uses the same password."
? "Password updated for Jellyfin and Magent. Seerr uses the same password."
: "Password updated.",
});
} catch (error) {
@@ -335,7 +335,7 @@ export default function ProfilePage() {
<span className="account-connection-dot" aria-hidden="true" />
<span>
{user.auth_provider === "jellyfin"
? "Connected with your Grizzlyflix account"
? "Connected with your Jellyfin account"
: user.auth_provider === "local"
? "Signed in with a Magent account"
: "Signed in with your media account"}
@@ -358,7 +358,7 @@ export default function ProfilePage() {
<h2>Change password</h2>
<p>
{passwordProvider === "jellyfin"
? "One password for Grizzlyflix, Seerr and Magent."
? "One password for Jellyfin, Seerr and Magent."
: "Keep your Magent account secure."}
</p>
</div>
+7 -7
View File
@@ -289,14 +289,14 @@ const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
{ id: "download", label: "Download", state: "waiting", summary: "No download attempt yet" },
{
id: "available",
label: complete ? "Available to watch" : indexing ? "Adding to Grizzlyflix" : "Media server",
label: complete ? "Available to watch" : indexing ? "Adding to Jellyfin" : "Media server",
state: complete ? "complete" : indexing ? "active" : "waiting",
stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting",
summary: complete
? "This title is ready to watch in Grizzlyflix."
? "This title is ready to watch in Jellyfin."
: indexing
? "The download is complete. Grizzlyflix is indexing this title now."
: "This title has not reached Grizzlyflix yet.",
? "The download is complete. Jellyfin is indexing this title now."
: "This title has not reached Jellyfin yet.",
link: snapshot.raw?.jellyfin?.link,
},
];
@@ -1031,13 +1031,13 @@ export default function RequestTimelinePage() {
<section>
<span className="request-overview-label">Ready to watch</span>
<strong>Watch this now!</strong>
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
<p>Open {snapshot.title} directly in Jellyfin.</p>
{mediaServerLink ? (
<a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">
Watch on Grizzlyflix <span aria-hidden="true">&rarr;</span>
Watch on Jellyfin <span aria-hidden="true">&rarr;</span>
</a>
) : (
<span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>
<span className="request-ready-unavailable">The Jellyfin watch link is not configured.</span>
)}
</section>
<section>
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useRef, useState } from "react";
import { copySetupTokenCommand, SETUP_TOKEN_COMMAND } from "./setup-token-help";
import styles from "./setup.module.css";
export default function SetupTokenHelp({ disabled = false }: { disabled?: boolean }) {
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const commandField = useRef<HTMLTextAreaElement>(null);
const [copyStatus, setCopyStatus] = useState("");
const copyCommand = async () => {
const result = await copySetupTokenCommand(navigator.clipboard, commandField.current);
setCopyStatus(
result === "copied"
? "Command copied. Paste it into the Magent container console."
: result === "selected"
? "Automatic copying is unavailable. The command is selected; press Ctrl+C (Command+C on Mac), or touch and hold to copy."
: "Automatic copying is unavailable. Select and copy the command above.",
);
};
return (
<div className={styles.tokenHelp}>
<button
ref={trigger}
type="button"
className="ghost-button"
disabled={disabled}
aria-haspopup="dialog"
aria-controls="setup-token-help"
onClick={() => {
setCopyStatus("");
dialog.current?.showModal();
}}
>
Get setup token
</button>
<dialog
ref={dialog}
id="setup-token-help"
className={styles.tokenHelpDialog}
aria-labelledby="setup-token-help-title"
aria-describedby="setup-token-help-description"
onClose={() => trigger.current?.focus()}
>
<header className={styles.tokenHelpHeading}>
<h2 id="setup-token-help-title">Get your setup token</h2>
<button type="button" className="ghost-button" onClick={() => dialog.current?.close()}>
Close
</button>
</header>
<p id="setup-token-help-description">
Magent generates a private setup token when a managed installation starts. Retrieve it from your server
console to create the first administrator.
</p>
<ol className={styles.tokenHelpSteps}>
<li>In Portainer, open Containers and select the healthy Magent container.</li>
<li>
Open Console, choose command <code>/bin/ash</code> and user <code>magent</code>, then connect.
</li>
<li>Run the command below, then copy its output into the Setup token field on this page.</li>
</ol>
<label htmlFor="setup-token-command">Container console command</label>
<textarea
ref={commandField}
id="setup-token-command"
className={styles.tokenCommand}
readOnly
rows={3}
spellCheck={false}
value={SETUP_TOKEN_COMMAND}
/>
<button type="button" onClick={() => void copyCommand()}>
Copy command
</button>
<p role="status" aria-live="polite" className={styles.copyStatus}>
{copyStatus}
</p>
<p>
Keep the token private. Anyone with it and access to this installation can create the first administrator. The
command stops returning it once an administrator exists.
</p>
<p>
For a manual deployment, use the <code>SETUP_TOKEN</code> from your deployment environment.
</p>
</dialog>
</div>
);
}
+42 -6
View File
@@ -4,11 +4,13 @@ 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 SetupTokenHelp from "./SetupTokenHelp";
import { serviceStatusLabel } from "../admin/configNavigation";
import {
ALL_FIELDS,
APPS,
PREFERENCES,
bootstrapApplicationUrl,
configuredApp,
settingsPayload,
settingsValues,
@@ -54,12 +56,14 @@ export default function SetupPage() {
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [setupToken, setSetupToken] = useState("");
const [applicationUrl, setApplicationUrl] = 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(() => {
setApplicationUrl(window.location.origin);
const controller = new AbortController();
const load = async () => {
try {
@@ -165,13 +169,19 @@ export default function SetupPage() {
void run("account", async () => {
let loginPassword = password;
if (status?.needs_admin) {
const confirmedApplicationUrl = bootstrapApplicationUrl(applicationUrl, window.location.origin);
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 }),
json({
setup_token: setupToken,
username: username.trim(),
password,
application_url: confirmedApplicationUrl,
}),
authFetch,
);
setPassword(loginPassword);
@@ -343,7 +353,7 @@ export default function SetupPage() {
Retry
</button>
) : forbidden ? (
<section className={styles.panel}>
<section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}>
@@ -351,14 +361,36 @@ export default function SetupPage() {
</button>
</section>
) : !admin ? (
<section className={styles.panel}>
<section className={`${styles.panel} ${styles.accountPanel}`}>
<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 private setup token to create the first administrator account."
: "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-application-url">Public Magent URL</label>
<input
id="setup-application-url"
type="url"
autoComplete="url"
required
maxLength={2048}
value={applicationUrl}
onChange={(event) => setApplicationUrl(event.target.value)}
aria-describedby="setup-application-url-hint"
disabled={!!busy}
/>
<p id="setup-application-url-hint">
Confirm the address your users will open. Use HTTPS for internet-facing installs. This must match
the address currently open in your browser; if you plan to use another domain, open Magent there
before creating your administrator. Managed installs use this address for links and sign-in
security.
</p>
</div>
)}
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-token">Setup token</label>
@@ -371,8 +403,11 @@ export default function SetupPage() {
maxLength={1024}
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
aria-describedby="setup-token-hint"
disabled={!!busy}
/>
<p id="setup-token-hint">Retrieve this token from your server console.</p>
<SetupTokenHelp disabled={!!busy} />
</div>
)}
<div className={styles.field}>
@@ -537,8 +572,9 @@ export default function SetupPage() {
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.
You may remove a manually configured SETUP_TOKEN from your environment after completion. Managed
installs keep their generated keys in the data volume; do not remove that file or volume. Existing
users and invites are preserved.
</p>
</section>
)}
+41 -1
View File
@@ -1,5 +1,45 @@
import { describe, expect, it } from "vitest";
import { APPS, configuredApp, settingsPayload, settingsValues } from "./setup-model";
import { APPS, bootstrapApplicationUrl, configuredApp, settingsPayload, settingsValues } from "./setup-model";
describe("first administrator application URL confirmation", () => {
it.each([
["https://magent.example.com", "https://magent.example.com"],
[" https://MAGENT.example.com:443/ ", "https://magent.example.com"],
["http://192.0.2.10:3000", "http://192.0.2.10:3000"],
["http://[fd00::10]:3000/", "http://[fd00::10]:3000"],
])("confirms a canonical same-origin address %s", (value, browserOrigin) => {
expect(bootstrapApplicationUrl(value, browserOrigin)).toBe(browserOrigin);
});
it.each([
"",
"magent.example.com",
"//magent.example.com",
"https:/magent.example.com",
"ftp://magent.example.com",
"javascript:alert(1)",
"https://user:password@magent.example.com",
"https://magent.example.com/setup",
"https://magent.example.com/../",
"https://magent.example.com?query=1",
"https://magent.example.com?",
"https://magent.example.com#fragment",
"https://magent.example.com#",
"https://magent.example.com\\path",
"https://magent.\texample.com",
])("rejects a non-origin or unsafe URL %j", (value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow("Public Magent URL must");
});
it.each(["https://other.example.com", "http://magent.example.com", "https://magent.example.com:8443"])(
"requires the intended browser origin before claiming %s",
(value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow(
"Open Magent at your intended address",
);
},
);
});
describe("installation settings", () => {
it("offers every supported media integration", () => {
+32 -1
View File
@@ -124,7 +124,7 @@ export const PREFERENCES: { title: string; fields: Field[] }[] = [
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.",
hint: "Used in invite and notification links. Managed installs also use this address for CORS and sign-in; changing it changes the allowed browser origin. Manual installs keep their environment-configured CORS policy.",
},
{ key: "site_login_message", label: "Login page message", type: "textarea" },
{
@@ -185,6 +185,37 @@ export const PREFERENCES: { title: string; fields: Field[] }[] = [
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
export function bootstrapApplicationUrl(value: string, browserOrigin: string): string {
const configured = value.trim();
let url: URL;
try {
url = new URL(configured);
} catch {
throw new Error("Public Magent URL must be a full HTTP or HTTPS origin, for example https://magent.example.com.");
}
if (
!/^https?:\/\/[^/?#]+\/?$/i.test(configured) ||
/[\s\\]/.test(configured) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error(
"Public Magent URL must be an HTTP or HTTPS origin without credentials, a path, query or fragment.",
);
}
if (url.origin !== browserOrigin) {
throw new Error(
"Public Magent URL must match the address open in this browser. Open Magent at your intended address, then confirm it and create the administrator there.",
);
}
return url.origin;
}
export function settingsValues(settings: Setting[]): Values {
const values: Values = {};
for (const field of ALL_FIELDS) {
@@ -0,0 +1,60 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import SetupTokenHelp from "./SetupTokenHelp";
import { copySetupTokenCommand, SETUP_TOKEN_COMMAND } from "./setup-token-help";
describe("setup token console help", () => {
it("copies only the retrieval command when clipboard access succeeds", async () => {
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(clipboard, commandField)).toBe("copied");
expect(clipboard.writeText).toHaveBeenCalledExactlyOnceWith("python -m app.container_bootstrap setup-token");
expect(commandField.select).not.toHaveBeenCalled();
});
it("selects the command for manual copying when LAN HTTP has no clipboard API", async () => {
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(undefined, commandField)).toBe("selected");
expect(commandField.focus).toHaveBeenCalledOnce();
expect(commandField.select).toHaveBeenCalledOnce();
});
it("offers manual copying instead of reporting success when clipboard permission is denied", async () => {
const clipboard = { writeText: vi.fn().mockRejectedValue(new Error("Clipboard permission denied")) };
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(clipboard, commandField)).toBe("selected");
expect(commandField.focus).toHaveBeenCalledOnce();
expect(commandField.select).toHaveBeenCalledOnce();
});
it("does not claim a selection or successful copy if the command field is unavailable", async () => {
expect(await copySetupTokenCommand(undefined, null)).toBe("manual");
});
it("provides labelled native dialog controls and the managed and manual retrieval instructions", () => {
const html = renderToStaticMarkup(<SetupTokenHelp />);
expect(html).toContain('aria-haspopup="dialog"');
expect(html).toContain('aria-controls="setup-token-help"');
expect(html).toContain('<dialog id="setup-token-help"');
expect(html).toContain('aria-labelledby="setup-token-help-title"');
expect(html).toContain('aria-describedby="setup-token-help-description"');
expect(html).toContain('for="setup-token-command"');
expect(html).toMatch(/readonly=""/i);
expect(html).toContain('role="status"');
expect(html).toContain("Portainer");
expect(html).toContain("/bin/ash");
expect(html).toContain("<code>magent</code>");
expect(html).toContain(SETUP_TOKEN_COMMAND);
expect(html).toContain("<code>SETUP_TOKEN</code>");
expect(html).not.toContain('type="submit"');
expect(html).not.toContain("Command copied.");
});
it("can disable the help trigger while account creation is busy", () => {
expect(renderToStaticMarkup(<SetupTokenHelp disabled />)).toMatch(/<button[^>]*disabled=""[^>]*aria-haspopup/);
});
});
+24
View File
@@ -0,0 +1,24 @@
export const SETUP_TOKEN_COMMAND = "python -m app.container_bootstrap setup-token";
type ClipboardWriter = Pick<Clipboard, "writeText">;
type CommandField = Pick<HTMLTextAreaElement, "focus" | "select">;
export async function copySetupTokenCommand(
clipboard: ClipboardWriter | undefined,
commandField: CommandField | null,
): Promise<"copied" | "selected" | "manual"> {
try {
if (clipboard?.writeText) {
await clipboard.writeText(SETUP_TOKEN_COMMAND);
return "copied";
}
} catch {
// Clipboard access may be unavailable on LAN HTTP or denied by the browser.
}
if (commandField) {
commandField.focus();
commandField.select();
return "selected";
}
return "manual";
}
+16 -3
View File
@@ -1,10 +1,11 @@
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
.heading { margin-bottom: 30px; }
.heading { margin-bottom: 30px; text-align: center; }
.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 { display: flex; align-items: center; justify-content: 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; }
.accountPanel { width: 100%; max-width: 560px; box-sizing: border-box; margin-left: auto; margin-right: auto; }
.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); }
@@ -24,7 +25,18 @@
.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; }
.account { display: grid; gap: 20px; margin: 24px 0; }
.tokenHelp { min-width: 0; }
.tokenHelpDialog { width: min(560px, calc(100% - 32px)); max-height: calc(100dvh - 48px); box-sizing: border-box; margin: auto; padding: 24px; overflow-y: auto; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); color: var(--ops-text); text-align: left; }
.tokenHelpDialog::backdrop { background: rgb(0 0 0 / 65%); }
.tokenHelpDialog p { margin: 14px 0; font-size: 14px; }
.tokenHelpHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.tokenHelpHeading h2 { margin: 0; font-size: 22px; }
.tokenHelpHeading button { flex-shrink: 0; }
.tokenHelpSteps { padding-left: 22px; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
.tokenHelpSteps li + li { margin-top: 10px; }
.field .tokenCommand { display: block; margin: 8px 0 12px; resize: none; font-family: monospace; }
.tokenHelpDialog .copyStatus { min-height: 1.6em; color: var(--ops-primary-2); }
.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); }
@@ -43,6 +55,7 @@
.setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; }
.tokenHelpDialog { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; }
+2 -2
View File
@@ -143,7 +143,7 @@ function SignupPageContent() {
};
return (
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<form onSubmit={submit} className="account-form login-form auth-flow-form">
<label>
Invite code
@@ -254,7 +254,7 @@ export default function SignupPage() {
return (
<Suspense
fallback={
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<p role="status">Loading sign-up</p>
</AuthLayout>
}
+1 -4
View File
@@ -33,14 +33,11 @@ export default function ApplicationChrome() {
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">GrizzlyFlix media operations</div>
<div className="tagline">Your media operations</div>
</div>
</a>
</div>
<div className="header-right">
<span className="beta-chip" title="Beta environment">
Beta
</span>
<HeaderIdentity />
</div>
<div className="header-nav">
+1 -2
View File
@@ -20,7 +20,6 @@ export default function AuthLayout({
<MagentMark />
<span>Magent</span>
</a>
<span className="login-beta">Beta</span>
</div>
<header>
<h1 id="login-title">{title}</h1>
@@ -29,7 +28,7 @@ export default function AuthLayout({
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
<p className="login-credit">Magent · Request. Watch. Enjoy.</p>
</main>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default function ResolutionChoice({
<span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p>
<p>Try the affected content in Grizzlyflix, then choose:</p>
<p>Try the affected content in Jellyfin, then choose:</p>
<div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}>
<strong>YES</strong>
+37
View File
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import ComingSoonPage from "../coming-soon/page";
import HowItWorksPage from "../how-it-works/page";
import AuthLayout from "./AuthLayout";
describe("portable default branding", () => {
it("uses Magent branding without replacing the supplied sign-in content", () => {
const html = renderToStaticMarkup(
<AuthLayout title="Welcome to our library" description="Use your account" footer="Local help">
<p>A custom message</p>
</AuthLayout>,
);
expect(html).toContain("Magent · Request. Watch. Enjoy.");
expect(html).toContain("Welcome to our library");
expect(html).toContain("A custom message");
expect(html).toContain("Local help");
expect(html).not.toMatch(/grizzlyflix/i);
expect(html).not.toContain(">Beta<");
});
it("shows a generic coming-soon page", () => {
const html = renderToStaticMarkup(<ComingSoonPage />);
expect(html).toContain("MAGENT");
expect(html).toContain("Your media member portal");
expect(html).not.toMatch(/grizzlyflix/i);
});
it("explains the Jellyfin integration without a deployment-specific service name", () => {
const html = renderToStaticMarkup(<HowItWorksPage />);
expect(html).toContain("Jellyfin is where you watch them.");
expect(html).not.toMatch(/grizzlyflix/i);
});
});
+3 -3
View File
@@ -33,7 +33,7 @@ export default function WelcomePage() {
return (
<main className="welcome-page">
<header>
<span className="welcome-kicker">GrizzlyFlix + Magent</span>
<span className="welcome-kicker">Your media + Magent</span>
<h1>Make yourself at home.</h1>
<p>Something to watch, or something to sort out?</p>
</header>
@@ -54,7 +54,7 @@ export default function WelcomePage() {
<span className="welcome-icon" aria-hidden="true">
</span>
<h2>Go to GrizzlyFlix</h2>
<h2>Open Jellyfin</h2>
<p>Find your next favourite. Watch movies and TV shows.</p>
<strong>
Lets watch <span aria-hidden="true"></span>
@@ -65,7 +65,7 @@ export default function WelcomePage() {
<span className="welcome-icon" aria-hidden="true">
</span>
<h2>Go to GrizzlyFlix</h2>
<h2>Open Jellyfin</h2>
<p>The watch link hasnt been set up yet. Please ask an admin to add the public playback URL.</p>
</section>
)}
+1
View File
@@ -2,6 +2,7 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || "http://backend:8000";
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
poweredByHeader: false,
compress: true,
// API rewrites clone bodies even when excluded from proxy.ts's matcher.
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { proxy } from "./proxy";
function response(headers: Record<string, string> = {}) {
return proxy(new NextRequest("http://localhost:3000/setup", { headers }));
}
describe("deployment-aware content security policy", () => {
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("MAGENT_APPLICATION_URL", undefined);
vi.stubEnv("MAGENT_RUNTIME_MANAGED", undefined);
});
afterEach(() => vi.unstubAllEnvs());
it("keeps HTTPS upgrades enabled by default", () => {
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it("keeps HTTPS upgrades for an explicitly configured HTTPS site", () => {
vi.stubEnv("MAGENT_APPLICATION_URL", "https://magent.example.com");
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it("allows an unclaimed managed install to load its wizard on HTTP", () => {
vi.stubEnv("MAGENT_RUNTIME_MANAGED", "1");
expect(response().headers.get("Content-Security-Policy")).not.toContain("upgrade-insecure-requests");
});
it.each(["0", "true", "false", ""])('does not activate managed setup for flag "%s"', (flag) => {
vi.stubEnv("MAGENT_RUNTIME_MANAGED", flag);
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it.each(["https://magent.example.com", "not-a-url", "http://magent.lan/path"])(
"does not let managed mode bypass configured HTTPS or invalid origins: %s",
(origin) => {
vi.stubEnv("MAGENT_RUNTIME_MANAGED", "1");
vi.stubEnv("MAGENT_APPLICATION_URL", origin);
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
},
);
it("does not accept a caller-provided managed-mode header", () => {
expect(response({ MAGENT_RUNTIME_MANAGED: "1" }).headers.get("Content-Security-Policy")).toContain(
"upgrade-insecure-requests",
);
});
it.each(["http://192.0.2.10:3000", "http://magent.lan:3000/", "http://[fd00::10]:3000"])(
"supports the operator's explicit HTTP origin %s without upgrading its assets",
(origin) => {
vi.stubEnv("MAGENT_APPLICATION_URL", origin);
expect(response().headers.get("Content-Security-Policy")).not.toContain("upgrade-insecure-requests");
},
);
it.each([
"",
"not-a-url",
"http:/magent.lan",
"//magent.lan",
"ftp://magent.lan",
"http://user:password@magent.lan",
"http://magent.lan/path",
"http://magent.lan?query=1",
"http://magent.lan#fragment",
"http://magent.lan\\path",
"http://magent.\tlan",
])("does not relax HTTPS upgrades for invalid or non-origin configuration %j", (origin) => {
vi.stubEnv("MAGENT_APPLICATION_URL", origin);
expect(response().headers.get("Content-Security-Policy")).toContain("upgrade-insecure-requests");
});
it("does not trust caller-controlled host or forwarding headers to disable upgrades", () => {
const policy = response({
Host: "magent.lan:3000",
"X-Forwarded-Host": "magent.lan:3000",
"X-Forwarded-Proto": "http",
Forwarded: "host=magent.lan:3000;proto=http",
}).headers.get("Content-Security-Policy");
expect(policy).toContain("upgrade-insecure-requests");
});
it("preserves nonce propagation and strict production script rules on HTTP", () => {
vi.stubEnv("MAGENT_APPLICATION_URL", "http://magent.lan:3000");
const first = response();
const policy = first.headers.get("Content-Security-Policy");
const nonce = first.headers.get("x-middleware-request-x-nonce");
expect(nonce).toBeTruthy();
expect(policy).toContain(`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`);
expect(policy).not.toContain("'unsafe-eval'");
expect(policy).toContain("frame-ancestors 'none'");
expect(policy).toContain("form-action 'self'");
expect(policy).toContain("connect-src 'self'");
expect(first.headers.get("x-middleware-request-content-security-policy")).toBe(policy);
expect(response().headers.get("x-middleware-request-x-nonce")).not.toBe(nonce);
});
});
+18 -1
View File
@@ -1,5 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
function hasExplicitHttpOrigin(): boolean {
// Allow initial managed setup on a private LAN before the operator claims its
// origin. The entrypoint owns this flag; it is never derived from the request.
// Otherwise only an operator-provided origin may opt into HTTP for a private LAN.
// Never derive this decision from caller-controlled Host/forwarded headers.
const configured = (process.env.MAGENT_APPLICATION_URL || '').trim()
if (!configured && process.env.MAGENT_RUNTIME_MANAGED === '1') return true
if (!/^http:\/\//i.test(configured) || /[\s\\]/.test(configured)) return false
try {
const url = new URL(configured)
return url.protocol === 'http:' && !!url.hostname && !url.username && !url.password
&& url.pathname === '/' && !url.search && !url.hash
} catch {
return false
}
}
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''
@@ -16,7 +33,7 @@ export function proxy(request: NextRequest) {
"connect-src 'self'",
"worker-src 'self' blob:",
"manifest-src 'self'",
"upgrade-insecure-requests",
...(hasExplicitHttpOrigin() ? [] : ['upgrade-insecure-requests']),
].join('; ')
const requestHeaders = new Headers(request.headers)
-21
View File
@@ -1,21 +0,0 @@
# Magent monitoring
Grafana dashboard: `grafana/magent-api-performance.json` (Prometheus UID `prometheus`).
Set `MAGENT_METRICS_ENABLED=true`, `MAGENT_METRICS_BIND=0.0.0.0` and
`MAGENT_METRICS_PORT=9108` inside the container. Publish port 9108 **only on a
private interface**; do not proxy it through the public website. By default the
listener is disabled and its bind address is loopback.
Production publishes `100.114.113.88:9108:9108` on GRZ-DKR01's Tailscale interface.
Prometheus on ANA-DKR01 scrapes it every 15 seconds with job name `magent`.
Grafana's existing file provider loads the dashboard from its Magent folder.
API labels contain method, matched route template and HTTP status, never raw
paths, query values, usernames or credentials. API latency measures time to
response headers, not long-lived event-stream duration. Service metrics cover
the shared ApiClient, including background calls; custom client paths and CSRF
subrequests are not separate calls. CPU/memory refer to the Python backend only.
Metrics start at deployment, with no historical backfill. Rate/percentile panels
need multiple scrapes; unused services have no series until called. Prometheus
retains history across Magent restarts, while process counters reset normally.
@@ -1,474 +0,0 @@
{
"uid": "magent-api-performance",
"title": "Magent — API & Performance",
"tags": [
"magent",
"production"
],
"schemaVersion": 40,
"version": 1,
"refresh": "15s",
"time": {
"from": "now-1h",
"to": "now"
},
"timezone": "browser",
"editable": true,
"description": "Metrics begin when instrumentation is deployed. No historical backfill. API timings are time-to-headers. Outbound metrics cover shared ApiClient calls; CPU/memory cover the Python backend. No user IDs, usernames, tokens, search terms or raw URLs are labels.",
"panels": [
{
"id": 1,
"title": "Magent metrics reachable",
"description": "1 = scrape healthy; 0 = unavailable.",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "up{job=\"magent\"}",
"legendFormat": "Magent"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
},
{
"id": 2,
"title": "API calls / second",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "Calls / sec"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 3,
"title": "API response time — p95 by route",
"description": "Time to response headers; streaming session lifetime is excluded.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (le, route) (rate(magent_api_response_seconds_bucket{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{route}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 4,
"title": "API responses by status",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (status) (rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "HTTP {{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 5,
"title": "API server error percentage",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "100 * (sum(rate(magent_api_requests_total{job=\"magent\",status=~\"5..\"}[$__rate_interval])) or vector(0)) / clamp_min(sum(rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval])), 0.000001)",
"legendFormat": "5xx"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 6,
"title": "Busiest API routes",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "topk(10,sum by (route) (rate(magent_api_requests_total{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{route}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 7,
"title": "Connected service calls / second",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 24,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (service) (rate(magent_remote_requests_total{job=\"magent\"}[$__rate_interval]))",
"legendFormat": "{{service}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 8,
"title": "Connected services — p95 response time",
"description": "Instrumented shared API-client calls, including background work. Does not count every low-level HTTP exchange.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 24,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95,sum by (le,service) (rate(magent_remote_response_seconds_bucket{job=\"magent\"}[$__rate_interval])))",
"legendFormat": "{{service}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 9,
"title": "Service redirects and errors",
"description": "error = connection/transport failure. Redirects are shown because they can prevent API operations.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 32,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "sum by (service,status) (rate(magent_remote_requests_total{job=\"magent\",status=~\"3..|4..|5..|error\"}[$__rate_interval]))",
"legendFormat": "{{service}} · {{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 10,
"title": "Backend memory",
"description": "",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 32,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "process_resident_memory_bytes{job=\"magent\"}",
"legendFormat": "Python backend"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 11,
"title": "Backend CPU — cores used",
"description": "Backend process only, not the frontend or whole host.",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 40,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "rate(process_cpu_seconds_total{job=\"magent\"}[$__rate_interval])",
"legendFormat": "CPU cores"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 12,
"title": "Backend uptime",
"description": "",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 40,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"expr": "time() - process_start_time_seconds{job=\"magent\"}",
"legendFormat": "Uptime"
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"colorMode": "value"
}
}
]
}
+132
View File
@@ -0,0 +1,132 @@
"""Check the environment reference against source without importing application settings.
Only tracked-source locations are inspected. Deployment .env files, process
environment values and runtime data are never opened or evaluated.
"""
import ast
from dataclasses import dataclass
import json
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z")
@dataclass(frozen=True)
class Setting:
names: tuple[str, ...]
default: str
def settings_inventory(source: str) -> list[Setting]:
tree = ast.parse(source.lstrip("\ufeff"))
settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings")
result = []
for node in settings.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
name = node.target.id
names = (name.upper(),)
default = node.value
if isinstance(default, ast.Call):
arguments = {keyword.arg: keyword.value for keyword in default.keywords}
alias = arguments.get("validation_alias")
if isinstance(alias, ast.Constant):
names = (alias.value,)
elif isinstance(alias, ast.Call):
names = tuple(ast.literal_eval(argument) for argument in alias.args)
default = arguments.get("default")
if isinstance(default, ast.Name):
value = "@" + default.id
else:
value = json.dumps(ast.literal_eval(default), ensure_ascii=True)
result.append(Setting(names, value))
return result
def python_environment_names(source: str) -> set[str]:
names = set()
for node in ast.walk(ast.parse(source.lstrip("\ufeff"))):
argument = None
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args:
receiver = ast.unparse(node.func.value)
if (node.func.attr == "getenv" and receiver == "os") or (
node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"}
):
argument = node.args[0]
elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in {
"os.environ", "environ", "environment", "prepared"
}:
argument = node.slice
if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value):
names.add(argument.value)
return names
def runtime_environment_names(root: Path) -> set[str]:
names = set()
sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")]
for path in sources:
names.update(python_environment_names(path.read_text(encoding="utf-8")))
javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"),
*root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"]
for path in javascript:
if ".test." not in path.name:
names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"),
*root.glob(".gitea/workflows/*.yml")]
for path in deployment:
source = path.read_text(encoding="utf-8")
names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source))
names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source))
names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source))
# These are shell syntax/builtins, not Magent configuration options.
names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"})
dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
for line in dockerfile.splitlines():
if line.startswith(("ENV ", "ARG ")):
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8")
for line in supervisor.splitlines():
if line.startswith("environment="):
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
return names
def check_documentation(root: Path = ROOT) -> tuple[list[str], int]:
document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8")
documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document))
settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8"))
required = runtime_environment_names(root) | {name for setting in settings for name in setting.names}
errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)]
defaults = {}
for line in document.splitlines():
cells = line.split("|")
if len(cells) >= 4 and cells[1].strip().startswith("`"):
for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]):
defaults[name] = cells[2].strip().strip("`")
for setting in settings:
for name in setting.names:
if name in documented and defaults.get(name) != setting.default:
errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}")
return errors, len(required)
def main() -> int:
errors, count = check_documentation()
if errors:
print("\n".join(errors), file=sys.stderr)
return 1
print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -1
View File
@@ -14,9 +14,10 @@ echo "Running Python dependency integrity check"
echo "Auditing Python production dependencies"
"$python_bin" -m pip_audit -r backend/requirements.txt --progress-spinner off
"$python_bin" -m pip_audit -r docker/requirements-runtime.txt --progress-spinner off
echo "Linting backend application code"
"$python_bin" -m ruff check backend/app
"$python_bin" -m ruff check backend/app scripts/container_smoke.py scripts/check_environment_docs.py backend/tests/test_container_packaging.py backend/tests/test_container_bootstrap.py backend/tests/test_managed_setup_origin.py backend/tests/test_environment_docs.py
echo "Running backend unit tests with coverage"
"$python_bin" -m coverage erase
+120 -43
View File
@@ -1,64 +1,141 @@
#!/usr/bin/env bash
# No argument preserves the CI build-and-test entry point. Pass an image tag to
# test an already-built release without building, pulling, or publishing it.
set -euo pipefail
container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$"
if [ "$#" -gt 1 ]; then
echo "Usage: $0 [existing-image]" >&2
exit 2
fi
script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repository_directory="$(cd -- "$script_directory/.." && pwd)"
image="${1:-magent:ci}"
size_limit_mb="${MAGENT_IMAGE_MAX_MB:-350}"
managed_mode="${MAGENT_SMOKE_MANAGED:-false}"
if [[ "$managed_mode" != true && "$managed_mode" != false ]]; then
echo "MAGENT_SMOKE_MANAGED must be true or false." >&2
exit 2
fi
if ! [[ "$size_limit_mb" =~ ^[1-9][0-9]*$ ]]; then
echo "MAGENT_IMAGE_MAX_MB must be a positive integer (MiB)." >&2
exit 2
fi
container_name="magent-ci-${GITHUB_RUN_ID:-local}-$$-${RANDOM}"
volume_name="${container_name}-data"
network_name="${container_name}-isolated"
container_created=false
volume_created=false
network_created=false
cleanup() {
result=$?
trap - EXIT
if [ "$result" -ne 0 ] && [ "$container_created" = true ]; then
# Only synthetic credentials/data enter this test container.
docker logs --tail 100 "$container_name" >&2 || true
fi
if [ "$container_created" = true ]; then
docker rm -f "$container_name" >/dev/null 2>&1 || true
docker volume rm -f "$volume_name" >/dev/null 2>&1 || true
fi
if [ "$volume_created" = true ]; then
docker volume rm "$volume_name" >/dev/null 2>&1 || true
fi
if [ "$network_created" = true ]; then
docker network rm "$network_name" >/dev/null 2>&1 || true
fi
exit "$result"
}
trap cleanup EXIT
docker build --tag magent:ci .
if [ "$#" -eq 0 ]; then
docker build --tag "$image" "$repository_directory"
fi
image_size="$(docker image inspect --format '{{.Size}}' "$image")"
image_id="$(docker image inspect --format '{{.Id}}' "$image")"
if [ "$image_size" -gt "$((size_limit_mb * 1024 * 1024))" ]; then
echo "Image exceeds ${size_limit_mb} MiB unpacked budget: ${image_size} bytes" >&2
exit 1
fi
echo "Image size: ${image_size} bytes (budget ${size_limit_mb} MiB unpacked)"
# Inspect the image's original filesystem before tmpfs or volume mounts could
# hide accidentally shipped build caches or private files.
docker run --rm --pull never --network none --read-only \
--cap-drop ALL --security-opt no-new-privileges:true \
--entrypoint python -i "$image_id" - packaging < "$script_directory/container_smoke.py"
# An internal network prevents accidental external integration calls. No host
# files, existing volumes, host credentials, or host ports are used.
docker network create --internal "$network_name" >/dev/null
network_created=true
docker volume create "$volume_name" >/dev/null
docker run --rm --user 0 \
--volume "$volume_name:/app/data" \
--entrypoint chown \
magent:ci -R 1000:1000 /app/data
docker run --detach --name "$container_name" \
volume_created=true
start_container() {
local -a secret_environment
if [ "$managed_mode" = true ]; then
# Exercise the image defaults: no keys, origin or managed-mode variables.
secret_environment=()
else
secret_environment=(
--env JWT_SECRET=ci-only-secret-with-at-least-32-characters
--env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
--env SETUP_TOKEN=ci-only-setup-token-with-at-least-32-characters
--env AUTH_COOKIE_SECURE=true
--env MAGENT_APPLICATION_URL=https://magent-ci.example.test
)
fi
docker run --detach --name "$container_name" --pull never \
--network "$network_name" \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 256 --memory 1g --cpus 2 \
--tmpfs /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000 \
--tmpfs /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000 \
--volume "$volume_name:/app/data" \
--env JWT_SECRET=ci-only-secret-with-at-least-32-characters \
--env SETTINGS_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \
--env ADMIN_PASSWORD=ci-only-bootstrap-password-123 \
--env MAGENT_APPLICATION_URL=https://magent-ci.example.test \
magent:ci >/dev/null
"${secret_environment[@]}" \
--env ADMIN_PASSWORD= \
--env AUTH_COOKIE_SAMESITE=strict \
--env BACKGROUND_TASKS_ENABLED=false \
--env MAGENT_METRICS_ENABLED=false \
"$image_id" >/dev/null
container_created=true
}
deadline=$((SECONDS + 120))
until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container_name")" = "healthy" ]; do
if [ "$SECONDS" -ge "$deadline" ]; then
docker logs "$container_name"
echo "Container did not become healthy within 120 seconds" >&2
exit 1
wait_for_health() {
local deadline=$((SECONDS + 150))
local status
while true; do
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_name")"
if [ "$status" = healthy ]; then
return
fi
if [ "$status" = missing ] || [ "$SECONDS" -ge "$deadline" ]; then
echo "Container did not become healthy within 150 seconds (status: $status)" >&2
return 1
fi
if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != true ]; then
echo "Container exited before becoming healthy" >&2
return 1
fi
sleep 2
done
}
docker exec "$container_name" curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null
docker exec "$container_name" curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null
# Deliberately no root/chown helper: the image must initialize a fresh named
# volume with correct ownership for its normal non-root runtime user.
start_container
wait_for_health
docker exec -i "$container_name" python - fresh < "$script_directory/container_smoke.py"
# Exercise browser-origin requests, not only GET health checks. A configured
# public address must work even when CORS_ALLOW_ORIGIN has its localhost default.
docker exec -i "$container_name" python - <<'PY'
from urllib import error, request
docker restart --time 15 "$container_name" >/dev/null
wait_for_health
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
for path in ("/auth/login", "/auth/jellyfin/login"):
for origin, expected in (
("https://magent-ci.example.test", 422),
("https://untrusted.example.test", 403),
):
probe = request.Request(
"http://127.0.0.1:8000" + path,
data=b"",
headers={"Origin": origin, "Content-Type": "application/x-www-form-urlencoded"},
)
try:
response = request.urlopen(probe, timeout=10)
except error.HTTPError as exc:
response = exc
with response:
assert response.status == expected, (path, origin, response.status, expected)
print(f"Browser-origin login smoke: {path} {origin} -> {expected}")
PY
# Recreation proves database/configuration are in the volume, not merely in the
# container's writable layer. Both test instances use the same immutable image.
docker rm -f "$container_name" >/dev/null
container_created=false
start_container
wait_for_health
docker exec -i "$container_name" python - persisted < "$script_directory/container_smoke.py"
echo "Container smoke passed: fresh install, security headers, assets, login, backup/restore, restart, recreation."
+340
View File
@@ -0,0 +1,340 @@
"""Disposable-image checks, streamed into the container by ci_container_smoke.sh.
Uses only Python's standard library. All credentials and configuration below
are synthetic and the caller disables network egress and background workers.
This checks script/asset delivery and CSP compatibility, not browser execution.
"""
from html.parser import HTMLParser
import hashlib
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import sqlite3
import subprocess
import sys
from urllib import error, parse, request
ORIGIN = "https://magent-ci.example.test"
FRONTEND = "http://127.0.0.1:3000"
SETUP_TOKEN = "ci-only-setup-token-with-at-least-32-characters"
ADMIN_USERNAME = "container-smoke-admin"
ADMIN_PASSWORD = "Container-smoke-owner-password-123456789!"
INTEGRATION_SECRET = "synthetic-container-smoke-integration-key"
LOGIN_MESSAGE = "Welcome to an independent Magent installation"
BACKUP_PASSPHRASE = "Synthetic container backup passphrase only"
CACHE_FIXTURE = Path("/app/data/artwork/tmdb/w342/container-smoke.jpg")
CACHE_CONTENT = b"synthetic artwork cache fixture"
def managed_installation() -> bool:
return os.environ.get("MAGENT_MANAGED_SECRETS") in {"true", "auto"} and not os.environ.get("JWT_SECRET")
def check(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def http(
path: str,
*,
expected: int = 200,
method: str = "GET",
payload: dict | None = None,
form: dict | None = None,
raw: bytes | None = None,
headers: dict | None = None,
base: str = FRONTEND,
) -> tuple[bytes, object]:
outgoing_headers = {"Origin": ORIGIN, **(headers or {})}
check(sum(value is not None for value in (payload, form, raw)) <= 1,
"HTTP body must use only one encoding")
data = raw
if payload is not None:
data = json.dumps(payload).encode()
outgoing_headers["Content-Type"] = "application/json"
elif form is not None:
data = parse.urlencode(form).encode()
outgoing_headers["Content-Type"] = "application/x-www-form-urlencoded"
probe = request.Request(base + path, data=data, headers=outgoing_headers, method=method)
try:
response = request.urlopen(probe, timeout=30)
except error.HTTPError as exc:
response = exc
with response:
check(response.status == expected, f"{method} {path}: expected {expected}, got {response.status}")
return response.read(), response.headers
def api(path: str, **kwargs) -> dict:
body, _ = http("/api" + path, **kwargs)
return json.loads(body)
class PageAssets(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.scripts: list[dict] = []
self.assets: set[str] = set()
def handle_starttag(self, tag: str, attributes: list) -> None:
values = dict(attributes)
if tag == "script":
self.scripts.append(values)
if values.get("src"):
self.assets.add(values["src"])
if tag == "link" and values.get("href", "").startswith("/_next/static/"):
self.assets.add(values["href"])
def check_page(path: str, asset_cache: set[str]) -> str:
body, headers = http(path)
check("text/html" in headers.get("Content-Type", ""), f"{path} is not HTML")
policy = headers.get("Content-Security-Policy", "")
match = re.search(r"script-src [^;]*'nonce-([^']+)'", policy)
check(match is not None, f"{path} missing script nonce policy")
nonce = match.group(1)
check("'strict-dynamic'" in policy, f"{path} lost strict-dynamic")
check("'unsafe-eval'" not in policy, f"{path} enables development eval")
check(headers.get("X-Content-Type-Options") == "nosniff", "Missing nosniff header")
check(headers.get("X-Frame-Options") == "DENY", "Missing anti-framing header")
check(headers.get("X-Powered-By") is None, "Frontend exposes its framework")
parsed = PageAssets()
parsed.feed(body.decode())
executable_scripts = [
script for script in parsed.scripts
if script.get("type", "").lower() in ("", "module", "text/javascript", "application/javascript")
]
check(bool(executable_scripts), f"{path} contains no frontend bootstrap scripts")
for script in executable_scripts:
check(script.get("nonce") == nonce, f"{path} contains a script blocked by its CSP nonce")
check(any(asset.startswith("/_next/static/") and ".js" in asset for asset in parsed.assets),
f"{path} contains no static JavaScript assets")
for asset in sorted(parsed.assets - asset_cache):
check(asset.startswith("/_next/static/"), f"Unexpected external executable asset on {path}")
content, asset_headers = http(asset)
check(bool(content), f"Empty static asset: {asset}")
check("text/html" not in asset_headers.get("Content-Type", ""), f"Asset returned HTML: {asset}")
asset_cache.add(asset)
return nonce
def check_packaging() -> None:
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
check(Path("/app/frontend/server.js").is_file(), "Missing standalone frontend server")
check(shutil.which("node") == "/usr/local/bin/node", "Node is not the standalone runtime binary")
check(shutil.which("supervisord") == "/usr/local/bin/supervisord", "Missing Python supervisor")
check(shutil.which("curl") is not None, "curl compatibility for existing healthchecks was removed")
for executable in ("npm", "npx", "yarn", "pnpm", "pip", "pip3", "gcc", "g++", "make", "git", "gpg"):
check(shutil.which(executable) is None, f"Unnecessary runtime development tool: {executable}")
for forbidden in (
"/app/.git", "/app/tests", "/app/app/tests", "/app/backend/tests",
"/app/frontend/app", "/app/frontend/tsconfig.json", "/app/frontend/proxy.ts",
"/app/frontend/node_modules/typescript", "/app/frontend/node_modules/eslint",
"/app/frontend/node_modules/vitest", "/app/frontend/node_modules/@playwright",
"/app/frontend/node_modules/@biomejs",
"/app/frontend/node_modules/@next/swc-linux-x64-gnu",
"/app/frontend/node_modules/@next/swc-linux-arm64-gnu",
"/app/frontend/node_modules/@next/swc-linux-x64-musl",
"/app/frontend/node_modules/@next/swc-linux-arm64-musl",
"/root/.npm", "/root/.cache/pip", "/usr/local/lib/node_modules/npm",
):
check(not Path(forbidden).exists(), f"Unnecessary build/private artifact: {forbidden}")
for directory in (Path("/app"), Path("/app/frontend")):
check(not any(directory.glob(".env*")), f"Private environment file in {directory}")
check(not Path("/app/data/bootstrap-secrets.json").exists(), "Managed secrets baked into image")
check(not any(Path("/app/frontend/.next/cache").iterdir()), "Frontend build cache shipped in runtime")
check(Path("/usr/share/licenses/magent/LICENSE").is_file(), "Magent license is missing")
check(Path("/usr/local/share/doc/nodejs/LICENSE").is_file(), "Node distribution license is missing")
check(Path("/usr/share/licenses/magent/frontend/dependencies.json").is_file(),
"Frontend dependency inventory is missing")
print("Standalone packaging, non-root runtime and absent development tools/private files: PASS")
def check_runtime() -> None:
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
check(Path("/app/data").stat().st_uid == os.getuid(), "Fresh data volume is not owned by runtime user")
check(os.access("/app/data", os.W_OK), "Data volume is not writable")
for path in ("/api/health", "/api/setup/status"):
http(path)
http("/health", base="http://127.0.0.1:8000")
asset_cache: set[str] = set()
first_nonce = check_page("/login", asset_cache)
second_nonce = check_page("/login", asset_cache)
check(first_nonce != second_nonce, "CSP nonce is reused between requests")
check_page("/setup", asset_cache)
# Check the retained curl command because some deployed stacks override the
# image HEALTHCHECK with this exact runtime dependency.
subprocess.run(["curl", "--fail", "--silent", "--show-error", FRONTEND + "/api/health"],
check=True, stdout=subprocess.DEVNULL)
print(f"Runtime, API rewrite, CSP nonce consistency and {len(asset_cache)} static assets: PASS")
def check_origin_guards() -> None:
# MAGENT_APPLICATION_URL must permit the public origin even while CORS uses
# its localhost default. Test both direct backend and Next's API rewrite.
for base, prefix in ((FRONTEND, "/api"), ("http://127.0.0.1:8000", "")):
for endpoint in ("/auth/login", "/auth/jellyfin/login"):
for origin, expected in ((ORIGIN, 422), ("https://untrusted.example.test", 403)):
http(prefix + endpoint, base=base, method="POST", form={}, expected=expected,
headers={"Origin": origin})
print("Both login Origin guards, directly and via frontend: PASS")
def sign_in() -> dict:
_, headers = http("/api/auth/login", method="POST", form={
"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
})
cookies = SimpleCookie()
for raw_cookie in headers.get_all("Set-Cookie", []):
cookies.load(raw_cookie)
check("magent_auth" in cookies, "Local login did not issue an authentication cookie")
auth_cookie = cookies["magent_auth"]
check(bool(auth_cookie["httponly"]), "Authentication cookie missing HttpOnly")
check(bool(auth_cookie["secure"]), "Authentication cookie missing Secure")
check(auth_cookie["samesite"].lower() == "strict", "Authentication cookie missing SameSite=strict")
# These requests traverse HTTP loopback behind the simulated HTTPS public
# origin. Forward only our synthetic cookie explicitly; never print tokens.
authenticated_headers = {"Cookie": "magent_auth=" + auth_cookie.value}
identity = api("/auth/me", headers=authenticated_headers)
check(identity["username"] == ADMIN_USERNAME and identity["role"] == "admin",
"Local administrator identity did not survive login")
return authenticated_headers
def check_persisted_settings(headers: dict) -> None:
values = {item["key"]: item for item in api("/admin/settings", headers=headers)["settings"]}
check(values["site_login_message"]["value"] == LOGIN_MESSAGE, "Public configuration did not persist")
check(values["jellyfin_api_key"]["value"] is None and values["jellyfin_api_key"]["isSet"],
"Integration secret is missing or exposed by settings API")
with sqlite3.connect("file:/app/data/magent.db?mode=ro", uri=True) as connection:
check(connection.execute("PRAGMA quick_check").fetchone()[0] == "ok", "SQLite integrity failure")
check(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1,
"Fresh smoke instance has unexpected users")
stored = connection.execute("SELECT value FROM settings WHERE key = 'jellyfin_api_key'").fetchone()
check(stored is not None and INTEGRATION_SECRET not in str(stored[0]),
"Integration secret was stored without encryption")
def backup_restore_upload(content: bytes, passphrase: str) -> tuple[bytes, str]:
boundary = "magent-smoke-" + secrets.token_hex(24)
parts = []
for name, value in (("passphrase", passphrase), ("confirmation", "RESTORE")):
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n'
f'\r\n{value}\r\n').encode())
parts.extend([
(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="smoke.magent-backup"\r\n'
'Content-Type: application/octet-stream\r\n\r\n').encode(),
content,
f"\r\n--{boundary}--\r\n".encode(),
])
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def stage_backup_roundtrip(headers: dict) -> None:
# All paths and values belong to this disposable CI volume, never real data.
CACHE_FIXTURE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FIXTURE.write_bytes(CACHE_CONTENT)
content, response_headers = http("/api/admin/backups/export", method="POST", headers=headers,
payload={"passphrase": BACKUP_PASSPHRASE, "include_cache": True})
check(content.startswith(b"MAGENT-BACKUP\x00\x01"), "Backup is not the encrypted portable format")
check(INTEGRATION_SECRET.encode() not in content, "Backup exposed plaintext integration credentials")
check(response_headers.get("Cache-Control") == "no-store", "Backup download is cacheable")
api("/admin/settings", method="PUT", headers=headers,
payload={"site_login_message": "Changed after backup"})
CACHE_FIXTURE.write_bytes(b"changed after backup")
body, content_type = backup_restore_upload(content, BACKUP_PASSPHRASE)
restored = api("/admin/backups/restore", method="POST", expected=202, raw=body,
headers={**headers, "Content-Type": content_type})
check(restored["restart_required"], "Restore did not require a restart")
status = api("/admin/backups", headers=headers)
check(status["pending_restore"] is not None, "Backup was not staged")
check(CACHE_FIXTURE.read_bytes() == b"changed after backup", "Restore applied before restart")
print("Encrypted config/database/cache backup and authenticated restore staging: PASS")
def fresh_install() -> None:
check(api("/setup/status") == {"setup_required": True, "needs_admin": True},
"Fresh volume did not open authorized first-install setup")
api("/setup/state", expected=401)
api("/admin/settings", expected=401)
token = SETUP_TOKEN
if managed_installation():
# docker exec does not inherit the entrypoint's generated environment:
# the console command must read persistent state, not rely on getenv.
token = subprocess.check_output(
[sys.executable, "-m", "app.container_bootstrap", "setup-token"], text=True,
).strip()
check(len(token) == 64 and token != SETUP_TOKEN, "Managed setup token was not generated")
state_file = Path("/app/data/bootstrap-secrets.json")
check(state_file.stat().st_mode & 0o777 == 0o600, "Managed secrets file is not private")
Path("/app/data/.smoke-managed-digest").write_text(hashlib.sha256(state_file.read_bytes()).hexdigest())
bootstrap = {"setup_token": token, "username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
"application_url": ORIGIN}
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=403,
headers={"Origin": "https://untrusted.example.test"})
api("/setup/bootstrap", method="POST", payload={**bootstrap, "setup_token": "wrong-token"}, expected=403)
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=201)
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=409)
headers = sign_in()
check(api("/setup/state", headers=headers)["step"] == "apps", "Setup did not advance to apps")
updated = api("/admin/settings", method="PUT", headers=headers, payload={
"site_login_message": LOGIN_MESSAGE,
"jellyfin_api_key": INTEGRATION_SECRET,
})
check(updated["updated"] == 2, "Setup configuration was not saved")
api("/setup/state", method="PUT", payload={"step": "review"}, headers=headers)
check(api("/setup/complete", method="POST", headers=headers)["completed"], "Setup did not complete")
check(api("/setup/status") == {"setup_required": False, "needs_admin": False}, "Setup remained public")
check_persisted_settings(headers)
print("Token-authorized setup, local admin login, secure cookies and encrypted settings: PASS")
stage_backup_roundtrip(headers)
def persisted_install() -> None:
check(api("/setup/status") == {"setup_required": False, "needs_admin": False},
"Setup reopened after restart/recreation")
api("/setup/bootstrap", method="POST", payload={
"setup_token": SETUP_TOKEN, "username": "must-not-exist", "password": ADMIN_PASSWORD,
}, expected=409)
headers = sign_in()
check_persisted_settings(headers)
status = api("/admin/backups", headers=headers)
check(status["pending_restore"] is None, "Restore remained pending after restart")
check(status["last_restore"] and status["last_restore"]["status"] == "restored",
"Backup restore did not complete")
check(CACHE_FIXTURE.read_bytes() == CACHE_CONTENT, "Artwork cache was not restored")
if managed_installation():
state_file = Path("/app/data/bootstrap-secrets.json")
check(hashlib.sha256(state_file.read_bytes()).hexdigest()
== Path("/app/data/.smoke-managed-digest").read_text(), "Managed keys changed on restart/restore")
result = subprocess.run([sys.executable, "-m", "app.container_bootstrap", "setup-token"],
capture_output=True, text=True, check=False)
check(result.returncode != 0 and not result.stdout, "Setup token remains available after admin creation")
print("Generated keys persisted unchanged; initial setup token is no longer available: PASS")
print("Persistent setup state, administrator login, encrypted settings and database integrity: PASS")
print("Restored configuration, database and artwork cache: PASS")
def main() -> None:
check(len(sys.argv) == 2 and sys.argv[1] in ("packaging", "fresh", "persisted"),
"Expected packaging, fresh or persisted mode")
if sys.argv[1] == "packaging":
check_packaging()
return
check_runtime()
if sys.argv[1] == "fresh":
fresh_install()
else:
persisted_install()
check_origin_guards()
if __name__ == "__main__":
main()
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
umask 077
mkdir -p '${deploy_path}'
chmod 700 '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
chmod 700 \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
(umask 022; tar -xf - -C '${deploy_path}')
if [ -f '${deploy_path}/.env' ]; then
chmod 600 '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data'
chmod 700 '${deploy_path}/data'
docker compose build
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
settings_key=\"\$(docker compose run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
chmod 600 .env
fi
docker compose run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
docker compose run --rm --user 0 --cap-add CHOWN --cap-add DAC_OVERRIDE magent chown -R 1000:1000 /app/data
docker compose up -d
"
echo "Running remote smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8000/health', 200),
('http://127.0.0.1:3000/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Deployment completed successfully"
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
umask 077
mkdir -p '${deploy_path}'
chmod 700 '${deploy_path}'
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
chmod 700 \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
(umask 022; tar -xf - -C '${deploy_path}')
if [ ! -f '${deploy_path}/.env' ]; then
echo 'Beta .env is missing. Provision independent beta secrets before deploying.' >&2
exit 1
fi
chmod 600 '${deploy_path}/.env'
mkdir -p '${deploy_path}/data'
chmod 700 '${deploy_path}/data'
cd '${deploy_path}'
docker compose -p magent-beta -f docker-compose.beta.yml build
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
settings_key=\"\$(docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
chmod 600 .env
fi
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --user 0 --cap-add CHOWN --cap-add DAC_OVERRIDE magent chown -R 1000:1000 /app/data
docker compose -p magent-beta -f docker-compose.beta.yml up -d
"
echo "Running remote beta smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
import time
from urllib import error, request
checks = [
('http://127.0.0.1:8100/health', 200),
('http://${beta_frontend_bind}:3100/login', 200),
]
# Compose returns before the app is ready. Allow the new processes to start
# instead of reporting a failed deployment on the first connection reset.
deadline = time.monotonic() + 90
for url, expected in checks:
while True:
try:
with request.urlopen(url, timeout=5) as response:
if response.status != expected:
raise OSError(f'HTTP {response.status}, expected {expected}')
print(url, response.status)
break
except (error.URLError, OSError, TimeoutError) as exc:
if time.monotonic() >= deadline:
raise SystemExit(f'Beta did not become ready: {url}: {exc}') from exc
print(f'Waiting for beta to start: {url}', flush=True)
time.sleep(2)
PY
"
echo "Beta deployment completed successfully"
-10
View File
@@ -1,10 +0,0 @@
function Set-EnvBuildNumber {
param(
[AllowEmptyString()][string]$Content,
[Parameter(Mandatory = $true)][string]$BuildNumber
)
if ($BuildNumber -notmatch '^\d+$') { throw 'Build number must contain digits only.' }
$newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" }
$remaining = [regex]::Replace($Content, '(?m)^[\t ]*(?:export[\t ]+)?BUILD_NUMBER[\t ]*=[^\r\n]*(?:\r?\n|$)', '')
return "BUILD_NUMBER=$BuildNumber$newline$remaining"
}
-153
View File
@@ -1,153 +0,0 @@
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
def _normalize_email(value: object) -> str | None:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as handle:
return [dict(row) for row in csv.DictReader(handle)]
def _ensure_email_column(conn: sqlite3.Connection) -> None:
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, username, email
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
rows = _load_rows(csv_path)
username_counts = Counter(
str(row.get("Username") or "").strip().lower()
for row in rows
if str(row.get("Username") or "").strip()
)
duplicate_usernames = {
username for username, count in username_counts.items() if username and count > 1
}
summary: dict[str, object] = {
"csv_path": str(csv_path),
"db_path": str(db_path),
"source_rows": len(rows),
"updated": 0,
"unchanged": 0,
"missing_email": [],
"missing_user": [],
"duplicate_source_username": [],
}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
_ensure_email_column(conn)
for row in rows:
username = str(row.get("Username") or "").strip()
if not username:
continue
username_key = username.lower()
if username_key in duplicate_usernames:
cast_list = summary["duplicate_source_username"]
assert isinstance(cast_list, list)
if username not in cast_list:
cast_list.append(username)
continue
email = _normalize_email(row.get("Email"))
if not email:
cast_list = summary["missing_email"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
matches = _lookup_user(conn, username)
if not matches:
cast_list = summary["missing_user"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
current_emails = {
normalized.lower()
for normalized in (_normalize_email(row["email"]) for row in matches)
if normalized
}
if current_emails == {email.lower()}:
summary["unchanged"] = int(summary["unchanged"]) + 1
continue
conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(email, username),
)
summary["updated"] = int(summary["updated"]) + 1
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
parser.add_argument(
"csv_path",
nargs="?",
default=str(DEFAULT_CSV_PATH),
help="CSV file containing Username and Email columns",
)
parser.add_argument(
"--db-path",
default=str(DEFAULT_DB_PATH),
help="Path to the Magent SQLite database",
)
args = parser.parse_args()
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More