Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f4071c970 | ||
|
|
a6a4a9aa24 | ||
|
|
91a950a3b0 | ||
|
|
2525a9eb25 | ||
|
|
8a6fe71446 | ||
|
|
6ab79efc35 | ||
|
|
f852e7c941 | ||
|
|
5639dbcb83 | ||
|
|
a6d1c73837 | ||
|
|
aed1bf9256 | ||
|
|
05a540ecbb | ||
|
|
d75f36c691 | ||
|
|
3465343a69 | ||
|
|
4ba1a5763e | ||
|
|
dd51332f3c | ||
|
|
6a84e68a03 | ||
|
|
c194db167a | ||
|
|
e232335ca9 | ||
|
|
8df02fdfd7 | ||
|
|
ce756c1a65 | ||
|
|
b3b83bda4f | ||
|
|
0eecab4e0e | ||
|
|
765b0d2033 | ||
|
|
1debf6053c | ||
|
|
d7e5c75cb1 | ||
|
|
dcd8082c8d | ||
|
|
e3332bec1f | ||
|
|
f57058eb26 | ||
|
|
8734f461bb | ||
|
|
835d9c8de3 | ||
|
|
956fb3ecb1 | ||
|
|
4f7853b17b | ||
|
|
de25255ea8 | ||
|
|
38169b881e | ||
|
|
52c85daae3 | ||
|
|
df6fe58278 | ||
|
|
ec0a866ef3 | ||
|
|
e2be8b3872 | ||
|
|
b286ca3c42 | ||
|
|
6e473fd0a7 | ||
|
|
9856c7fb90 | ||
|
|
df651eb312 | ||
|
|
77f2c1b42a | ||
|
|
b310e86f80 | ||
|
|
747a330b19 | ||
|
|
b0f8c89db7 | ||
|
|
e014baadc3 | ||
|
|
1979e02cde | ||
|
|
333a799e21 | ||
|
|
437836243c | ||
|
|
e7e4c9eff3 | ||
|
|
12611a9819 | ||
|
|
2976145dd8 | ||
|
|
a3b5759708 | ||
|
|
13edcb8136 | ||
|
|
131b5fc5c7 | ||
|
|
bd1f2cb1cb | ||
|
|
edca300d27 | ||
|
|
4034a8f72a | ||
|
|
0637860b95 | ||
|
|
697fc235ee | ||
|
|
62ee07f92b | ||
|
|
458ef53f47 | ||
|
|
c2685f43a7 | ||
|
|
98d8b197a9 | ||
|
|
625f9ad7f0 | ||
|
|
bd668715a3 | ||
|
|
212ac560ec | ||
|
|
a32928b1c5 | ||
|
|
74c49fad5b | ||
|
|
009bb35032 | ||
|
|
8d720de500 | ||
|
|
1851fa9753 | ||
|
|
dec1dd902c | ||
|
|
4d67567d4c | ||
|
|
b5e4c57e93 | ||
|
|
f65e1b114c | ||
|
|
32bfa20ab7 | ||
|
|
0b59289a2e | ||
|
|
ded794a819 | ||
|
|
c6d449dc17 | ||
|
|
06d944c9d9 | ||
|
|
0ac53b7f59 | ||
|
|
2dbe11e6bc | ||
|
|
3aac40ba0f | ||
|
|
b6c48a0be7 | ||
|
|
3fc52f70c7 | ||
|
|
6391fbfd81 | ||
|
|
0ed22dd315 | ||
|
|
7ed0f4b103 | ||
|
|
b3c41f6dea | ||
|
|
976d24217b | ||
|
|
c49a149cfd | ||
|
|
5de14b1cb7 | ||
|
|
16876e1cf0 | ||
|
|
c7a56f2525 | ||
|
|
87a4aae246 | ||
|
|
e58614305e | ||
|
|
2adbed7259 | ||
|
|
393b8c2a88 | ||
|
|
b0eff9ffcf | ||
|
|
ae6cee5d0b | ||
|
|
906a777b95 | ||
|
|
ec8145a58a | ||
|
|
f8770cb44a | ||
|
|
0e04d219a0 | ||
|
|
3402e53c31 | ||
|
|
ecf9b230c1 | ||
|
|
8f810e0f36 | ||
|
|
82d87d968e | ||
|
|
372f4a1bfc | ||
|
|
547ed754e6 | ||
|
|
a55369190b | ||
|
|
ee81749b43 | ||
|
|
9dfea25d56 | ||
|
|
963506d098 | ||
|
|
02245d365e | ||
|
|
2cbd9fe73f | ||
|
|
9db32481bd | ||
|
|
3815dfea60 | ||
|
|
c073581639 | ||
|
|
06a000bb06 | ||
|
|
391cd41d71 | ||
|
|
96fc43365f | ||
|
|
655e2f8158 |
@@ -0,0 +1 @@
|
||||
0803262237
|
||||
+17
-41
@@ -1,44 +1,20 @@
|
||||
# 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/
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.venv/
|
||||
**/.pytest_cache/
|
||||
stitch_magent_media_operations_redesign/
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
bootstrap-admin.json
|
||||
release.tar
|
||||
*.log
|
||||
data/*
|
||||
!data/branding/
|
||||
!data/branding/**
|
||||
|
||||
# Defense in depth for accidental private/generated files under allowed paths.
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/__pycache__
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
backend/__pycache__/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
**/*.log
|
||||
**/*.db
|
||||
**/*.db-*
|
||||
**/*.sqlite
|
||||
**/*.sqlite3
|
||||
**/bootstrap-admin.json
|
||||
**/bootstrap-secrets.json
|
||||
**/.magent-secrets-*
|
||||
**/node_modules
|
||||
**/.next
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
+4
-23
@@ -1,37 +1,18 @@
|
||||
# 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.
|
||||
# Copy to .env for local development. Never reuse these example values in a deployed environment.
|
||||
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
|
||||
# Backend address is internal to the combined container, not a browser endpoint.
|
||||
MAGENT_API_URL=http://127.0.0.1:8000
|
||||
MAGENT_API_URL=http://localhost:8000
|
||||
SQLITE_PATH=/app/data/magent.db
|
||||
LOG_FILE=/app/data/magent.log
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Generate independent values as documented in docs/PUBLIC_RELEASE.md.
|
||||
# Keep both unchanged when upgrading or restoring an offline data-volume backup.
|
||||
# Generate independent values as documented in README.md.
|
||||
JWT_SECRET=replace-with-at-least-32-random-characters
|
||||
SETTINGS_ENCRYPTION_KEY=replace-with-a-valid-fernet-key
|
||||
ADMIN_USERNAME=admin
|
||||
# Recommended fresh install: generate a separate random setup token. Open /setup
|
||||
# to create the administrator and connect your apps; remove this after finishing.
|
||||
SETUP_TOKEN=replace-with-a-separate-random-setup-token
|
||||
# Alternatively pre-create the first admin with a unique password (12+ chars).
|
||||
# Leave blank to create the account using the setup wizard and SETUP_TOKEN.
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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
|
||||
+11
-12
@@ -1,10 +1,14 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
bootstrap-admin.json
|
||||
.venv/
|
||||
.security-test-venv*/
|
||||
data/
|
||||
!data/branding/
|
||||
!data/branding/**
|
||||
backend/__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/.pytest_cache/
|
||||
backend/.pytest_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
@@ -12,15 +16,10 @@ frontend/node_modules/
|
||||
frontend/.next/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite*
|
||||
*.magent-backup
|
||||
bootstrap-admin.json
|
||||
bootstrap-secrets.json
|
||||
.magent-secrets-*
|
||||
data/*
|
||||
!data/branding/
|
||||
**/.pytest_cache/
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
+26
-54
@@ -1,12 +1,8 @@
|
||||
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS frontend-builder
|
||||
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 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
|
||||
|
||||
@@ -20,77 +16,53 @@ COPY frontend/next.config.js ./next.config.js
|
||||
COPY frontend/proxy.ts ./proxy.ts
|
||||
COPY frontend/tsconfig.json ./tsconfig.json
|
||||
|
||||
# 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 {} +
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.14-alpine@sha256:016508ba505da24f7139765bc4bb669df4e88eb2f12eeadd571bf2f88d7533df AS runtime
|
||||
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
MAGENT_MANAGED_SECRETS=auto \
|
||||
SQLITE_PATH=/app/data/magent.db \
|
||||
API_DOCS_ENABLED=false \
|
||||
NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
NODE_ENV=production
|
||||
|
||||
# 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
|
||||
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/*
|
||||
|
||||
ARG MAGENT_UID=1000
|
||||
ARG MAGENT_GID=1000
|
||||
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
|
||||
RUN groupadd --gid ${MAGENT_GID} magent \
|
||||
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
|
||||
|
||||
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 backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY --chown=magent:magent backend/app ./app
|
||||
COPY --chown=magent:magent data/branding /app/data/branding
|
||||
|
||||
# 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/.next /app/frontend/.next
|
||||
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 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"
|
||||
COPY --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
||||
|
||||
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 --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 \
|
||||
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 \
|
||||
|| exit 1
|
||||
|
||||
ENTRYPOINT ["python", "-m", "app.container_bootstrap"]
|
||||
CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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).
|
||||
@@ -1,104 +1,226 @@
|
||||
# Magent
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Install
|
||||
## How it works
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
## Core features
|
||||
|
||||
1. Deploy the stack and wait for the container to become healthy.
|
||||
2. In its console, select `/bin/ash` and user `magent`, then run:
|
||||
- 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.
|
||||
|
||||
```sh
|
||||
python -m app.container_bootstrap setup-token
|
||||
## 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
|
||||
```
|
||||
|
||||
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.
|
||||
Then open:
|
||||
|
||||
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.
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend: http://localhost:8000
|
||||
|
||||
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.
|
||||
### Docker setup steps
|
||||
|
||||
## Build and test
|
||||
1) Create `.env` with your service URLs and API keys.
|
||||
2) Run `docker compose up --build`.
|
||||
3) Log in at http://localhost:3000.
|
||||
4) Visit Settings to confirm service health.
|
||||
|
||||
The source tree contains everything needed to build the application:
|
||||
### Docker environment variables (sample)
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yml -f compose.build.yml up -d --build
|
||||
```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"
|
||||
```
|
||||
|
||||
For a disposable verification run, without touching an existing installation:
|
||||
## Screenshots
|
||||
|
||||
```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
|
||||
```
|
||||
Add screenshots here once available:
|
||||
|
||||
Unit checks require Python 3.14 and Node 24:
|
||||
- `docs/screenshots/home.png`
|
||||
- `docs/screenshots/request-timeline.png`
|
||||
- `docs/screenshots/settings.png`
|
||||
- `docs/screenshots/profile.png`
|
||||
|
||||
```sh
|
||||
## Local development (secondary)
|
||||
|
||||
Use this only when you need to modify code locally.
|
||||
|
||||
### Backend (FastAPI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
. .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
|
||||
.\.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
|
||||
cd frontend
|
||||
npm ci
|
||||
npm test
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
On Windows, activate `.venv\Scripts\Activate.ps1` instead. Do not point tests
|
||||
at live services or use production credentials.
|
||||
## Public Hosting Notes
|
||||
|
||||
## How it is organised
|
||||
The frontend proxies `/api/*` to the backend container. Set:
|
||||
|
||||
- `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.
|
||||
- `NEXT_PUBLIC_API_BASE=/api` (browser uses same-origin)
|
||||
- `BACKEND_INTERNAL_URL=http://backend:8000` (container-to-container)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Gitea CI/CD
|
||||
|
||||
## Contributing and security
|
||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
|
||||
Licensed under [MIT](LICENSE). Third-party dependency licences remain applicable.
|
||||
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.
|
||||
- `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.
|
||||
|
||||
## History endpoints
|
||||
|
||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||
- `GET /requests/{id}/actions?limit=10` recent action logs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Login fails
|
||||
|
||||
- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
|
||||
- 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.
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
+1
-8
@@ -5,7 +5,6 @@ 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
|
||||
@@ -48,14 +47,8 @@ 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": secure,
|
||||
"secure": bool(settings.auth_cookie_secure),
|
||||
"httponly": True,
|
||||
"samesite": samesite,
|
||||
"domain": settings.auth_cookie_domain or None,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,9 +15,9 @@ def _availability_message(result: Any) -> str:
|
||||
or (isinstance(items, list) and len(items) > 0)
|
||||
)
|
||||
return (
|
||||
"Jellyfin returned possible matches. Magent still needs to check the exact title and file."
|
||||
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
|
||||
if available
|
||||
else "Jellyfin did not find this title in its library search."
|
||||
else "Grizzlyflix did not find this title in its library search."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class Settings(BaseSettings):
|
||||
)
|
||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN"))
|
||||
auth_cookie_name: str = Field(
|
||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||
)
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""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())
|
||||
@@ -929,10 +929,6 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str
|
||||
def ensure_admin_user() -> None:
|
||||
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
|
||||
return
|
||||
# Environment credentials bootstrap only the first administrator. In
|
||||
# particular, do not inject a destination host's account into a restored DB.
|
||||
if has_admin_user():
|
||||
return
|
||||
existing = get_user_by_username(settings.admin_username)
|
||||
if existing:
|
||||
return
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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
|
||||
+9
-68
@@ -6,8 +6,7 @@ import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exception_handlers import request_validation_exception_handler
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .config import settings
|
||||
@@ -34,10 +33,6 @@ from .routers.insights import router as insights_router
|
||||
from .routers.identities import router as identities_router
|
||||
from .routers.recaps import router as recaps_router
|
||||
from .routers.newsletters import router as newsletters_router
|
||||
from .routers.backups import router as backups_router
|
||||
from .routers.setup import router as setup_router
|
||||
from .services.backups import apply_pending_restore
|
||||
from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .services.issue_resolution import run_issue_confirmation_loop
|
||||
from .services.email_recaps import run_email_recap_loop
|
||||
@@ -57,13 +52,11 @@ from .logging_config import (
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
from .metrics import record_api, start_metrics
|
||||
from .request_limits import InstallationBodyLimitMiddleware
|
||||
from .secret_storage import validate_secret_storage_configuration
|
||||
from .services.request_origins import ConfiguredOriginCORSMiddleware, can_claim_initial_origin, is_allowed_request_origin
|
||||
from .services.request_origins import is_allowed_request_origin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
_background_started = False
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
@@ -73,29 +66,12 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
ConfiguredOriginCORSMiddleware,
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.cors_allow_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def installation_validation_error(request: Request, exc: RequestValidationError):
|
||||
if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
|
||||
# Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
|
||||
# includes rejected raw input. Never echo tokens/passwords/passphrases.
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": [
|
||||
{key: error[key] for key in ("type", "loc", "msg") if key in error}
|
||||
for error in exc.errors()
|
||||
]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -113,11 +89,7 @@ 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 "")
|
||||
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:
|
||||
if origin and not is_allowed_request_origin(origin):
|
||||
record_api(request, 403, 0.0)
|
||||
if operation_id and operation_token is not None:
|
||||
finish_operation(operation_id, success=False, status_code=403)
|
||||
@@ -249,9 +221,9 @@ def _log_security_configuration_warnings() -> None:
|
||||
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
|
||||
)
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if admin_password == "adminadmin":
|
||||
if not admin_password or admin_password == "adminadmin":
|
||||
logger.warning(
|
||||
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
||||
)
|
||||
if bool(settings.api_docs_enabled):
|
||||
logger.warning(
|
||||
@@ -272,11 +244,8 @@ def _enforce_secure_startup_configuration() -> None:
|
||||
_enforce_secret_configuration()
|
||||
admin_password = str(settings.admin_password or "")
|
||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||
if is_setup_required() and setup_token_configured():
|
||||
return
|
||||
raise RuntimeError(
|
||||
"First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
|
||||
"or a secure ADMIN_PASSWORD, until an admin account exists."
|
||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
||||
)
|
||||
|
||||
|
||||
@@ -295,9 +264,6 @@ async def startup() -> None:
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
_enforce_secret_configuration()
|
||||
# Restore offline, before any schema migration, database reader or worker.
|
||||
apply_pending_restore()
|
||||
initialize_setup_state()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
@@ -320,22 +286,9 @@ async def startup() -> None:
|
||||
runtime.log_background_sync_level,
|
||||
runtime.requests_data_source,
|
||||
)
|
||||
app.state.on_setup_complete = _start_background_tasks
|
||||
await _start_background_tasks()
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
async def _start_background_tasks() -> None:
|
||||
global _background_started
|
||||
if _background_started:
|
||||
return
|
||||
if is_setup_required():
|
||||
logger.info("Background imports and automation paused until setup is complete")
|
||||
return
|
||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||
logger.info("Background imports and automation disabled by configuration")
|
||||
logger.info("Background imports and automation paused for initial setup")
|
||||
return
|
||||
_background_started = True
|
||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
||||
_launch_background_task("request-local-stages", run_local_request_stage_loop)
|
||||
@@ -345,17 +298,7 @@ async def _start_background_tasks() -> None:
|
||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||
_launch_background_task("email-recaps", run_email_recap_loop)
|
||||
_launch_background_task("newsletters", run_newsletter_loop)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
global _background_started
|
||||
for task in _background_tasks:
|
||||
task.cancel()
|
||||
if _background_tasks:
|
||||
await asyncio.gather(*_background_tasks, return_exceptions=True)
|
||||
_background_tasks.clear()
|
||||
_background_started = False
|
||||
logger.info("startup complete")
|
||||
|
||||
|
||||
app.include_router(requests_router)
|
||||
@@ -374,5 +317,3 @@ app.include_router(insights_router)
|
||||
app.include_router(identities_router)
|
||||
app.include_router(recaps_router)
|
||||
app.include_router(newsletters_router)
|
||||
app.include_router(backups_router)
|
||||
app.include_router(setup_router)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Bound security-sensitive request bodies before JSON/multipart parsing."""
|
||||
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart
|
||||
# envelope; count streamed chunks as well as checking the untrusted header.
|
||||
RESTORE_BODY_LIMIT = 34 * 1024 * 1024
|
||||
BOOTSTRAP_BODY_LIMIT = 16 * 1024
|
||||
|
||||
|
||||
class InstallationBodyLimitMiddleware:
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http" or scope.get("method") != "POST":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "").rstrip("/")
|
||||
limit = {
|
||||
"/admin/backups/restore": RESTORE_BODY_LIMIT,
|
||||
"/admin/backups/export": BOOTSTRAP_BODY_LIMIT,
|
||||
"/setup/bootstrap": BOOTSTRAP_BODY_LIMIT,
|
||||
}.get(path)
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
headers = dict(scope.get("headers", []))
|
||||
try:
|
||||
length = int(headers.get(b"content-length", b"0"))
|
||||
except ValueError:
|
||||
length = -1
|
||||
if length < 0 or length > limit:
|
||||
await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send)
|
||||
return
|
||||
received = 0
|
||||
|
||||
async def bounded_receive() -> Message:
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > limit:
|
||||
raise HTTPException(status_code=413, detail="Request body is too large.")
|
||||
return message
|
||||
|
||||
await self.app(scope, bounded_receive, send)
|
||||
@@ -680,12 +680,6 @@ 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] = []
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Administrator-only encrypted backup downloads and staged restores."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..db import get_rate_limit_status, record_rate_limit_event
|
||||
from ..services import backups
|
||||
|
||||
def _no_store(response: Response) -> None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/backups", tags=["backups"],
|
||||
dependencies=[Depends(require_admin), Depends(_no_store)],
|
||||
)
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
passphrase: SecretStr = Field(min_length=12, max_length=1024)
|
||||
include_cache: bool = False
|
||||
|
||||
|
||||
def _rate_limit(user: dict) -> None:
|
||||
key = str(user["username"])
|
||||
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
|
||||
if exceeded:
|
||||
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
|
||||
record_rate_limit_event("backups", key)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def status() -> dict:
|
||||
return backups.backup_status()
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
|
||||
_rate_limit(user)
|
||||
try:
|
||||
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return Response(content, media_type="application/octet-stream", headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-store", "Pragma": "no-cache",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/restore", status_code=202)
|
||||
async def restore(
|
||||
file: UploadFile = File(...),
|
||||
passphrase: str = Form(..., min_length=12, max_length=1024),
|
||||
confirmation: Literal["RESTORE"] = Form(...),
|
||||
user: dict = Depends(require_admin),
|
||||
) -> dict:
|
||||
_rate_limit(user)
|
||||
try:
|
||||
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
|
||||
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
await file.close()
|
||||
return {
|
||||
"status": "staged", "restart_required": True, "backup": metadata,
|
||||
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/restore")
|
||||
def cancel() -> dict:
|
||||
try:
|
||||
backups.cancel_restore()
|
||||
except backups.BackupError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
return {"status": "cancelled"}
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Initial install bootstrap and authenticated setup wizard endpoints."""
|
||||
|
||||
from inspect import isawaitable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import Field, SecretStr
|
||||
|
||||
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
|
||||
from ..auth import _extract_client_ip, require_admin
|
||||
from ..services import setup as setup_service
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
step: setup_service.SetupStep
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def public_status(response: Response) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return setup_service.get_public_setup_status()
|
||||
|
||||
|
||||
@router.post("/bootstrap", status_code=201)
|
||||
def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
|
||||
status = setup_service.get_public_setup_status()
|
||||
if not status["needs_admin"]:
|
||||
raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
|
||||
retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many setup attempts. Try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
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(),
|
||||
application_url=application_url,
|
||||
)
|
||||
except setup_service.InvalidSetupTokenError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except setup_service.SetupUnavailableError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"status": "created", "username": payload.username.strip()}
|
||||
|
||||
|
||||
@router.get("/state", dependencies=[Depends(require_admin)])
|
||||
def get_state() -> dict:
|
||||
return setup_service.get_setup_state()
|
||||
|
||||
|
||||
@router.put("/state", dependencies=[Depends(require_admin)])
|
||||
def update_state(payload: SetupProgress) -> dict:
|
||||
return setup_service.update_setup_step(payload.step)
|
||||
|
||||
|
||||
@router.post("/complete", dependencies=[Depends(require_admin)])
|
||||
async def finish_setup(request: Request) -> dict:
|
||||
try:
|
||||
state = setup_service.complete_setup()
|
||||
except setup_service.SetupUnavailableError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# Startup owns worker lifecycle. Its callback must be idempotent so retries
|
||||
# after a network interruption cannot start duplicate import/automation jobs.
|
||||
callback = getattr(request.app.state, "on_setup_complete", None)
|
||||
if callback is not None:
|
||||
result = callback()
|
||||
if isawaitable(result):
|
||||
await result
|
||||
return state
|
||||
@@ -15,7 +15,7 @@ SENSITIVE_SETTING_KEYS = frozenset(
|
||||
"magent_notify_telegram_bot_token", "magent_notify_push_token",
|
||||
"magent_notify_push_user_key", "magent_notify_webhook_url", "jellyseerr_api_key",
|
||||
"jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "bazarr_api_key",
|
||||
"prowlarr_api_key", "qbittorrent_password", "discord_webhook_url",
|
||||
"prowlarr_api_key", "qbittorrent_password",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
"""Encrypted, portable backups and restart-only SQLite restores.
|
||||
|
||||
Restore is deliberately a two-step operation: the authenticated request validates
|
||||
and stages it, then a single backend process applies it before opening the DB.
|
||||
A durable journal and a private rollback copy protect interrupted installations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, BinaryIO, Iterator
|
||||
import uuid
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from ..config import Settings, settings
|
||||
from ..db import _db_path
|
||||
from ..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
|
||||
|
||||
FORMAT_VERSION = 1
|
||||
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
|
||||
MAX_ENTRIES = 20_000
|
||||
MAGIC = b"MAGENT-BACKUP\x00\x01"
|
||||
_LOCK = threading.Lock()
|
||||
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||
# Host identity, process controls and local file locations belong to the target.
|
||||
_LOCAL_FIELDS = {
|
||||
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
|
||||
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
|
||||
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
|
||||
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
|
||||
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
|
||||
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
|
||||
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
|
||||
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
|
||||
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
|
||||
}
|
||||
|
||||
|
||||
class BackupError(ValueError):
|
||||
"""A safe-to-display backup validation or state error."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _assets_root() -> Path:
|
||||
# Matches the image and branding routers, independently of SQLITE_PATH.
|
||||
return Path.cwd() / "data"
|
||||
|
||||
|
||||
def _control_root() -> Path:
|
||||
return Path(_db_path()).absolute().parent / "backups"
|
||||
|
||||
|
||||
def _private_dir(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise BackupError("Backup directories must not be symbolic links")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def _write_private(path: Path, content: bytes) -> None:
|
||||
with path.open("xb") as handle:
|
||||
path.chmod(0o600)
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
|
||||
try:
|
||||
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
|
||||
os.replace(temporary, path)
|
||||
_sync_directory(path.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _sync_directory(path: Path) -> None:
|
||||
if os.name != "nt":
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sync_tree(path: Path) -> None:
|
||||
for parent, _directories, files in os.walk(path, topdown=False):
|
||||
for filename in files:
|
||||
with (Path(parent) / filename).open("r+b") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
_sync_directory(Path(parent))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_operation() -> Iterator[None]:
|
||||
if not _LOCK.acquire(blocking=False):
|
||||
raise BackupError("Another backup or restore operation is in progress")
|
||||
handle = None
|
||||
locked = False
|
||||
try:
|
||||
root = _control_root()
|
||||
_private_dir(root)
|
||||
handle = (root / "operation.lock").open("a+b")
|
||||
os.chmod(handle.name, 0o600)
|
||||
# OS locks are released even if a process crashes; support the dev host too.
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
handle.seek(0)
|
||||
if not handle.read(1):
|
||||
handle.write(b"0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
try:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError as exc:
|
||||
raise BackupError("Another backup or restore operation is in progress") from exc
|
||||
else:
|
||||
import fcntl
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
raise BackupError("Another backup or restore operation is in progress") from exc
|
||||
locked = True
|
||||
yield
|
||||
finally:
|
||||
if handle is not None:
|
||||
if locked:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
_LOCK.release()
|
||||
|
||||
|
||||
def validate_passphrase(passphrase: str) -> None:
|
||||
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
|
||||
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
|
||||
|
||||
|
||||
def _key(passphrase: str, salt: bytes) -> bytes:
|
||||
validate_passphrase(passphrase)
|
||||
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
|
||||
|
||||
|
||||
def _encrypt(content: bytes, passphrase: str) -> bytes:
|
||||
salt, nonce = os.urandom(16), os.urandom(12)
|
||||
header = MAGIC + salt + nonce
|
||||
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
|
||||
|
||||
|
||||
def _decrypt(content: bytes, passphrase: str) -> bytes:
|
||||
header_size = len(MAGIC) + 28
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise BackupError("Backup exceeds the 32 MiB upload limit")
|
||||
if len(content) < header_size + 16 or not content.startswith(MAGIC):
|
||||
raise BackupError("This is not a supported encrypted Magent backup")
|
||||
salt = content[len(MAGIC):len(MAGIC) + 16]
|
||||
nonce = content[len(MAGIC) + 16:header_size]
|
||||
try:
|
||||
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
|
||||
except InvalidTag as exc:
|
||||
raise BackupError("Incorrect passphrase or damaged backup") from exc
|
||||
|
||||
|
||||
def _database_copy(source: Path, destination: Path) -> None:
|
||||
if not source.is_file() or source.is_symlink():
|
||||
raise BackupError("The configured database is unavailable or is a symbolic link")
|
||||
deadline = time.monotonic() + 60
|
||||
|
||||
def progress(_status: int, _remaining: int, _total: int) -> None:
|
||||
if time.monotonic() > deadline:
|
||||
raise BackupError("Database is too busy to back up; try again shortly")
|
||||
|
||||
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
|
||||
with closing(sqlite3.connect(destination)) as dst:
|
||||
destination.chmod(0o600)
|
||||
src.backup(dst, pages=256, progress=progress, sleep=0.05)
|
||||
dst.execute("PRAGMA journal_mode=DELETE")
|
||||
|
||||
|
||||
def _portable_database(path: Path) -> None:
|
||||
"""Materialize env-backed settings and remove source-specific encryption."""
|
||||
with closing(sqlite3.connect(path)) as conn, conn:
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
# init_db recreates application-owned triggers after restoration; never
|
||||
# distribute executable schema objects in a data backup.
|
||||
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
|
||||
quoted = str(trigger).replace('"', '""')
|
||||
conn.execute(f'DROP TRIGGER "{quoted}"')
|
||||
overrides = dict(conn.execute("SELECT key, value FROM settings"))
|
||||
for key, default in settings.model_dump().items():
|
||||
if key in _LOCAL_FIELDS:
|
||||
continue
|
||||
value = overrides.get(key)
|
||||
value = default if value is None else decrypt_setting_value(key, value)
|
||||
conn.execute(
|
||||
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
|
||||
(key, "" if value is None else str(value), _now()),
|
||||
)
|
||||
for key in _LOCAL_FIELDS:
|
||||
conn.execute("DELETE FROM settings WHERE key=?", (key,))
|
||||
# Future secret keys may not yet be exposed through Settings.
|
||||
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
|
||||
if key in SENSITIVE_SETTING_KEYS:
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
|
||||
conn.commit()
|
||||
conn.execute("VACUUM")
|
||||
|
||||
|
||||
def _asset_allowed(name: str, include_cache: bool) -> bool:
|
||||
parts = PurePosixPath(name).parts
|
||||
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
|
||||
return True
|
||||
return bool(
|
||||
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
|
||||
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
|
||||
and parts[4] not in {".", ".."}
|
||||
)
|
||||
|
||||
|
||||
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
|
||||
root = _assets_root()
|
||||
for directory in ("branding", "artwork") if include_cache else ("branding",):
|
||||
base = root / directory
|
||||
if not base.exists():
|
||||
continue
|
||||
if base.is_symlink() or root.is_symlink():
|
||||
raise BackupError("Asset directories must not be symbolic links")
|
||||
for parent, directories, files in os.walk(base, followlinks=False):
|
||||
if any((Path(parent) / name).is_symlink() for name in directories + files):
|
||||
raise BackupError("Symbolic links are not supported in backup assets")
|
||||
for filename in files:
|
||||
path = Path(parent) / filename
|
||||
archive_name = "files/" + path.relative_to(root).as_posix()
|
||||
if _asset_allowed(archive_name, include_cache):
|
||||
yield path, archive_name
|
||||
|
||||
|
||||
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
|
||||
validate_passphrase(passphrase)
|
||||
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
|
||||
directory = Path(temporary)
|
||||
directory.chmod(0o700)
|
||||
database = directory / "database.sqlite3"
|
||||
_database_copy(Path(_db_path()).absolute(), database)
|
||||
_portable_database(database)
|
||||
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
|
||||
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Backup is too large; retry without the artwork cache")
|
||||
archive_path = directory / "payload.zip"
|
||||
manifest = {
|
||||
"format_version": FORMAT_VERSION, "created_at": _now(),
|
||||
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
|
||||
"files": {},
|
||||
}
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
archive_path.chmod(0o600)
|
||||
total = 0
|
||||
for path, name in files:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with path.open("rb") as source, archive.open(name, "w") as destination:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
size += len(chunk)
|
||||
if total > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Backup is too large; retry without the artwork cache")
|
||||
digest.update(chunk)
|
||||
destination.write(chunk)
|
||||
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
|
||||
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
|
||||
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return encrypted, f"magent-backup-{stamp}.magent-backup"
|
||||
|
||||
|
||||
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
|
||||
try:
|
||||
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
|
||||
conn.execute("PRAGMA trusted_schema=OFF")
|
||||
deadline = time.monotonic() + 30
|
||||
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
|
||||
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
|
||||
raise BackupError("Backup database failed its integrity check")
|
||||
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
|
||||
if len(schema) > 500 or any(
|
||||
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
|
||||
for kind, _name, sql in schema
|
||||
):
|
||||
raise BackupError("Backup contains an unsupported database schema")
|
||||
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||
raise BackupError("Backup database contains broken references")
|
||||
required = {
|
||||
"settings": {"key", "value", "updated_at"},
|
||||
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
|
||||
"signup_invites": {"id", "code", "enabled"},
|
||||
"requests_cache": {"request_id", "payload_json"},
|
||||
"schema_migrations": {"version", "name", "applied_at"},
|
||||
"password_reset_tokens": {"id", "token_hash"},
|
||||
}
|
||||
for table, fields in required.items():
|
||||
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
|
||||
if not fields <= columns:
|
||||
raise BackupError("Backup does not contain a compatible Magent database")
|
||||
optional = {
|
||||
"installation_setup": {"id", "completed", "step", "completed_at"},
|
||||
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
|
||||
}
|
||||
table_names = {name for kind, name, _sql in schema if kind == "table"}
|
||||
for table, fields in optional.items():
|
||||
if table in table_names:
|
||||
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
|
||||
if not fields <= columns:
|
||||
raise BackupError("Backup setup state has an incompatible schema")
|
||||
# An admin can stage a restore only after target initialization. Its
|
||||
# schema is a trusted reference for *all* runtime columns, including
|
||||
# versioned migrations that init_db will not rerun on a restored DB.
|
||||
target = Path(_db_path()).absolute()
|
||||
if target.is_file() and target != path:
|
||||
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
|
||||
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
for table in tables:
|
||||
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
|
||||
continue
|
||||
quoted = str(table).replace('"', '""')
|
||||
expected = {
|
||||
row[1]: (row[2].upper(), bool(row[3]), row[5])
|
||||
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
|
||||
}
|
||||
actual = {
|
||||
row[1]: (row[2].upper(), bool(row[3]), row[5])
|
||||
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
|
||||
}
|
||||
if expected != actual:
|
||||
raise BackupError("Backup is missing database columns required by this installation")
|
||||
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
|
||||
if versions != {migration.version for migration in MIGRATIONS}:
|
||||
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
|
||||
).fetchone():
|
||||
raise BackupError("Backup must contain an active administrator account")
|
||||
values = dict(conn.execute("SELECT key,value FROM settings"))
|
||||
if _LOCAL_FIELDS.intersection(values):
|
||||
raise BackupError("Backup contains host-specific configuration")
|
||||
# Pydantic checks the types of portable settings without reading env values.
|
||||
for key, value in values.items():
|
||||
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
|
||||
value = decrypt_setting_value(key, value)
|
||||
if key in Settings.model_fields and value not in {None, ""}:
|
||||
field = Settings.model_fields[key]
|
||||
TypeAdapter(field.rebuild_annotation()).validate_python(value)
|
||||
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
|
||||
if isinstance(exc, BackupError):
|
||||
raise
|
||||
raise BackupError("Backup database or configuration is invalid") from exc
|
||||
|
||||
|
||||
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
entries = archive.infolist()
|
||||
if not entries or len(entries) > MAX_ENTRIES:
|
||||
raise BackupError("Backup contains too many files")
|
||||
names = [entry.filename for entry in entries]
|
||||
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
|
||||
raise BackupError("Backup manifest is missing or contains duplicate files")
|
||||
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Expanded backup exceeds the 128 MiB limit")
|
||||
for entry in entries:
|
||||
parts = PurePosixPath(entry.filename).parts
|
||||
mode = entry.external_attr >> 16
|
||||
if (
|
||||
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
|
||||
or str(PurePosixPath(entry.filename)) != entry.filename
|
||||
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
|
||||
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
|
||||
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
|
||||
):
|
||||
raise BackupError("Backup contains an unsafe archive entry")
|
||||
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
|
||||
raise BackupError("Backup manifest is too large")
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if (
|
||||
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
|
||||
or not isinstance(manifest.get("include_cache"), bool)
|
||||
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
|
||||
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
|
||||
or not isinstance(manifest.get("files"), dict)
|
||||
or set(manifest["files"]) != set(names) - {"manifest.json"}
|
||||
):
|
||||
raise BackupError("Backup manifest is invalid or unsupported")
|
||||
extracted_bytes = 0
|
||||
for entry in entries:
|
||||
name = entry.filename
|
||||
if name == "manifest.json":
|
||||
continue
|
||||
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
|
||||
raise BackupError("Backup contains an unsupported file")
|
||||
expected = manifest["files"][name]
|
||||
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
|
||||
raise BackupError("Backup file does not match its manifest")
|
||||
target = directory.joinpath(*PurePosixPath(name).parts)
|
||||
_private_dir(target.parent)
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(entry) as source, target.open("xb") as destination:
|
||||
target.chmod(0o600)
|
||||
while chunk := source.read(1024 * 1024):
|
||||
extracted_bytes += len(chunk)
|
||||
if extracted_bytes > MAX_EXPANDED_BYTES:
|
||||
raise BackupError("Expanded backup exceeds the 128 MiB limit")
|
||||
digest.update(chunk)
|
||||
destination.write(chunk)
|
||||
destination.flush()
|
||||
os.fsync(destination.fileno())
|
||||
if digest.hexdigest() != expected.get("sha256"):
|
||||
raise BackupError("Backup file failed its checksum")
|
||||
_validate_database(directory / "database.sqlite3")
|
||||
return manifest
|
||||
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
|
||||
if isinstance(exc, BackupError):
|
||||
raise
|
||||
raise BackupError("Backup archive is invalid or damaged") from exc
|
||||
|
||||
|
||||
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
|
||||
validate_passphrase(passphrase)
|
||||
with _exclusive_operation():
|
||||
root = _control_root()
|
||||
pending = root / "pending"
|
||||
if pending.exists():
|
||||
raise BackupError("A restore is already staged; cancel it before uploading another")
|
||||
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
|
||||
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)
|
||||
manifest = _extract_archive(payload, stage)
|
||||
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
|
||||
if key in SENSITIVE_SETTING_KEYS:
|
||||
if value and str(value).startswith("enc:v1:"):
|
||||
raise BackupError("Backup settings are not portable")
|
||||
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
|
||||
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,))
|
||||
if not manifest["include_cache"]:
|
||||
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
|
||||
conn.commit()
|
||||
# Remove plaintext secret remnants from replaced/free SQLite pages.
|
||||
conn.execute("VACUUM")
|
||||
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
|
||||
metadata["staged_at"] = _now()
|
||||
_write_json(stage / "metadata.json", metadata)
|
||||
# Stage survives reboot; it contains only secrets encrypted for this installation.
|
||||
os.replace(stage, pending)
|
||||
_sync_directory(root)
|
||||
return metadata
|
||||
|
||||
|
||||
def backup_status() -> dict[str, Any]:
|
||||
root = _control_root()
|
||||
pending_path = root / "pending" / "metadata.json"
|
||||
last_path = root / "last-restore.json"
|
||||
return {
|
||||
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
|
||||
"max_expanded_bytes": MAX_EXPANDED_BYTES,
|
||||
"include_cache_default": False,
|
||||
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
|
||||
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
|
||||
}
|
||||
|
||||
|
||||
def cancel_restore() -> None:
|
||||
with _exclusive_operation():
|
||||
pending = _control_root() / "pending"
|
||||
if pending.is_symlink():
|
||||
raise BackupError("Invalid staged restore directory")
|
||||
if pending.exists():
|
||||
shutil.rmtree(pending)
|
||||
|
||||
|
||||
def _replace_file(source: Path, target: Path) -> None:
|
||||
_private_dir(target.parent)
|
||||
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
|
||||
try:
|
||||
shutil.copyfile(source, temporary)
|
||||
temporary.chmod(0o600)
|
||||
with temporary.open("r+b") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, target)
|
||||
_sync_directory(target.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _replace_assets(source: Path, target: Path) -> None:
|
||||
if target.is_symlink():
|
||||
raise BackupError("Asset directories must not be symbolic links")
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
if source.exists():
|
||||
shutil.copytree(source, target, copy_function=shutil.copyfile)
|
||||
for parent, _directories, files in os.walk(target):
|
||||
Path(parent).chmod(0o700)
|
||||
for filename in files:
|
||||
(Path(parent) / filename).chmod(0o600)
|
||||
_sync_tree(target)
|
||||
if target.parent.exists():
|
||||
_sync_directory(target.parent)
|
||||
|
||||
|
||||
def _recover(journal: dict, root: Path) -> None:
|
||||
rollback_name = journal.get("rollback_directory", "")
|
||||
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
|
||||
raise BackupError("Restore recovery journal is invalid")
|
||||
rollback = root / rollback_name
|
||||
database = Path(_db_path()).absolute()
|
||||
if journal["had_database"]:
|
||||
_replace_file(rollback / "database.sqlite3", database)
|
||||
else:
|
||||
database.unlink(missing_ok=True)
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
Path(str(database) + suffix).unlink(missing_ok=True)
|
||||
for name in journal["asset_roots"]:
|
||||
if name not in {"branding", "artwork"}:
|
||||
raise BackupError("Restore recovery journal is invalid")
|
||||
_replace_assets(rollback / "files" / name, _assets_root() / name)
|
||||
_write_json(root / "last-restore.json", {
|
||||
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
|
||||
"message": "An interrupted or failed restore was rolled back automatically.",
|
||||
})
|
||||
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
|
||||
pending = root / "pending"
|
||||
if pending.exists():
|
||||
shutil.rmtree(pending)
|
||||
(root / "restore-journal.json").unlink()
|
||||
_sync_directory(root)
|
||||
|
||||
|
||||
def apply_pending_restore() -> bool:
|
||||
"""Call once before init_db, with no other backend processes using the DB."""
|
||||
with _exclusive_operation():
|
||||
root = _control_root()
|
||||
journal_path = root / "restore-journal.json"
|
||||
if journal_path.exists():
|
||||
journal = json.loads(journal_path.read_text())
|
||||
if journal.get("phase") in {"complete", "rolled_back"}:
|
||||
if (root / "pending").exists():
|
||||
shutil.rmtree(root / "pending")
|
||||
journal_path.unlink()
|
||||
_sync_directory(root)
|
||||
return journal["phase"] == "complete"
|
||||
_recover(journal, root)
|
||||
return False
|
||||
pending = root / "pending"
|
||||
if not pending.exists():
|
||||
return False
|
||||
if pending.is_symlink():
|
||||
raise BackupError("Invalid staged restore directory")
|
||||
metadata = json.loads((pending / "metadata.json").read_text())
|
||||
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
|
||||
database = Path(_db_path()).absolute()
|
||||
rollback = root / ("rollback-" + uuid.uuid4().hex)
|
||||
_private_dir(rollback)
|
||||
# Ensure all disk-space/permission failures in backup happen before replacement.
|
||||
if database.exists():
|
||||
_database_copy(database, rollback / "database.sqlite3")
|
||||
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
|
||||
# Reject links anywhere before copying or deleting the controlled asset trees.
|
||||
list(_asset_files(metadata["include_cache"]))
|
||||
for name in names:
|
||||
source = _assets_root() / name
|
||||
if source.exists():
|
||||
shutil.copytree(source, rollback / "files" / name)
|
||||
_sync_tree(rollback)
|
||||
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
|
||||
_write_json(journal_path, journal)
|
||||
try:
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
Path(str(database) + suffix).unlink(missing_ok=True)
|
||||
_replace_file(pending / "database.sqlite3", database)
|
||||
for name in names:
|
||||
_replace_assets(pending / "files" / name, _assets_root() / name)
|
||||
_write_json(root / "last-restore.json", {
|
||||
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
|
||||
"backup_created_at": metadata["created_at"],
|
||||
})
|
||||
_write_json(journal_path, {**journal, "phase": "complete"})
|
||||
except Exception:
|
||||
_recover(journal, root)
|
||||
raise
|
||||
shutil.rmtree(pending)
|
||||
journal_path.unlink()
|
||||
_sync_directory(root)
|
||||
return True
|
||||
@@ -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? Magent issue #{item['id']}"
|
||||
subject = f"Ready to try again? Grizzlyflix 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 Jellyfin. Is it fixed?\n\n"
|
||||
"Please try the affected content in Grizzlyflix. 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;">MAGENT</p>'
|
||||
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · 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>'
|
||||
|
||||
@@ -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 your media library."
|
||||
return {'subject': 'Confirm your Magent newsletter subscription',
|
||||
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from Grizzlyflix."
|
||||
return {'subject': 'Confirm your Grizzlyflix 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 IN YOUR LIBRARY',
|
||||
action='Confirm newsletter subscription', url=url, kicker='NEW ON GRIZZLYFLIX',
|
||||
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 Jellyfin ↗</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 Grizzlyflix ↗</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 your media library.</p>')
|
||||
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in Grizzlyflix.</p>')
|
||||
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
|
||||
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>'
|
||||
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>'
|
||||
subject = ('[Test] ' if test else '') + content['subject']
|
||||
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Jellyfin: {playback_url}', '',
|
||||
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Grizzlyflix: {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='What’s new in your library',
|
||||
'body_html': document(title='What’s new on Grizzlyflix',
|
||||
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 Jellyfin', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
|
||||
content=''.join(body), action='Explore Grizzlyflix', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
|
||||
'inline_images': attachments}
|
||||
|
||||
@@ -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"What’s new in your library · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
|
||||
(identity, f"What’s new on Grizzlyflix · {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:
|
||||
|
||||
@@ -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"What’s new in your library · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
|
||||
return store.create_edition(content, f"What’s new on Grizzlyflix · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
|
||||
|
||||
|
||||
def require_edition(identity, revision=None):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""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):
|
||||
@@ -22,8 +21,6 @@ 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():
|
||||
|
||||
@@ -6,10 +6,8 @@ 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
|
||||
|
||||
|
||||
@@ -38,26 +36,6 @@ 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)
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"""Persistent, operator-authorized first-install setup.
|
||||
|
||||
Initialize the marker before the main schema: an existing users table identifies
|
||||
an upgraded installation, while a new database must finish the setup wizard.
|
||||
The marker and first administrator are protected by SQLite write transactions.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
from math import ceil
|
||||
from time import time
|
||||
from typing import Literal
|
||||
|
||||
from .. import db
|
||||
from ..config import settings
|
||||
from ..security import hash_password, validate_password_policy
|
||||
from ..installation_origin import normalize_application_origin
|
||||
|
||||
|
||||
SetupStep = Literal["administrator", "apps", "preferences", "review"]
|
||||
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
|
||||
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
|
||||
BOOTSTRAP_IP_ATTEMPTS = 5
|
||||
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
|
||||
|
||||
|
||||
class SetupUnavailableError(ValueError):
|
||||
"""Setup has finished, or another administrator already exists."""
|
||||
|
||||
|
||||
class InvalidSetupTokenError(ValueError):
|
||||
"""The operator's setup token was absent or did not match."""
|
||||
|
||||
|
||||
def initialize_setup_state() -> None:
|
||||
"""Run once before init_db; subsequent calls preserve progress."""
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
existing_install = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
|
||||
).fetchone() is not None
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS installation_setup (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
|
||||
step TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
|
||||
scope TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
|
||||
VALUES (1, ?, ?, ?)""",
|
||||
(
|
||||
int(existing_install),
|
||||
"review" if existing_install else "administrator",
|
||||
datetime.now(timezone.utc).isoformat() if existing_install else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_setup_state() -> dict:
|
||||
with db._connect() as conn:
|
||||
# Old databases and isolated callers without startup initialization are
|
||||
# already installed. A missing marker must never open public bootstrap.
|
||||
table = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
|
||||
).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
|
||||
).fetchone() if table else None
|
||||
if row is None:
|
||||
return {"completed": True, "step": "review", "completed_at": None}
|
||||
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
|
||||
|
||||
|
||||
def is_setup_required() -> bool:
|
||||
return not get_setup_state()["completed"]
|
||||
|
||||
|
||||
def get_public_setup_status() -> dict:
|
||||
required = is_setup_required()
|
||||
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
|
||||
|
||||
|
||||
def setup_token_configured() -> bool:
|
||||
"""Reject missing values and obvious examples, without claiming to measure entropy."""
|
||||
token = str(getattr(settings, "setup_token", "") or "").strip()
|
||||
placeholder = token.casefold().replace("_", "-")
|
||||
return (
|
||||
len(token) >= 32
|
||||
and len(set(token)) > 1
|
||||
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
|
||||
)
|
||||
|
||||
|
||||
def consume_bootstrap_attempt(client_ip: str) -> int | None:
|
||||
"""Atomically reserve one attempt; return Retry-After when limited.
|
||||
|
||||
The IP is keyed using the existing HMAC helper, never stored in clear text.
|
||||
A shared cap limits distributed attempts and expensive password hashing.
|
||||
"""
|
||||
now = time()
|
||||
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
|
||||
limits = (
|
||||
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
|
||||
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
|
||||
)
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
retry_after = 0
|
||||
for scope, key, maximum in limits:
|
||||
count, oldest = conn.execute(
|
||||
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
|
||||
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
|
||||
(scope, key, cutoff),
|
||||
).fetchone()
|
||||
if count >= maximum:
|
||||
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
|
||||
if retry_after:
|
||||
return retry_after
|
||||
conn.executemany(
|
||||
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
|
||||
[(scope, key, now) for scope, key, _ in limits],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def bootstrap_administrator(setup_token: str, username: str, password: str, *, 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(
|
||||
setup_token.encode("utf-8"), expected.encode("utf-8")
|
||||
):
|
||||
raise InvalidSetupTokenError("Invalid setup token.")
|
||||
username = username.strip()
|
||||
if not username or len(username) > 100 or any(
|
||||
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
|
||||
):
|
||||
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
|
||||
if len(password) > 1024:
|
||||
raise ValueError("Password must contain no more than 1024 characters.")
|
||||
password = validate_password_policy(password)
|
||||
if 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.")
|
||||
|
||||
password_hash = hash_password(password)
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
|
||||
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
|
||||
if setup is None or setup[0] or admin:
|
||||
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
||||
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
|
||||
raise SetupUnavailableError("That username already exists.")
|
||||
conn.execute(
|
||||
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
|
||||
VALUES (?, ?, 'admin', 'local', ?)""",
|
||||
(username, password_hash, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
|
||||
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:
|
||||
if step not in SETUP_STEPS:
|
||||
raise ValueError("Invalid setup step.")
|
||||
if not is_setup_required():
|
||||
return get_setup_state()
|
||||
with db._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
|
||||
)
|
||||
return get_setup_state()
|
||||
|
||||
|
||||
def complete_setup() -> dict:
|
||||
if not is_setup_required():
|
||||
return get_setup_state()
|
||||
with db._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
|
||||
raise SetupUnavailableError("Create an administrator before completing setup.")
|
||||
conn.execute(
|
||||
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
|
||||
WHERE id = 1 AND completed = 0""",
|
||||
(datetime.now(timezone.utc).isoformat(),),
|
||||
)
|
||||
return get_setup_state()
|
||||
@@ -582,9 +582,9 @@ def _build_repair_activity(
|
||||
message = (
|
||||
f"{collector} now reports the replacement file as collected. "
|
||||
+ (
|
||||
"It is also available in Jellyfin."
|
||||
"It is also available in Grizzlyflix."
|
||||
if jellyfin_found
|
||||
else "Jellyfin is indexing the updated file now."
|
||||
else "Grizzlyflix 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 Jellyfin."
|
||||
"The repaired title is available in Grizzlyflix."
|
||||
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 Jellyfin."
|
||||
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
|
||||
elif jellyfin_found:
|
||||
available_label = "Available to watch"
|
||||
available_state = "complete"
|
||||
available_state_label = "Ready"
|
||||
available_summary = "This title is ready to watch in Jellyfin."
|
||||
available_summary = "This title is ready to watch in Grizzlyflix."
|
||||
elif arr_state == "available":
|
||||
available_label = "Adding to Jellyfin"
|
||||
available_label = "Adding to Grizzlyflix"
|
||||
available_state = "active"
|
||||
available_state_label = "Indexing"
|
||||
available_summary = "The download is complete. Jellyfin is indexing this title now."
|
||||
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
|
||||
else:
|
||||
available_label = "Media server"
|
||||
available_state = "waiting"
|
||||
available_state_label = "Waiting"
|
||||
available_summary = "This title has not reached Jellyfin yet."
|
||||
available_summary = "This title has not reached Grizzlyflix 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 Jellyfin"
|
||||
meaning = "The replacement has been imported. Waiting for Jellyfin to index the updated file."
|
||||
label = "Replacement collected — updating Grizzlyflix"
|
||||
meaning = "The replacement has been imported. Waiting for Grizzlyflix 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 Jellyfin", state="active", stateLabel="Indexing", summary=meaning)
|
||||
pipeline["available"].update(label="Updating Grizzlyflix", state="active", stateLabel="Indexing", summary=meaning)
|
||||
snapshot.presentation["nextStep"] = {
|
||||
"title": "Wait for the updated file", "description": "This page will update when Jellyfin confirms the replacement.", "actionIds": [],
|
||||
"title": "Wait for the updated file", "description": "This page will update when Grizzlyflix 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 Jellyfin 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 Grizzlyflix to index the repaired episodes.", link=link)
|
||||
snapshot.raw["jellyfin"].update(partial=True, link=link)
|
||||
|
||||
|
||||
|
||||
@@ -448,7 +448,7 @@ class OperationMessageTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
_availability_message({"TotalRecordCount": 0, "Items": []}),
|
||||
"Jellyfin did not find this title in its library search.",
|
||||
"Grizzlyflix 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 Jellyfin.")
|
||||
self.assertEqual(available_stage["summary"], "This title is ready to watch in Grizzlyflix.")
|
||||
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 Jellyfin")
|
||||
self.assertEqual(available_stage["label"], "Adding to Grizzlyflix")
|
||||
self.assertEqual(
|
||||
available_stage["summary"],
|
||||
"The download is complete. Jellyfin is indexing this title now.",
|
||||
"The download is complete. Grizzlyflix is indexing this title now.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
from contextlib import closing
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.auth import get_current_user
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import backups as backup_router
|
||||
from backend.app.services import backups
|
||||
|
||||
|
||||
PASSPHRASE = "test backup passphrase with spaces"
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.database = self.root / "magent.db"
|
||||
for key, value in {
|
||||
"sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
|
||||
"settings_encryption_key": Fernet.generate_key().decode(),
|
||||
"jwt_secret": "source-installation-signing-secret-for-backup-tests",
|
||||
"admin_username": "backup-admin", "admin_password": "a secure initial password",
|
||||
"jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
|
||||
"discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
|
||||
}.items():
|
||||
context = patch.object(settings, key, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
db.init_db()
|
||||
db.set_setting("sonarr_api_key", "database-integration-secret")
|
||||
db.set_setting("site_login_message", "Restored configuration")
|
||||
db.set_setting("installation_setup", "complete")
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
|
||||
conn.execute(
|
||||
"INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
|
||||
)
|
||||
self.assets = self.root / "assets"
|
||||
(self.assets / "branding").mkdir(parents=True)
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
|
||||
(self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
|
||||
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
|
||||
|
||||
def export(self, include_cache=True):
|
||||
content, filename = backups.create_backup(PASSPHRASE, include_cache)
|
||||
self.assertTrue(filename.endswith(".magent-backup"))
|
||||
return content
|
||||
|
||||
def rewrite_archive(self, content, change):
|
||||
decrypted = backups._decrypt(content, PASSPHRASE)
|
||||
with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
|
||||
files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
|
||||
change(files)
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
for name, value in files.items():
|
||||
archive.writestr(name, value)
|
||||
return backups._encrypt(output.getvalue(), PASSPHRASE)
|
||||
|
||||
def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
|
||||
content = self.export()
|
||||
self.assertNotIn(b"database-integration-secret", content)
|
||||
self.assertNotIn(b"environment-integration-secret", content)
|
||||
original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
|
||||
db.set_setting("site_login_message", "Live data before restart")
|
||||
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||
settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
|
||||
# Simulate a different host with different env-backed integration settings.
|
||||
settings.jellyfin_api_key = "destination-env-value"
|
||||
metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(metadata["include_cache"])
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
|
||||
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||
staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
|
||||
self.assertNotIn(b"database-integration-secret", staged_bytes)
|
||||
self.assertNotIn(b"environment-integration-secret", staged_bytes)
|
||||
self.assertNotIn(b"legacy-private-token", staged_bytes)
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
|
||||
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
|
||||
self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
|
||||
self.assertEqual(db.get_setting("installation_setup"), "complete")
|
||||
self.assertIsNone(db.get_setting("setup_token"))
|
||||
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
|
||||
self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
|
||||
self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
|
||||
self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
|
||||
self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
|
||||
status = backups.backup_status()
|
||||
self.assertIsNone(status["pending_restore"])
|
||||
self.assertEqual(status["last_restore"]["status"], "restored")
|
||||
self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
|
||||
def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
|
||||
with closing(sqlite3.connect(self.database)) as writer:
|
||||
writer.execute("PRAGMA journal_mode=WAL")
|
||||
writer.execute("PRAGMA wal_autocheckpoint=0")
|
||||
writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
|
||||
writer.commit()
|
||||
self.assertTrue(Path(str(self.database) + "-wal").exists())
|
||||
content = self.export()
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
self.assertTrue(backups.apply_pending_restore())
|
||||
with closing(sqlite3.connect(self.database)) as restored:
|
||||
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
|
||||
|
||||
def test_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
|
||||
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Value before interrupted restart")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
|
||||
with self.assertRaises(ProcessStopped):
|
||||
backups.apply_pending_restore()
|
||||
self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
|
||||
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_crash_after_rollback_does_not_reapply_pending_restore(self):
|
||||
class ProcessStopped(BaseException):
|
||||
pass
|
||||
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Value to retain")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
replace_assets = backups._replace_assets
|
||||
remove_tree = backups.shutil.rmtree
|
||||
calls = 0
|
||||
|
||||
def fail_first_copy(source, target):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("failed apply")
|
||||
return replace_assets(source, target)
|
||||
|
||||
def interrupt_cleanup(path, *args, **kwargs):
|
||||
if Path(path).name == "pending":
|
||||
raise ProcessStopped()
|
||||
return remove_tree(path, *args, **kwargs)
|
||||
|
||||
with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
|
||||
patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
|
||||
with self.assertRaises(ProcessStopped):
|
||||
backups.apply_pending_restore()
|
||||
journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
|
||||
self.assertEqual(journal["phase"], "rolled_back")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
|
||||
directory = self.root / "schema-test"
|
||||
directory.mkdir()
|
||||
backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
|
||||
source = directory / "database.sqlite3"
|
||||
with closing(sqlite3.connect(source)) as conn, conn:
|
||||
conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
|
||||
with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
|
||||
backups._validate_database(source)
|
||||
|
||||
def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Current data")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||
with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
|
||||
backups.apply_pending_restore()
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Current data")
|
||||
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||
|
||||
def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
|
||||
with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
|
||||
self.assertIn("database.sqlite3", archive.namelist())
|
||||
self.assertIn("files/branding/logo.png", archive.namelist())
|
||||
self.assertFalse(any("artwork" in name for name in archive.namelist()))
|
||||
|
||||
def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
|
||||
content = self.export()
|
||||
for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
|
||||
with self.subTest(password=password):
|
||||
with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
|
||||
backups.stage_restore(io.BytesIO(bad_content), password)
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
|
||||
def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
|
||||
content = self.export()
|
||||
for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
|
||||
with self.subTest(name=name):
|
||||
malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
|
||||
with self.assertRaises(backups.BackupError):
|
||||
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||
malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
|
||||
with self.assertRaises(backups.BackupError):
|
||||
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||
self.assertFalse((self.root / "outside.txt").exists())
|
||||
|
||||
def test_size_limit_and_unsupported_schema_rejected(self):
|
||||
content = self.export()
|
||||
with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
|
||||
with self.assertRaisesRegex(backups.BackupError, "upload limit"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
|
||||
with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||
conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
|
||||
# Validate the original fixture to avoid executing the malicious trigger in export.
|
||||
with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
|
||||
backups._validate_database(self.database)
|
||||
|
||||
def test_unsupported_compression_is_rejected_before_expansion(self):
|
||||
content = self.export()
|
||||
rewritten = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
|
||||
with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
|
||||
for entry in original.infolist():
|
||||
target.writestr(entry.filename, original.read(entry))
|
||||
with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
|
||||
backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
|
||||
|
||||
def test_cancel_is_idempotent_and_does_not_change_database(self):
|
||||
content = self.export()
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
with self.assertRaisesRegex(backups.BackupError, "already staged"):
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
backups.cancel_restore()
|
||||
backups.cancel_restore()
|
||||
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||
|
||||
def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
|
||||
content = self.export()
|
||||
db.set_setting("site_login_message", "Keep this current value")
|
||||
(self.assets / "branding" / "logo.png").write_bytes(b"current logo")
|
||||
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||
original = backups._replace_assets
|
||||
calls = 0
|
||||
|
||||
def fail_once(source, target):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise OSError("simulated interrupted copy")
|
||||
return original(source, target)
|
||||
|
||||
with patch.object(backups, "_replace_assets", side_effect=fail_once):
|
||||
with self.assertRaisesRegex(OSError, "interrupted copy"):
|
||||
backups.apply_pending_restore()
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
|
||||
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
|
||||
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||
self.assertFalse(backups.apply_pending_restore())
|
||||
|
||||
def test_api_requires_admin_and_restore_confirmation(self):
|
||||
app = FastAPI()
|
||||
app.include_router(backup_router.router)
|
||||
with TestClient(app) as client:
|
||||
self.assertEqual(client.get("/admin/backups").status_code, 401)
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
|
||||
self.assertEqual(client.get("/admin/backups").status_code, 403)
|
||||
self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
|
||||
app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
|
||||
status = client.get("/admin/backups")
|
||||
self.assertEqual(status.status_code, 200)
|
||||
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||
self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
|
||||
response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||
data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
|
||||
self.assertEqual(rejected.status_code, 422)
|
||||
restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||
data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
|
||||
self.assertEqual(restored.status_code, 202)
|
||||
self.assertTrue(restored.json()["restart_required"])
|
||||
self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,519 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,122 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,65 +0,0 @@
|
||||
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()
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Real application HTTP checks for installation, cookies, and backup controls.
|
||||
|
||||
All persistence and artwork paths are isolated in temporary directories; workers,
|
||||
logging file handlers, and the metrics listener are disabled for these tests.
|
||||
"""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db, main
|
||||
from backend.app.config import settings
|
||||
from backend.app.services import backups
|
||||
|
||||
|
||||
OPERATOR_TOKEN = "installation-http-operator-token-test-123456789"
|
||||
OWNER_PASSWORD = "installation-http-owner-password-123456789"
|
||||
BACKUP_PASSPHRASE = "installation-http-backup-passphrase-123456789"
|
||||
|
||||
|
||||
class InstallationHttpTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
for key, value in {
|
||||
"sqlite_path": str(self.root / "magent.db"),
|
||||
"sqlite_journal_mode": "DELETE",
|
||||
"jwt_secret": "installation-http-test-jwt-secret-1234567890",
|
||||
"settings_encryption_key": None,
|
||||
"admin_username": "unused-environment-admin",
|
||||
"admin_password": "",
|
||||
"setup_token": OPERATOR_TOKEN,
|
||||
"auth_cookie_secure": True,
|
||||
"auth_cookie_domain": None,
|
||||
"auth_cookie_samesite": "strict",
|
||||
}.items():
|
||||
context = patch.object(settings, key, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
for context in (
|
||||
patch.object(main, "configure_logging"),
|
||||
patch.object(main, "start_metrics"),
|
||||
patch.object(main, "_background_tasks", []),
|
||||
patch.object(main, "_background_started", False),
|
||||
patch.object(backups, "_assets_root", return_value=self.root / "assets"),
|
||||
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}),
|
||||
):
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
self.origin = str(settings.cors_allow_origin).rstrip("/")
|
||||
self.client = self.enterContext(TestClient(main.app, base_url="https://magent.test"))
|
||||
self.client.headers["Origin"] = self.origin
|
||||
|
||||
def create_owner(self):
|
||||
response = self.client.post("/setup/bootstrap", json={
|
||||
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
return response
|
||||
|
||||
def sign_in(self):
|
||||
response = self.client.post("/auth/login", data={"username": "owner", "password": OWNER_PASSWORD})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||
auth_cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "="))
|
||||
self.assertIn("HttpOnly", auth_cookie)
|
||||
self.assertIn("Secure", auth_cookie)
|
||||
self.assertIn("SameSite=strict", auth_cookie)
|
||||
self.assertNotIn("Authorization", self.client.headers)
|
||||
|
||||
def test_fresh_setup_cookie_settings_completion_and_backup_round_trip(self):
|
||||
status = self.client.get("/setup/status")
|
||||
self.assertEqual(status.json(), {"setup_required": True, "needs_admin": True})
|
||||
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||
self.assertIn("default-src 'none'", status.headers["content-security-policy"])
|
||||
self.assertEqual(self.client.get("/setup/state").status_code, 401)
|
||||
self.assertEqual(self.client.get("/admin/backups").status_code, 401)
|
||||
|
||||
self.create_owner()
|
||||
self.sign_in()
|
||||
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
|
||||
response = self.client.put("/admin/settings", json={
|
||||
"jellyfin_base_url": "http://jellyfin.test:8096",
|
||||
"jellyfin_api_key": "test-integration-key-for-setup",
|
||||
"site_login_message": "Welcome to this installation",
|
||||
})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(response.json()["updated"], 3)
|
||||
values = {row["key"]: row for row in self.client.get("/admin/settings").json()["settings"]}
|
||||
self.assertEqual(values["jellyfin_base_url"]["value"], "http://jellyfin.test:8096")
|
||||
self.assertIsNone(values["jellyfin_api_key"]["value"])
|
||||
self.assertTrue(values["jellyfin_api_key"]["isSet"])
|
||||
response = self.client.put("/setup/state", json={"step": "review"})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
completed = self.client.post("/setup/complete")
|
||||
self.assertEqual(completed.status_code, 200, completed.text)
|
||||
self.assertTrue(completed.json()["completed"])
|
||||
self.assertEqual(self.client.get("/setup/status").json(), {"setup_required": False, "needs_admin": False})
|
||||
self.assertEqual(main._background_tasks, [])
|
||||
|
||||
exported = self.client.post("/admin/backups/export", json={
|
||||
"passphrase": BACKUP_PASSPHRASE, "include_cache": False,
|
||||
})
|
||||
self.assertEqual(exported.status_code, 200, exported.text[:100])
|
||||
self.assertTrue(exported.content.startswith(backups.MAGIC))
|
||||
self.assertEqual(exported.headers["cache-control"], "no-store")
|
||||
self.assertNotIn(b"test-integration-key-for-setup", exported.content)
|
||||
restored = self.client.post("/admin/backups/restore", files={
|
||||
"file": ("restore.magent-backup", io.BytesIO(exported.content), "application/octet-stream"),
|
||||
}, data={"passphrase": BACKUP_PASSPHRASE, "confirmation": "RESTORE"})
|
||||
self.assertEqual(restored.status_code, 202, restored.text)
|
||||
self.assertTrue(restored.json()["restart_required"])
|
||||
self.assertEqual(db.get_setting("site_login_message"), "Welcome to this installation")
|
||||
self.assertIsNotNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||
cancelled = self.client.delete("/admin/backups/restore")
|
||||
self.assertEqual(cancelled.status_code, 200, cancelled.text)
|
||||
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||
|
||||
def test_cross_origin_bootstrap_and_authenticated_changes_are_rejected(self):
|
||||
response = self.client.post("/setup/bootstrap", headers={"Origin": "https://unrelated.invalid"}, json={
|
||||
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
|
||||
})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.create_owner()
|
||||
self.sign_in()
|
||||
response = self.client.put("/setup/state", headers={"Origin": "https://unrelated.invalid"}, json={"step": "review"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
response = self.client.post("/admin/backups/export", headers={"Origin": "https://unrelated.invalid"}, json={"passphrase": BACKUP_PASSPHRASE})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
|
||||
|
||||
def test_setup_validation_errors_do_not_echo_password_or_token(self):
|
||||
secret_password = "private-password-marker-" + "p" * 1024
|
||||
secret_token = "private-token-marker-" + "t" * 1024
|
||||
for payload, secret in (
|
||||
({"setup_token": OPERATOR_TOKEN, "username": "owner", "password": secret_password}, secret_password),
|
||||
({"setup_token": secret_token, "username": "owner", "password": OWNER_PASSWORD}, secret_token),
|
||||
({"setup_token": OPERATOR_TOKEN, "password": OWNER_PASSWORD}, OWNER_PASSWORD),
|
||||
):
|
||||
with self.subTest(secret=secret[:22]):
|
||||
response = self.client.post("/setup/bootstrap", json=payload)
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertNotIn(secret, response.text)
|
||||
self.assertNotIn(OPERATOR_TOKEN, response.text)
|
||||
for error in response.json()["detail"]:
|
||||
self.assertNotIn("input", error)
|
||||
|
||||
def test_backup_validation_errors_do_not_echo_passphrases(self):
|
||||
self.create_owner()
|
||||
self.sign_in()
|
||||
passphrase = "private-backup-passphrase-marker-" + "p" * 1024
|
||||
response = self.client.post("/admin/backups/export", json={"passphrase": passphrase})
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertNotIn(passphrase, response.text)
|
||||
response = self.client.post("/admin/backups/restore", files={"file": ("archive", b"data")}, data={
|
||||
"passphrase": passphrase, "confirmation": "RESTORE",
|
||||
})
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertNotIn(passphrase, response.text)
|
||||
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||
|
||||
def test_real_middleware_rejects_oversized_bootstrap_before_creation(self):
|
||||
response = self.client.post("/setup/bootstrap", content=b"x" * (17 * 1024), headers={"Content-Type": "application/json"})
|
||||
self.assertEqual(response.status_code, 413, response.text)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,162 +0,0 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, Request, UploadFile
|
||||
|
||||
from backend.app import db, main
|
||||
from backend.app.config import settings
|
||||
from backend.app.request_limits import InstallationBodyLimitMiddleware
|
||||
from backend.app.services import setup
|
||||
|
||||
|
||||
class InstallationLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(temporary.cleanup)
|
||||
patches = [
|
||||
patch.object(settings, "sqlite_path", str(Path(temporary.name) / "magent.db")),
|
||||
patch.object(settings, "jwt_secret", "installation-lifecycle-secret-1234567890"),
|
||||
patch.object(settings, "settings_encryption_key", None),
|
||||
patch.object(settings, "admin_password", ""),
|
||||
patch.object(settings, "setup_token", "operator-setup-token-at-least-32-characters"),
|
||||
patch.object(main, "_background_started", False),
|
||||
patch.object(main, "_background_tasks", []),
|
||||
patch.object(main, "start_metrics"),
|
||||
patch.object(main, "configure_logging"),
|
||||
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "true"}),
|
||||
]
|
||||
for item in patches:
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
async def test_fresh_start_waits_for_admin_and_completion_then_starts_workers_once(self):
|
||||
with patch.object(main, "_launch_background_task") as launch:
|
||||
await main.startup()
|
||||
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": True})
|
||||
launch.assert_not_called()
|
||||
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
|
||||
await main._start_background_tasks()
|
||||
launch.assert_not_called()
|
||||
setup.complete_setup()
|
||||
await main.app.state.on_setup_complete()
|
||||
await main.app.state.on_setup_complete()
|
||||
self.assertEqual(launch.call_count, 9)
|
||||
|
||||
async def test_upgraded_install_starts_normally_without_setup_token(self):
|
||||
db.init_db()
|
||||
db.create_user("owner", "existing-password-12345", role="admin")
|
||||
settings.setup_token = ""
|
||||
with patch.object(main, "_launch_background_task") as launch:
|
||||
await main.startup()
|
||||
self.assertFalse(setup.is_setup_required())
|
||||
self.assertEqual(launch.call_count, 9)
|
||||
|
||||
async def test_disabled_workers_stay_disabled_after_setup(self):
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
|
||||
setup.complete_setup()
|
||||
with patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), patch.object(main, "_launch_background_task") as launch:
|
||||
await main._start_background_tasks()
|
||||
launch.assert_not_called()
|
||||
|
||||
async def test_bad_secret_stops_before_restore_or_database_initialization(self):
|
||||
settings.jwt_secret = "short"
|
||||
with patch.object(main, "apply_pending_restore") as restore, patch.object(main, "init_db") as initialize:
|
||||
with self.assertRaisesRegex(RuntimeError, "JWT_SECRET"):
|
||||
await main.startup()
|
||||
restore.assert_not_called()
|
||||
initialize.assert_not_called()
|
||||
|
||||
async def test_restore_failure_stops_before_initialization_and_workers(self):
|
||||
with patch.object(main, "apply_pending_restore", side_effect=RuntimeError("restore failed")), patch.object(main, "init_db") as initialize, patch.object(main, "_launch_background_task") as launch:
|
||||
with self.assertRaisesRegex(RuntimeError, "restore failed"):
|
||||
await main.startup()
|
||||
initialize.assert_not_called()
|
||||
launch.assert_not_called()
|
||||
|
||||
async def test_startup_order_is_restore_then_setup_marker_then_schema(self):
|
||||
calls = Mock()
|
||||
calls.attach_mock(Mock(wraps=main.apply_pending_restore), "restore")
|
||||
calls.attach_mock(Mock(wraps=main.initialize_setup_state), "setup")
|
||||
calls.attach_mock(Mock(wraps=main.init_db), "schema")
|
||||
with patch.object(main, "apply_pending_restore", calls.restore), patch.object(main, "initialize_setup_state", calls.setup), patch.object(main, "init_db", calls.schema):
|
||||
await main.startup()
|
||||
self.assertEqual([call[0] for call in calls.mock_calls], ["restore", "setup", "schema"])
|
||||
|
||||
def test_missing_token_does_not_allow_fresh_bootstrap(self):
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
settings.setup_token = ""
|
||||
with self.assertRaisesRegex(RuntimeError, "SETUP_TOKEN"):
|
||||
main._enforce_secure_startup_configuration()
|
||||
|
||||
def test_destination_environment_does_not_add_an_admin_to_restored_accounts(self):
|
||||
db.init_db()
|
||||
db.create_user("restored-owner", "existing-password-12345", role="admin")
|
||||
with patch.object(settings, "admin_username", "host-bootstrap"), patch.object(settings, "admin_password", "new-host-password-12345"):
|
||||
db.init_db()
|
||||
self.assertIsNone(db.get_user_by_username("host-bootstrap"))
|
||||
|
||||
async def test_shutdown_cancels_workers_and_allows_next_start(self):
|
||||
task = asyncio.create_task(asyncio.Event().wait())
|
||||
main._background_tasks.append(task)
|
||||
main._background_started = True
|
||||
await main.shutdown()
|
||||
self.assertTrue(task.cancelled())
|
||||
self.assertEqual(main._background_tasks, [])
|
||||
self.assertFalse(main._background_started)
|
||||
|
||||
|
||||
class InstallationRequestLimitsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_rejects_oversized_declared_body_before_parser(self):
|
||||
app = FastAPI()
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
@app.post("/setup/bootstrap")
|
||||
async def bootstrap(request: Request):
|
||||
self.fail("Body must be rejected before the endpoint")
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/setup/bootstrap", content=b"{}", headers={"Content-Length": "999999"})
|
||||
self.assertEqual(response.status_code, 413)
|
||||
|
||||
async def test_counts_chunks_with_missing_or_forged_content_length(self):
|
||||
app = FastAPI()
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
@app.post("/setup/bootstrap")
|
||||
async def bootstrap(request: Request):
|
||||
return await request.json()
|
||||
|
||||
async def chunks():
|
||||
yield b'{"token":"'
|
||||
yield b"a" * 17000
|
||||
yield b'"}'
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
for headers in ({}, {"Content-Length": "1"}):
|
||||
response = await client.post("/setup/bootstrap", content=chunks(), headers=headers)
|
||||
self.assertEqual(response.status_code, 413)
|
||||
|
||||
async def test_multipart_stream_limit_is_413_not_parser_500(self):
|
||||
app = FastAPI()
|
||||
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||
|
||||
@app.post("/admin/backups/restore")
|
||||
async def restore(file: UploadFile = File(...)):
|
||||
return {"size": file.size}
|
||||
|
||||
async def chunks():
|
||||
yield b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="backup"\r\n\r\n'
|
||||
yield b"a" * 2048
|
||||
yield b"\r\n--boundary--\r\n"
|
||||
|
||||
with patch("backend.app.request_limits.RESTORE_BODY_LIMIT", 1024):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/admin/backups/restore", content=chunks(), headers={"Content-Type": "multipart/form-data; boundary=boundary"})
|
||||
self.assertEqual(response.status_code, 413)
|
||||
@@ -39,9 +39,6 @@ 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"])
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
"""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()
|
||||
@@ -91,9 +91,6 @@ 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')
|
||||
@@ -242,7 +239,6 @@ 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'], 'What’s 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'])
|
||||
@@ -384,30 +380,6 @@ 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('What’s 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 <media> 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())
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
import tempfile
|
||||
from threading import Barrier
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.config import settings
|
||||
from backend.app.routers import setup as setup_router
|
||||
from backend.app.security import create_access_token
|
||||
from backend.app.services import setup
|
||||
|
||||
|
||||
SETUP_TOKEN = "operator-setup-token-for-tests-only-1234567890"
|
||||
ADMIN_PASSWORD = "A-long-admin-password!123"
|
||||
|
||||
|
||||
class SetupTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
for field, value in {
|
||||
"sqlite_path": os.path.join(self.temp.name, "test.db"),
|
||||
"sqlite_journal_mode": "DELETE",
|
||||
"admin_username": "environment-admin",
|
||||
"admin_password": "",
|
||||
"jwt_secret": "setup-test-jwt-secret-only-1234567890",
|
||||
"settings_encryption_key": "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=",
|
||||
}.items():
|
||||
context = patch.object(settings, field, value)
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
context = patch.object(setup, "settings", SimpleNamespace(setup_token=SETUP_TOKEN))
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.app = FastAPI()
|
||||
self.app.include_router(setup_router.router)
|
||||
self.client = TestClient(self.app)
|
||||
self.addCleanup(self.client.close)
|
||||
|
||||
def bootstrap(self, **changes):
|
||||
return self.client.post("/setup/bootstrap", json={
|
||||
"setup_token": SETUP_TOKEN,
|
||||
"username": "first-admin",
|
||||
"password": ADMIN_PASSWORD,
|
||||
**changes,
|
||||
})
|
||||
|
||||
def admin_headers(self):
|
||||
return {"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}"}
|
||||
|
||||
def test_fresh_install_requires_setup_and_exposes_no_configuration(self):
|
||||
response = self.client.get("/setup/status")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"setup_required": True, "needs_admin": True})
|
||||
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||
self.assertEqual(self.client.get("/setup/state").status_code, 401)
|
||||
|
||||
def test_existing_install_migrates_as_completed_without_reopening_bootstrap(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("DROP TABLE installation_setup")
|
||||
setup.initialize_setup_state()
|
||||
self.assertEqual(setup.get_public_setup_status(), {"setup_required": False, "needs_admin": False})
|
||||
self.assertIsNotNone(setup.get_setup_state()["completed_at"])
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
|
||||
def test_missing_marker_fails_closed(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("DROP TABLE installation_setup")
|
||||
self.assertFalse(setup.is_setup_required())
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
|
||||
def test_marker_survives_restart_before_schema_initialization(self):
|
||||
new_path = os.path.join(self.temp.name, "interrupted.db")
|
||||
with patch.object(settings, "sqlite_path", new_path):
|
||||
setup.initialize_setup_state()
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
|
||||
def test_empty_precreated_database_is_a_fresh_install(self):
|
||||
new_path = os.path.join(self.temp.name, "empty.db")
|
||||
with open(new_path, "wb"):
|
||||
pass
|
||||
with patch.object(settings, "sqlite_path", new_path):
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
|
||||
def test_environment_admin_uses_wizard_without_public_bootstrap(self):
|
||||
with patch.object(settings, "admin_password", ADMIN_PASSWORD):
|
||||
db.ensure_admin_user()
|
||||
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": False})
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
|
||||
def test_valid_token_creates_local_admin_once_and_uses_password_hash(self):
|
||||
response = self.bootstrap()
|
||||
self.assertEqual(response.status_code, 201, response.text)
|
||||
self.assertEqual(response.json(), {"status": "created", "username": "first-admin"})
|
||||
user = db.verify_user_password("first-admin", ADMIN_PASSWORD)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertEqual(user["role"], "admin")
|
||||
self.assertEqual(user["auth_provider"], "local")
|
||||
self.assertNotEqual(user["password_hash"], ADMIN_PASSWORD)
|
||||
self.assertEqual(setup.get_setup_state()["step"], "apps")
|
||||
self.assertEqual(self.bootstrap(username="second-admin").status_code, 409)
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_invalid_and_missing_operator_tokens_never_create_admin(self):
|
||||
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
|
||||
with patch.object(setup.settings, "setup_token", ""):
|
||||
self.assertEqual(self.bootstrap().status_code, 403)
|
||||
with patch.object(setup.settings, "setup_token", "too-short"):
|
||||
self.assertEqual(self.bootstrap(setup_token="too-short").status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_non_ascii_token_fails_cleanly(self):
|
||||
self.assertEqual(self.bootstrap(setup_token="invalid-\N{SNOWMAN}").status_code, 403)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_example_and_repeated_character_setup_tokens_are_rejected(self):
|
||||
for token in (
|
||||
"replace-with-a-separate-random-setup-token",
|
||||
"CHANGE_ME_before_starting_this_installation",
|
||||
"your-setup-token-goes-here-at-least-32-characters",
|
||||
"a" * 64,
|
||||
"0" * 64,
|
||||
" " * 64,
|
||||
):
|
||||
with self.subTest(token=token), patch.object(setup.settings, "setup_token", token):
|
||||
self.assertFalse(setup.setup_token_configured())
|
||||
with self.assertRaises(setup.InvalidSetupTokenError):
|
||||
setup.bootstrap_administrator(token, "owner", ADMIN_PASSWORD)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.assertTrue(setup.setup_token_configured())
|
||||
|
||||
def test_password_policy_and_username_validation(self):
|
||||
for username in (" ", "admin user", "admin\x7f", "admin\nname"):
|
||||
with self.subTest(username=repr(username)):
|
||||
self.assertEqual(self.bootstrap(username=username).status_code, 400)
|
||||
self.assertEqual(self.bootstrap(password="short").status_code, 400)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_oversized_fields_and_unexpected_privileges_are_rejected(self):
|
||||
self.assertEqual(self.bootstrap(password="x" * 1025).status_code, 422)
|
||||
self.assertEqual(self.bootstrap(username="x" * 101).status_code, 422)
|
||||
self.assertEqual(self.bootstrap(role="admin").status_code, 422)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
|
||||
def test_existing_normalized_username_is_not_replaced(self):
|
||||
db.create_user("Taken", ADMIN_PASSWORD)
|
||||
self.assertEqual(self.bootstrap(username="taken").status_code, 409)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_bootstrap_attempts_are_persistently_limited(self):
|
||||
for _ in range(setup.BOOTSTRAP_IP_ATTEMPTS):
|
||||
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
|
||||
setup.initialize_setup_state()
|
||||
response = self.bootstrap()
|
||||
self.assertEqual(response.status_code, 429)
|
||||
self.assertGreater(int(response.headers["retry-after"]), 0)
|
||||
self.assertFalse(db.has_admin_user())
|
||||
with db._connect() as conn:
|
||||
keys = [row[0] for row in conn.execute("SELECT key_hash FROM installation_setup_attempts")]
|
||||
self.assertNotIn("testclient", keys)
|
||||
|
||||
def test_rate_limit_global_cap_and_expiry(self):
|
||||
with patch.object(setup, "time", return_value=1000):
|
||||
for number in range(setup.BOOTSTRAP_GLOBAL_ATTEMPTS):
|
||||
self.assertIsNone(setup.consume_bootstrap_attempt(f"192.0.2.{number}"))
|
||||
self.assertEqual(setup.consume_bootstrap_attempt("198.51.100.1"), 900)
|
||||
with patch.object(setup, "time", return_value=1901):
|
||||
self.assertIsNone(setup.consume_bootstrap_attempt("198.51.100.1"))
|
||||
|
||||
def test_concurrent_attempts_cannot_bypass_rate_limit(self):
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
results = list(executor.map(lambda _: setup.consume_bootstrap_attempt("192.0.2.1"), range(12)))
|
||||
self.assertEqual(results.count(None), setup.BOOTSTRAP_IP_ATTEMPTS)
|
||||
|
||||
def test_concurrent_bootstraps_create_only_one_admin(self):
|
||||
barrier = Barrier(4)
|
||||
|
||||
def synchronized_hash(_):
|
||||
barrier.wait(timeout=10)
|
||||
return "test-only-precomputed-hash"
|
||||
|
||||
def create(number):
|
||||
try:
|
||||
setup.bootstrap_administrator(SETUP_TOKEN, f"admin-{number}", ADMIN_PASSWORD)
|
||||
return True
|
||||
except setup.SetupUnavailableError:
|
||||
return False
|
||||
|
||||
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
results = list(executor.map(create, range(4)))
|
||||
self.assertEqual(results.count(True), 1)
|
||||
self.assertEqual(len(db.get_all_users()), 1)
|
||||
|
||||
def test_state_mutations_require_admin_and_progress_resumes(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
db.create_user("viewer", ADMIN_PASSWORD)
|
||||
user_headers = {"Authorization": f"Bearer {create_access_token('viewer', 'user')}"}
|
||||
for path, method, kwargs in (
|
||||
("/setup/state", "get", {}),
|
||||
("/setup/state", "put", {"json": {"step": "review"}}),
|
||||
("/setup/complete", "post", {}),
|
||||
):
|
||||
with self.subTest(path=path, method=method):
|
||||
call = getattr(self.client, method)
|
||||
self.assertEqual(call(path, **kwargs).status_code, 401)
|
||||
self.assertEqual(call(path, headers=user_headers, **kwargs).status_code, 403)
|
||||
response = self.client.put("/setup/state", json={"step": "preferences"}, headers=self.admin_headers())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
setup.initialize_setup_state()
|
||||
db.init_db()
|
||||
self.assertEqual(setup.get_setup_state()["step"], "preferences")
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
self.assertEqual(self.client.put(
|
||||
"/setup/state", json={"step": "invalid"}, headers=self.admin_headers()
|
||||
).status_code, 422)
|
||||
|
||||
def test_completion_invokes_worker_callback_and_cannot_reopen_bootstrap(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
callback = AsyncMock()
|
||||
self.app.state.on_setup_complete = callback
|
||||
response = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertTrue(response.json()["completed"])
|
||||
self.assertIsNotNone(response.json()["completed_at"])
|
||||
callback.assert_awaited_once()
|
||||
self.assertFalse(setup.is_setup_required())
|
||||
# A retry can restart an idempotent callback if the first response was
|
||||
# interrupted, while keeping the original completion timestamp.
|
||||
retry = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||
self.assertEqual(retry.json(), response.json())
|
||||
self.assertEqual(callback.await_count, 2)
|
||||
self.client.put("/setup/state", json={"step": "administrator"}, headers=self.admin_headers())
|
||||
with db._connect() as conn:
|
||||
conn.execute("DELETE FROM users")
|
||||
self.assertEqual(self.bootstrap().status_code, 409)
|
||||
self.assertEqual(setup.get_setup_state()["step"], "review")
|
||||
|
||||
def test_completion_requires_an_administrator(self):
|
||||
with self.assertRaises(setup.SetupUnavailableError):
|
||||
setup.complete_setup()
|
||||
self.assertTrue(setup.is_setup_required())
|
||||
|
||||
def test_sync_callback_is_supported(self):
|
||||
self.assertEqual(self.bootstrap().status_code, 201)
|
||||
called = []
|
||||
self.app.state.on_setup_complete = lambda: called.append(True)
|
||||
response = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(called, [True])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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
@@ -1,27 +0,0 @@
|
||||
# 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:
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
+4
-16
@@ -1,22 +1,13 @@
|
||||
services:
|
||||
magent:
|
||||
# Select a published immutable release tag or digest in .env.
|
||||
image: ${MAGENT_IMAGE:?Set MAGENT_IMAGE to a published release tag or digest}
|
||||
image: rephl3xnz/magent:latest
|
||||
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:
|
||||
# Keep the API internal; the frontend serves /api on this same port.
|
||||
- "${MAGENT_BIND_ADDRESS:-127.0.0.1}:${MAGENT_HTTP_PORT:-3000}:3000"
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
# Fresh installs only: existing installs must retain their original mount.
|
||||
- magent-data:/app/data
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
- ./data:/app/data
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
@@ -24,6 +15,3 @@ 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:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
<!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">We’re 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>
|
||||
@@ -1,3 +0,0 @@
|
||||
# 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
|
||||
@@ -14,13 +14,11 @@ 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/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"
|
||||
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"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
@@ -28,5 +26,3 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=20
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,179 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,263 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Installation, backup and recovery
|
||||
|
||||
## 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
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
The first two commands produce the JWT secret and setup token respectively. The third requires the backend dependencies. Alternatively generate the Fernet key using Python's standard library: `python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"`.
|
||||
|
||||
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 -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.
|
||||
3. Set site access, request refresh/retention and optional SMTP preferences. Invite signup remains invite-only.
|
||||
4. Review and finish. Magent starts its configured background jobs, unless `BACKGROUND_TASKS_ENABLED=false`.
|
||||
|
||||
Use server-reachable addresses: `localhost` in a container refers to that container. Optional apps can be skipped. Each successful save persists; closing the tab leaves setup resumable. Unsaved form fields are not retained. Remove `SETUP_TOKEN` after finishing. Bootstrap is permanently disabled after setup, and cannot replace an existing administrator. Administrators can revisit the wizard from Settings without resetting the installation.
|
||||
|
||||
Upgrades with an existing users table are marked configured automatically. Setup status reveals only whether setup is needed and whether the first administrator is missing. Configuration and wizard progress require administrator authentication. First-admin creation uses a constant-time token comparison, persistent rate limits and a database transaction to prevent concurrent claims.
|
||||
|
||||
## Create a backup
|
||||
|
||||
Open **Settings → Advanced tools → Backup & restore** (`/admin/backups`). Choose a unique backup passphrase of 12–1024 characters, confirm it, optionally include the filesystem artwork cache, and download the `.magent-backup` file.
|
||||
|
||||
Every backup includes:
|
||||
|
||||
- A consistent SQLite snapshot: users, password hashes, invite records, requests, issues, settings, saved statistics, subscriptions and database-backed caches.
|
||||
- Portable runtime configuration, including environment-provided app credentials. Secrets are decrypted only inside the private export staging area and encrypted archive; they are re-encrypted with the destination installation key when restoring.
|
||||
- Custom branding (`data/branding/logo.png` and `favicon.ico`).
|
||||
|
||||
The optional cache adds supported TMDB artwork from `data/artwork/tmdb`. In-memory caches are rebuilt, not backed up. Media files, the connected apps' databases, log files, `.env`, TLS private keys, host paths, signing/encryption keys and deployment/network controls are not included. Keep a separate secure record of the deployment configuration and backup passphrase.
|
||||
|
||||
Backups use authenticated AES-256-GCM encryption with a per-backup salt and scrypt-derived key. The passphrase is never stored by Magent and cannot be recovered. Keep backups and their passphrases separately, off the Magent host. Treat backups as sensitive even though encrypted.
|
||||
|
||||
Current limits: **32 MiB encrypted archive**, **128 MiB expanded data**, and **20,000 entries**. These bound memory and disk use; including a large artwork cache can exceed them. Retry without artwork if necessary. For larger installations, use a separate operator-managed offline volume/database backup; this UI does not silently omit oversized data. Automatic scheduled backups and media-server backups are not part of this feature.
|
||||
|
||||
The frontend and backend accept up to 34 MiB for the whole multipart request, including the 32 MiB file. Configure any external reverse proxy's upload limit accordingly (for example `client_max_body_size 34m` in nginx); otherwise it may reject valid files before they reach Magent.
|
||||
|
||||
## Restore safely
|
||||
|
||||
1. Make a fresh backup of the destination. Stop external writes/other backend processes sharing its SQLite file. The supplied deployment uses one backend worker; do not run restore against a multi-worker/shared-database deployment.
|
||||
2. Sign in as an administrator, select a `.magent-backup`, enter its passphrase and type `RESTORE`. A fresh replacement installation must first create its temporary administrator through `/setup`; then use the **Restore it here** link before connecting apps.
|
||||
3. Upload and stage the restore. Magent checks authentication, encrypted integrity, archive paths and sizes, checksums, SQLite integrity, schema compatibility and an active restored administrator. Live data is unchanged at this point. A pending restore can be cancelled from the same page.
|
||||
4. Restart the application using your normal deployment process, for example `docker compose restart magent` 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. 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
|
||||
|
||||
The `backups/` directory beside the configured SQLite database contains private staging, lock/journal/status files and `rollback-<id>/` copies. It is not a library of exported encrypted downloads. Rollback copies contain the old database and assets; protect the data volume with host encryption and restrictive access. Magent does not automatically delete rollback copies after success. After validating the restored installation and saving a separate backup, an operator may archive or remove the specific old rollback directories during maintenance. Never remove an active `pending/` directory or `restore-journal.json` during a restore.
|
||||
|
||||
Insufficient disk space or invalid input stops the operation rather than partially accepting a backup. Allow room for the upload, extracted staging database/assets, live data and a rollback copy. The supplied Docker image's unprivileged user must have write access to the persistent data volume. Do not delete the data volume or replace `.env` to retry setup or recovery.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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 1–28 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import PageHeading from "./ui/PageHeading";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth";
|
||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
|
||||
import {
|
||||
normalizeRecentResults,
|
||||
normalizeSearchResults,
|
||||
type RecentRequest,
|
||||
type RequestSearchResult,
|
||||
} from "./lib/request-results";
|
||||
import { useEffectiveRole } from "./lib/viewMode";
|
||||
import PageHeading from "./ui/PageHeading";
|
||||
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -22,8 +22,6 @@ export default function HomePage() {
|
||||
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const effectiveRole = useEffectiveRole(role);
|
||||
const isAdmin = effectiveRole === "admin";
|
||||
const [recentDays, setRecentDays] = useState(90);
|
||||
const [recentStage, setRecentStage] = useState<RequestStage>("all");
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
@@ -64,7 +62,7 @@ export default function HomePage() {
|
||||
const userRole = me?.role ?? null;
|
||||
setRole(userRole);
|
||||
setAuthReady(true);
|
||||
const take = isAdmin ? 50 : 6;
|
||||
const take = userRole === "admin" ? 50 : 6;
|
||||
const params = new URLSearchParams({
|
||||
take: String(take),
|
||||
days: String(recentDays),
|
||||
@@ -98,7 +96,7 @@ export default function HomePage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAdmin, recentDays, recentStage, router]);
|
||||
}, [recentDays, recentStage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) {
|
||||
@@ -307,7 +305,7 @@ export default function HomePage() {
|
||||
<div className="recent-header home-section-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Request activity</span>
|
||||
<h2>{isAdmin ? "Recent requests" : "My recent requests"}</h2>
|
||||
<h2>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
|
||||
</div>
|
||||
{authReady && (
|
||||
<div className="recent-filter-group">
|
||||
@@ -339,7 +337,7 @@ export default function HomePage() {
|
||||
<span>Try a wider period or a different stage.</span>
|
||||
</div>
|
||||
) : (
|
||||
(isAdmin ? recent : recent.slice(0, 6)).map((item) => (
|
||||
recent.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
|
||||
@@ -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: "my-phone",
|
||||
magent_notify_push_device: "iphone-zak",
|
||||
magent_notify_webhook_url: "https://automation.example.com/webhooks/magent",
|
||||
jellyseerr_base_url: "https://requests.example.com or http://seerr:5055",
|
||||
jellyfin_base_url: "https://jelly.example.com or http://jellyfin:8096",
|
||||
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",
|
||||
jellystat_base_url: "http://jellystat:3000",
|
||||
jellyfin_public_url: "https://jelly.example.com",
|
||||
sonarr_base_url: "https://sonarr.example.com or http://sonarr:8989",
|
||||
bazarr_base_url: "https://bazarr.example.com or http://bazarr:6767",
|
||||
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",
|
||||
bazarr_default_language: "en",
|
||||
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",
|
||||
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",
|
||||
site_login_message: "Sign-in information, an outage notice, or help for users…",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.page p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.summary,
|
||||
.pending,
|
||||
.panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.summary p,
|
||||
.panel > p,
|
||||
.muted,
|
||||
.help {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pending {
|
||||
border-color: var(--accent);
|
||||
border-inline-start-width: 4px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-bottom: 16px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input[type="file"] {
|
||||
padding: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page button {
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.page input:focus-visible,
|
||||
.page button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.summary,
|
||||
.pending,
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.page button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiUrl, requestJson } from "../../lib/api-client";
|
||||
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
|
||||
import AdminShell from "../../ui/AdminShell";
|
||||
import styles from "./backups.module.css";
|
||||
|
||||
type BackupDetails = {
|
||||
created_at: string;
|
||||
build: string;
|
||||
include_cache: boolean;
|
||||
};
|
||||
|
||||
type BackupStatus = {
|
||||
format_version: number;
|
||||
max_upload_bytes: number;
|
||||
max_expanded_bytes: number;
|
||||
include_cache_default: boolean;
|
||||
pending_restore: (BackupDetails & { staged_at: string }) | null;
|
||||
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
|
||||
};
|
||||
|
||||
type RestoreResult = {
|
||||
status: "staged";
|
||||
restart_required: true;
|
||||
backup: BackupDetails;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const dateLabel = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
};
|
||||
|
||||
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
|
||||
|
||||
export default function BackupsPage() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BackupStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
|
||||
const [includeCache, setIncludeCache] = useState(false);
|
||||
const [exportPassphrase, setExportPassphrase] = useState("");
|
||||
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
||||
const [restorePassphrase, setRestorePassphrase] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleError = useCallback(
|
||||
(cause: unknown, fallback: string) => {
|
||||
if (cause instanceof UnauthorizedError) {
|
||||
router.replace("/login?next=%2Fadmin%2Fbackups");
|
||||
return;
|
||||
}
|
||||
if (cause instanceof ForbiddenError) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
setError(cause instanceof Error ? cause.message : fallback);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void revision;
|
||||
const abort = new AbortController();
|
||||
setLoading(true);
|
||||
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
|
||||
.then((result) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setData(result);
|
||||
setIncludeCache(result.include_cache_default);
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!abort.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [revision, handleError]);
|
||||
|
||||
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
setNotice("");
|
||||
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
|
||||
setError("Choose a backup passphrase between 12 and 1,024 characters.");
|
||||
return;
|
||||
}
|
||||
if (exportPassphrase !== confirmPassphrase) {
|
||||
setError("The backup passphrases do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy("export");
|
||||
try {
|
||||
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
|
||||
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
|
||||
link.href = downloadUrl;
|
||||
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
|
||||
link.hidden = true;
|
||||
document.body.appendChild(link);
|
||||
try {
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
||||
}
|
||||
setExportPassphrase("");
|
||||
setConfirmPassphrase("");
|
||||
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
|
||||
} catch (cause) {
|
||||
handleError(cause, "Could not create the backup.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (busy || !data || data.pending_restore) return;
|
||||
setError("");
|
||||
setNotice("");
|
||||
if (!file || file.size === 0) {
|
||||
setError("Choose a Magent backup file to restore.");
|
||||
return;
|
||||
}
|
||||
if (file.size > data.max_upload_bytes) {
|
||||
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
|
||||
return;
|
||||
}
|
||||
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
|
||||
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
|
||||
return;
|
||||
}
|
||||
if (confirmation !== "RESTORE") {
|
||||
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
|
||||
return;
|
||||
}
|
||||
setBusy("restore");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("passphrase", restorePassphrase);
|
||||
form.append("confirmation", confirmation);
|
||||
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
|
||||
setData((current) =>
|
||||
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
|
||||
);
|
||||
setRestorePassphrase("");
|
||||
setConfirmation("");
|
||||
setFile(null);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
setNotice(
|
||||
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
|
||||
);
|
||||
} catch (cause) {
|
||||
handleError(cause, "Could not prepare the restore.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRestore = async () => {
|
||||
if (busy) return;
|
||||
setBusy("cancel");
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await requestJson("/admin/backups/restore", { method: "DELETE" });
|
||||
setData((current) => (current ? { ...current, pending_restore: null } : current));
|
||||
setNotice("Pending restore cancelled. Your current data is unchanged.");
|
||||
} catch (cause) {
|
||||
handleError(cause, "Could not cancel the restore.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
|
||||
<div className={styles.page}>
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{loading && <p role="status">Loading backup settings...</p>}
|
||||
{!loading && !data && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<section className={styles.summary} aria-labelledby="backup-contents">
|
||||
<h2 id="backup-contents">What is saved</h2>
|
||||
<p>
|
||||
Every backup includes Magent settings, app connection credentials, branding, and the complete database
|
||||
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
|
||||
</p>
|
||||
<p>
|
||||
Connected apps and media files need their own backups. App credentials configured through the
|
||||
environment are included, but the deployment environment file, host paths, and signing or encryption
|
||||
keys are not.
|
||||
</p>
|
||||
{data.last_restore && (
|
||||
<p className={styles.muted}>
|
||||
{data.last_restore.status === "rolled_back"
|
||||
? "Last restore was rolled back"
|
||||
: "Last restore completed"}
|
||||
: {dateLabel(data.last_restore.restored_at)}.
|
||||
{data.last_restore.status === "rolled_back" &&
|
||||
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{data.pending_restore && (
|
||||
<section className={styles.pending} aria-labelledby="pending-restore-title">
|
||||
<h2 id="pending-restore-title">Restore ready — restart required</h2>
|
||||
<p>
|
||||
Backup from {dateLabel(data.pending_restore.created_at)}
|
||||
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
|
||||
{data.pending_restore.include_cache ? "included" : "not included"}.
|
||||
</p>
|
||||
<p>
|
||||
Restart the Magent container or service to apply this backup. Changes made since the backup was
|
||||
created will be replaced. Afterwards, sign in again with an administrator account from the restored
|
||||
backup.
|
||||
</p>
|
||||
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
|
||||
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={styles.columns}>
|
||||
<section className={styles.panel} aria-labelledby="create-backup-title">
|
||||
<h2 id="create-backup-title">Create a backup</h2>
|
||||
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
|
||||
<p>
|
||||
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
|
||||
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
|
||||
artwork caches unchecked.
|
||||
</p>
|
||||
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
|
||||
<fieldset className={styles.fields} disabled={!!busy}>
|
||||
<legend className={styles.legend}>Backup options</legend>
|
||||
<label className={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCache}
|
||||
onChange={(event) => setIncludeCache(event.target.checked)}
|
||||
aria-describedby="cache-help"
|
||||
/>
|
||||
Include artwork caches
|
||||
</label>
|
||||
<p id="cache-help" className={styles.help}>
|
||||
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
|
||||
again. Database caches are always included.
|
||||
</p>
|
||||
<label className={styles.field}>
|
||||
Backup passphrase
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={exportPassphrase}
|
||||
onChange={(event) => setExportPassphrase(event.target.value)}
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
required
|
||||
aria-describedby="backup-passphrase-help"
|
||||
/>
|
||||
</label>
|
||||
<p id="backup-passphrase-help" className={styles.help}>
|
||||
Use at least 12 characters. This passphrase is separate from your login password. A lost
|
||||
passphrase cannot be recovered.
|
||||
</p>
|
||||
<label className={styles.field}>
|
||||
Confirm backup passphrase
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassphrase}
|
||||
onChange={(event) => setConfirmPassphrase(event.target.value)}
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">
|
||||
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={styles.panel} aria-labelledby="restore-backup-title">
|
||||
<h2 id="restore-backup-title">Restore a backup</h2>
|
||||
<p>
|
||||
Restoring replaces Magent's settings and database, including users and invites. Download a
|
||||
current backup first if you want to keep these changes.
|
||||
</p>
|
||||
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
|
||||
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
|
||||
<legend className={styles.legend}>Choose and confirm a backup</legend>
|
||||
<label className={styles.field}>
|
||||
Backup file
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".magent-backup,application/octet-stream"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
required
|
||||
aria-describedby="backup-file-help"
|
||||
/>
|
||||
</label>
|
||||
<p id="backup-file-help" className={styles.help}>
|
||||
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
|
||||
</p>
|
||||
<label className={styles.field}>
|
||||
Backup passphrase
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={restorePassphrase}
|
||||
onChange={(event) => setRestorePassphrase(event.target.value)}
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Type RESTORE to confirm replacement
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
pattern="RESTORE"
|
||||
required
|
||||
aria-describedby="restore-restart-help"
|
||||
/>
|
||||
</label>
|
||||
<p id="restore-restart-help" className={styles.help}>
|
||||
The backup is checked before being queued. It only takes effect when you restart Magent; you can
|
||||
cancel before then. You will need to sign in using an account from the backup.
|
||||
</p>
|
||||
<button type="submit" className="danger-button">
|
||||
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -82,12 +82,6 @@ export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
advanced: true,
|
||||
items: [
|
||||
{ href: "/admin/general", label: "Hosting & proxy", description: "Public addresses and deployment options" },
|
||||
{ href: "/setup", label: "Setup wizard", description: "Guided app connections and installation preferences" },
|
||||
{
|
||||
href: "/admin/backups",
|
||||
label: "Backup & restore",
|
||||
description: "Encrypted settings, database and cache backups",
|
||||
},
|
||||
{ href: "/admin/diagnostics", label: "System health", description: "Service checks and diagnostics" },
|
||||
{ href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" },
|
||||
{ href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" },
|
||||
|
||||
@@ -1837,7 +1837,7 @@ export default function AdminInviteManagementPage() {
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
placeholder="Welcome! Use this link to create your account."
|
||||
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
|
||||
/>
|
||||
</label>
|
||||
{inviteFlowStep === 2 && (
|
||||
|
||||
@@ -368,7 +368,7 @@ export default function NewslettersAdminPage() {
|
||||
<div className="recap-section-heading">
|
||||
<div>
|
||||
<span className="recap-eyebrow">A fresh edition</span>
|
||||
<h2>What’s new in your library</h2>
|
||||
<h2>What’s new on Grizzlyflix</h2>
|
||||
<p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p>
|
||||
</div>
|
||||
<div className="newsletter-create">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import "./style.css";
|
||||
|
||||
export const metadata = { title: "Coming soon | Magent" };
|
||||
export const metadata = { title: "Coming soon | Magent — Grizzlyflix" };
|
||||
|
||||
export default function ComingSoonPage() {
|
||||
return (
|
||||
<main className="launch-cover">
|
||||
<div className="launch-brand">MAGENT</div>
|
||||
<div className="launch-brand">GRIZZLYFLIX</div>
|
||||
<span className="launch-badge">COMING SOON</span>
|
||||
<h1>
|
||||
Your next watch.
|
||||
@@ -26,7 +26,7 @@ export default function ComingSoonPage() {
|
||||
<p className="launch-note">We’re getting everything ready. Check back soon.</p>
|
||||
<footer>
|
||||
<strong>Magent</strong>
|
||||
<span>Your media member portal</span>
|
||||
<span>Grizzlyflix member portal</span>
|
||||
<a href="/login">Admin sign in</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -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. Jellyfin is where you watch them."
|
||||
description="Magent looks after your requests. GrizzlyFlix 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> Jellyfin has added the content. Use the watch button to open it.
|
||||
<strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authFetch, getApiBase } from "../lib/auth";
|
||||
import { useEffectiveRole } from "../lib/viewMode";
|
||||
import PageHeading from "../ui/PageHeading";
|
||||
import {
|
||||
type Stats,
|
||||
@@ -21,7 +20,6 @@ export default function InsightsPage() {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<Stats | null>(null);
|
||||
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
@@ -128,11 +126,11 @@ export default function InsightsPage() {
|
||||
</span>
|
||||
<h2>Your viewing story starts here</h2>
|
||||
<p>
|
||||
{isAdmin
|
||||
{data.is_admin
|
||||
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
|
||||
: "Viewing stats will appear here once your administrator connects Jellystat."}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
{data.is_admin && (
|
||||
<a className="stats-action" href="/admin/jellystat">
|
||||
Connect Jellystat
|
||||
</a>
|
||||
|
||||
@@ -5,7 +5,6 @@ import EmailReportControl from "./EmailReportControl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authFetch, getApiBase } from "../../lib/auth";
|
||||
import { useEffectiveRole } from "../../lib/viewMode";
|
||||
import PageHeading from "../../ui/PageHeading";
|
||||
import {
|
||||
type Stats,
|
||||
@@ -68,7 +67,6 @@ export default function MonthlyReportsPage() {
|
||||
const [monthReady, setMonthReady] = useState(false);
|
||||
const [months, setMonths] = useState<string[]>([]);
|
||||
const [data, setData] = useState<MonthlyReport | null>(null);
|
||||
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
@@ -269,11 +267,11 @@ export default function MonthlyReportsPage() {
|
||||
<section className="stats-state">
|
||||
<h2>Your monthly story starts here</h2>
|
||||
<p>
|
||||
{isAdmin
|
||||
{data.is_admin
|
||||
? "Connect Jellystat to bring your monthly viewing reports into Magent."
|
||||
: "Monthly reports will appear once your administrator connects Jellystat."}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
{data.is_admin && (
|
||||
<a className="stats-action" href="/admin/jellystat">
|
||||
Connect Jellystat
|
||||
</a>
|
||||
@@ -287,7 +285,7 @@ export default function MonthlyReportsPage() {
|
||||
Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
|
||||
your user identities.
|
||||
</p>
|
||||
{isAdmin && (
|
||||
{data.is_admin && (
|
||||
<a className="stats-action" href="/admin/identities">
|
||||
Review user identities
|
||||
</a>
|
||||
|
||||
@@ -9,8 +9,6 @@ import type { ReactNode } from "react";
|
||||
import BrandingFavicon from "./ui/BrandingFavicon";
|
||||
import FeatureGate from "./ui/FeatureGate";
|
||||
import ApplicationChrome from "./ui/ApplicationChrome";
|
||||
import SetupGate from "./ui/SetupGate";
|
||||
import AdminViewGate from "./ui/AdminViewGate";
|
||||
|
||||
export const metadata = {
|
||||
title: "Magent",
|
||||
@@ -28,12 +26,8 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<body>
|
||||
<BrandingFavicon />
|
||||
<div className="page">
|
||||
<SetupGate>
|
||||
<ApplicationChrome />
|
||||
<AdminViewGate>
|
||||
<FeatureGate>{children}</FeatureGate>
|
||||
</AdminViewGate>
|
||||
</SetupGate>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEffectiveRole, isAdminPage } from "./user-view-policy";
|
||||
|
||||
describe("user view preview policy", () => {
|
||||
it("downgrades only the displayed administrator role during preview", () => {
|
||||
expect(getEffectiveRole("admin", true)).toBe("user");
|
||||
expect(getEffectiveRole("admin", false)).toBe("admin");
|
||||
for (const role of ["user", null, undefined]) {
|
||||
expect(getEffectiveRole(role, true)).toBe(role);
|
||||
expect(getEffectiveRole(role, false)).toBe(role);
|
||||
}
|
||||
});
|
||||
it("covers configuration, nested admin pages, user management and setup", () => {
|
||||
for (const path of [
|
||||
"/admin",
|
||||
"/admin/",
|
||||
"/admin/backups",
|
||||
"/admin/recaps",
|
||||
"/users",
|
||||
"/users/42",
|
||||
"/setup",
|
||||
"/admin?section=site",
|
||||
"/%61dmin/diagnostics",
|
||||
]) {
|
||||
expect(isAdminPage(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
it("does not restrict normal member pages or similarly named paths", () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/profile",
|
||||
"/profile/invites",
|
||||
"/portal/issues",
|
||||
"/requests/3580",
|
||||
"/insights",
|
||||
"/administrator",
|
||||
"/users-guide",
|
||||
]) {
|
||||
expect(isAdminPage(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
it("keeps public first-install setup separate from admin authentication", () => {
|
||||
expect(isAdminPage("/setup", false)).toBe(false);
|
||||
expect(isAdminPage("/admin/backups", false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
// Preview never promotes a user or changes server-side account permissions.
|
||||
export function getEffectiveRole(role: string | null | undefined, preview: boolean) {
|
||||
return preview && role === "admin" ? "user" : role;
|
||||
}
|
||||
|
||||
export function isAdminPage(pathname: string, includeSetup = true): boolean {
|
||||
let path = pathname.split(/[?#]/, 1)[0];
|
||||
try {
|
||||
path = decodeURIComponent(path);
|
||||
} catch {
|
||||
// Let the router handle malformed URLs; never infer a more privileged role.
|
||||
}
|
||||
path = path.replace(/\/{2,}/g, "/");
|
||||
const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"];
|
||||
return roots.some((root) => path === root || path.startsWith(`${root}/`));
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { getEffectiveRole } from "./user-view-policy";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
|
||||
const USER_VIEW_EVENT = "magent:user-view-change";
|
||||
let fallbackPreview = false;
|
||||
|
||||
const readUserViewPreview = () => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return fallbackPreview;
|
||||
}
|
||||
};
|
||||
|
||||
const applyDocumentMode = (enabled: boolean) => {
|
||||
@@ -23,46 +17,32 @@ const applyDocumentMode = (enabled: boolean) => {
|
||||
|
||||
export const setUserViewPreview = (enabled: boolean) => {
|
||||
if (typeof window === "undefined") return;
|
||||
fallbackPreview = enabled;
|
||||
try {
|
||||
if (enabled) {
|
||||
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
|
||||
} else {
|
||||
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Preview still works for this document when browser storage is unavailable.
|
||||
}
|
||||
applyDocumentMode(enabled);
|
||||
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
|
||||
};
|
||||
|
||||
const subscribe = (notify: () => void) => {
|
||||
window.addEventListener(USER_VIEW_EVENT, notify);
|
||||
window.addEventListener("storage", notify);
|
||||
return () => {
|
||||
window.removeEventListener(USER_VIEW_EVENT, notify);
|
||||
window.removeEventListener("storage", notify);
|
||||
};
|
||||
};
|
||||
|
||||
// Unknown during server rendering/initial hydration: admin pages must not mount
|
||||
// and fetch privileged data before the saved per-tab preview mode is known.
|
||||
const serverSnapshot = (): boolean | null => null;
|
||||
|
||||
export const useUserViewState = () => {
|
||||
const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot);
|
||||
export const useUserViewPreview = () => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== null) applyDocumentMode(value);
|
||||
}, [value]);
|
||||
|
||||
return { enabled: value === true, ready: value !== null };
|
||||
const sync = () => {
|
||||
const nextValue = readUserViewPreview();
|
||||
applyDocumentMode(nextValue);
|
||||
setEnabled(nextValue);
|
||||
};
|
||||
sync();
|
||||
window.addEventListener(USER_VIEW_EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(USER_VIEW_EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
export const useUserViewPreview = () => useUserViewState().enabled;
|
||||
|
||||
export const useEffectiveRole = (role?: string | null) => {
|
||||
const { enabled, ready } = useUserViewState();
|
||||
return getEffectiveRole(role, !ready || enabled);
|
||||
return enabled;
|
||||
};
|
||||
|
||||
@@ -93,8 +93,6 @@ export default function LoginPage() {
|
||||
"/profile#newsletters",
|
||||
"/admin/recaps",
|
||||
"/admin/newsletters",
|
||||
"/setup",
|
||||
"/admin/backups",
|
||||
].includes(next) ||
|
||||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
|
||||
/^\/issues\/confirm\/\d+$/.test(next);
|
||||
@@ -138,7 +136,7 @@ export default function LoginPage() {
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Jellyfin
|
||||
Grizzlyflix
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,7 +162,7 @@ export default function LoginPage() {
|
||||
) : (
|
||||
<form className="account-form login-form" onSubmit={submit}>
|
||||
<p className="login-method-help">
|
||||
{selectedMode === "jellyfin" ? "Use your Jellyfin account." : "Use your Magent account."}
|
||||
{selectedMode === "jellyfin" ? "Use your Grizzlyflix / 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} → Jellyfin</strong>
|
||||
<strong>Seerr → {options.destination.collector} → Grizzlyflix</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">Magent newsletters</span>
|
||||
<span className="recap-eyebrow">Grizzlyflix newsletters</span>
|
||||
<h1>
|
||||
{state === "enabled"
|
||||
? "You’re on the list."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import ResolutionChoice from "../ui/ResolutionChoice";
|
||||
|
||||
import PageHeading from "../ui/PageHeading";
|
||||
import IssueFlowStep from "./IssueFlowStep";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
||||
import { useEffectiveRole } from "../lib/viewMode";
|
||||
import PageHeading from "../ui/PageHeading";
|
||||
import ResolutionChoice from "../ui/ResolutionChoice";
|
||||
import IssueFlowStep from "./IssueFlowStep";
|
||||
|
||||
type PortalPermissions = {
|
||||
can_edit?: boolean;
|
||||
@@ -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 Jellyfin.",
|
||||
description: "A title, season, episode, or expected part is not available in Grizzlyflix.",
|
||||
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: "Jellyfin will not open or every title fails across the device or household.",
|
||||
description: "Grizzlyflix 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: [
|
||||
"Jellyfin will not open",
|
||||
"Grizzlyflix will not open",
|
||||
"Every title fails",
|
||||
"Login works but playback does not",
|
||||
"Server error is shown",
|
||||
@@ -536,16 +536,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
|
||||
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
|
||||
|
||||
const effectiveRole = useEffectiveRole(me?.role);
|
||||
const isAdmin = effectiveRole === "admin";
|
||||
const isOwner = (item: PortalItem) => me?.username === item.created_by_username;
|
||||
const canConfirmResolution = (item: PortalItem) =>
|
||||
Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item)));
|
||||
const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem)));
|
||||
const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate);
|
||||
const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete);
|
||||
const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal);
|
||||
const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added");
|
||||
const isAdmin = me?.role === "admin";
|
||||
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
|
||||
const workspaceLabel = workspace === "request" ? "request" : "issue";
|
||||
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
|
||||
@@ -619,16 +610,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
});
|
||||
const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) return;
|
||||
setDeleteConfirming(false);
|
||||
if (commentInternal) {
|
||||
// Do not turn an unfinished internal note into a public comment when preview changes.
|
||||
setCommentText("");
|
||||
setCommentInternal(false);
|
||||
}
|
||||
}, [isAdmin, commentInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -1354,7 +1335,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
|
||||
const saveItem = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedItem || !canEditSelected) return;
|
||||
if (!selectedItem) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
@@ -1366,7 +1347,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
year: editYear.trim() ? toPositiveInt(editYear) : null,
|
||||
external_ref: editExternalRef || null,
|
||||
};
|
||||
if (canModerateSelected) {
|
||||
if (selectedItem.permissions?.can_moderate) {
|
||||
if (selectedItem.kind === "request") {
|
||||
payload.request_status = editRequestStatus;
|
||||
payload.media_status = editMediaStatus;
|
||||
@@ -1409,7 +1390,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
const postComment = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedItem) return;
|
||||
if (commentInternal && !isAdmin) return;
|
||||
if (!commentText.trim()) {
|
||||
setError("Comment message is required.");
|
||||
return;
|
||||
@@ -1424,7 +1404,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
message: commentText,
|
||||
is_internal: isAdmin && commentInternal,
|
||||
is_internal: commentInternal,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1449,7 +1429,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
};
|
||||
|
||||
const respondToResolution = async (resolved: boolean) => {
|
||||
if (!selectedItem || !canConfirmResolution(selectedItem)) return;
|
||||
if (!selectedItem) return;
|
||||
setRespondingResolution(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
@@ -1485,7 +1465,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
};
|
||||
|
||||
const deleteIssue = async () => {
|
||||
if (selectedItem?.kind !== "issue" || !canDeleteSelected) return;
|
||||
if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return;
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
@@ -1570,7 +1550,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status === "awaiting_confirmation" &&
|
||||
canConfirmResolution(item) &&
|
||||
item.permissions?.can_confirm_resolution &&
|
||||
item.created_by_username === me?.username,
|
||||
)
|
||||
.map((item) => (
|
||||
@@ -1743,7 +1723,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
setSelectedEpisodeIds([]);
|
||||
}
|
||||
}}
|
||||
placeholder="Search the media catalogue"
|
||||
placeholder="Search the Grizzlyflix catalogue"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -2436,7 +2416,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</strong>
|
||||
</div>
|
||||
<div className="issue-modal-toolbar-actions">
|
||||
{canDeleteSelected ? (
|
||||
{selectedItem?.permissions?.can_delete ? (
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
@@ -2462,7 +2442,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<>
|
||||
{selectedItem.kind === "issue" &&
|
||||
selectedItem.status === "awaiting_confirmation" &&
|
||||
canConfirmResolution(selectedItem) && (
|
||||
selectedItem.permissions?.can_confirm_resolution && (
|
||||
<ResolutionChoice
|
||||
title={selectedItem.title}
|
||||
busy={respondingResolution}
|
||||
@@ -2498,7 +2478,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedItem.kind === "issue" && canDeleteSelected && deleteConfirming ? (
|
||||
{selectedItem.kind === "issue" && deleteConfirming ? (
|
||||
<section className="issue-delete-confirmation" aria-live="polite">
|
||||
<div>
|
||||
<span className="section-kicker">Permanent deletion</span>
|
||||
@@ -2549,7 +2529,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<input
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
disabled={!canEditSelected}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
<label className="portal-field-span-2">
|
||||
@@ -2558,7 +2538,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
rows={4}
|
||||
value={editDescription}
|
||||
onChange={(event) => setEditDescription(event.target.value)}
|
||||
disabled={!canEditSelected}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
{selectedItem.kind === "request" ? (
|
||||
@@ -2568,7 +2548,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<select
|
||||
value={editMediaType}
|
||||
onChange={(event) => setEditMediaType(event.target.value)}
|
||||
disabled={!canEditSelected}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
>
|
||||
{MEDIA_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value || "none"} value={option.value}>
|
||||
@@ -2583,7 +2563,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
value={editYear}
|
||||
onChange={(event) => setEditYear(event.target.value)}
|
||||
inputMode="numeric"
|
||||
disabled={!canEditSelected}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
@@ -2593,10 +2573,10 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<input
|
||||
value={editExternalRef}
|
||||
onChange={(event) => setEditExternalRef(event.target.value)}
|
||||
disabled={!canEditSelected}
|
||||
disabled={!selectedItem.permissions?.can_edit}
|
||||
/>
|
||||
</label>
|
||||
{canModerateSelected && (
|
||||
{selectedItem.permissions?.can_moderate && (
|
||||
<>
|
||||
{selectedItem.kind === "request" ? (
|
||||
<>
|
||||
@@ -2672,7 +2652,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</>
|
||||
)}
|
||||
<div className="admin-inline-actions portal-field-span-2">
|
||||
<button type="submit" disabled={saving || !canEditSelected}>
|
||||
<button type="submit" disabled={saving || !selectedItem.permissions?.can_edit}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -2685,13 +2665,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<span className="section-kicker">Recorded work</span>
|
||||
<h3>Issue activity</h3>
|
||||
</div>
|
||||
<span className="small-pill">{visibleActivity.length} events</span>
|
||||
<span className="small-pill">{activity.length} events</span>
|
||||
</div>
|
||||
{visibleActivity.length === 0 ? (
|
||||
{activity.length === 0 ? (
|
||||
<div className="status-banner">No issue activity has been recorded yet.</div>
|
||||
) : (
|
||||
<ol className="issue-activity-list">
|
||||
{visibleActivity.map((entry) => (
|
||||
{activity.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<i aria-hidden="true" />
|
||||
<div>
|
||||
@@ -2710,11 +2690,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
|
||||
<div className="portal-comments-block">
|
||||
<h3>Comments</h3>
|
||||
{visibleComments.length === 0 ? (
|
||||
{comments.length === 0 ? (
|
||||
<div className="status-banner">No comments yet.</div>
|
||||
) : (
|
||||
<div className="portal-comment-list">
|
||||
{visibleComments.map((comment) => (
|
||||
{comments.map((comment) => (
|
||||
<article key={comment.id} className="portal-comment-card">
|
||||
<header>
|
||||
<strong>{comment.author_username}</strong>
|
||||
|
||||
@@ -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 in your library.</h2>
|
||||
<h2 id="newsletter-preference-title">New on Grizzlyflix.</h2>
|
||||
</div>
|
||||
{data && (
|
||||
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
|
||||
|
||||
import PageHeading from "../../ui/PageHeading";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
||||
import { useEffectiveRole } from "../../lib/viewMode";
|
||||
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
|
||||
import PageHeading from "../../ui/PageHeading";
|
||||
|
||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
|
||||
type OwnedInvite = {
|
||||
@@ -76,10 +77,6 @@ export default function ProfileInvitesPage() {
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
|
||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
|
||||
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
|
||||
const effectiveRole = useEffectiveRole(profile?.role);
|
||||
const canManageInvites =
|
||||
effectiveRole === "admin" ||
|
||||
(profile?.role === "admin" ? Boolean(profile.invite_management_enabled) : inviteAccessEnabled);
|
||||
|
||||
const signupBaseUrl = useMemo(() => {
|
||||
if (typeof window === "undefined") return "/signup";
|
||||
@@ -161,7 +158,6 @@ export default function ProfileInvitesPage() {
|
||||
|
||||
const saveInvite = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canManageInvites) return;
|
||||
const inviteName = inviteForm.label.trim();
|
||||
const recipientEmail = inviteForm.recipient_email.trim();
|
||||
if (!inviteName) {
|
||||
@@ -229,7 +225,6 @@ export default function ProfileInvitesPage() {
|
||||
};
|
||||
|
||||
const deleteInvite = async (invite: OwnedInvite) => {
|
||||
if (!canManageInvites) return;
|
||||
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -245,7 +240,6 @@ export default function ProfileInvitesPage() {
|
||||
};
|
||||
|
||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||
if (!canManageInvites) return;
|
||||
try {
|
||||
let usableInvite = invite;
|
||||
if (!invite.code_available) {
|
||||
@@ -269,13 +263,14 @@ export default function ProfileInvitesPage() {
|
||||
|
||||
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
|
||||
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
|
||||
const canManageInvites = profile?.role === "admin" || inviteAccessEnabled;
|
||||
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
|
||||
|
||||
if (loading) return <main className="card">Loading invite workspace…</main>;
|
||||
|
||||
return (
|
||||
<main className="card invites-page">
|
||||
<PageHeading title="Invites" description="Invite someone to your media library and manage the links you share." />
|
||||
<PageHeading title="Invites" description="Invite someone to Grizzlyflix and manage the links you share." />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
@@ -418,7 +413,7 @@ export default function ProfileInvitesPage() {
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
placeholder="Welcome! Use this link to create your account."
|
||||
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
|
||||
/>
|
||||
</label>
|
||||
{flowStep === 2 && (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
|
||||
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
||||
import { canAccess, type FeatureAccess } from "../lib/features";
|
||||
import { useEffectiveRole } from "../lib/viewMode";
|
||||
import PageHeading from "../ui/PageHeading";
|
||||
import MonthlyRecapPreference from "./MonthlyRecapPreference";
|
||||
import NewsletterPreference from "./NewsletterPreference";
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
||||
|
||||
type ProfileInfo = {
|
||||
features?: FeatureAccess;
|
||||
username: string;
|
||||
@@ -200,7 +200,7 @@ export default function ProfilePage() {
|
||||
tone: "status",
|
||||
message:
|
||||
result.provider === "jellyfin"
|
||||
? "Password updated for Jellyfin and Magent. Seerr uses the same password."
|
||||
? "Password updated for Grizzlyflix and Magent. Seerr uses the same password."
|
||||
: "Password updated.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -214,7 +214,6 @@ export default function ProfilePage() {
|
||||
};
|
||||
|
||||
const user = data?.user;
|
||||
const effectiveRole = useEffectiveRole(user?.role);
|
||||
const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
|
||||
const canChangePassword =
|
||||
user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
|
||||
@@ -240,7 +239,7 @@ export default function ProfilePage() {
|
||||
</span>
|
||||
<div>
|
||||
<strong>{user.username}</strong>
|
||||
<span>{effectiveRole === "admin" ? "Administrator" : "Member"}</span>
|
||||
<span>{user.role === "admin" ? "Administrator" : "Member"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -335,15 +334,13 @@ export default function ProfilePage() {
|
||||
<span className="account-connection-dot" aria-hidden="true" />
|
||||
<span>
|
||||
{user.auth_provider === "jellyfin"
|
||||
? "Connected with your Jellyfin account"
|
||||
? "Connected with your Grizzlyflix account"
|
||||
: user.auth_provider === "local"
|
||||
? "Signed in with a Magent account"
|
||||
: "Signed in with your media account"}
|
||||
</span>
|
||||
</div>
|
||||
{canAccess({ ...user, role: effectiveRole ?? undefined }, "stats") && (
|
||||
<MonthlyRecapPreference key={user.email || "no-email"} />
|
||||
)}
|
||||
{canAccess(user, "stats") && <MonthlyRecapPreference key={user.email || "no-email"} />}
|
||||
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
|
||||
</section>
|
||||
|
||||
@@ -358,7 +355,7 @@ export default function ProfilePage() {
|
||||
<h2>Change password</h2>
|
||||
<p>
|
||||
{passwordProvider === "jellyfin"
|
||||
? "One password for Jellyfin, Seerr and Magent."
|
||||
? "One password for Grizzlyflix, Seerr and Magent."
|
||||
: "Keep your Magent account secure."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,8 @@ import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
||||
import { canAccess, type FeatureAccess } from "../../lib/features";
|
||||
import { canAccess } from "../../lib/features";
|
||||
import { lockBodyScroll } from "../../lib/scrollLock";
|
||||
import { useEffectiveRole } from "../../lib/viewMode";
|
||||
import PageHeading from "../../ui/PageHeading";
|
||||
import LatestActivity from "./LatestActivity";
|
||||
import RequestLanguage from "./RequestLanguage";
|
||||
@@ -289,14 +288,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 Jellyfin" : "Media server",
|
||||
label: complete ? "Available to watch" : indexing ? "Adding to Grizzlyflix" : "Media server",
|
||||
state: complete ? "complete" : indexing ? "active" : "waiting",
|
||||
stateLabel: complete ? "Ready" : indexing ? "Indexing" : "Waiting",
|
||||
summary: complete
|
||||
? "This title is ready to watch in Jellyfin."
|
||||
? "This title is ready to watch in Grizzlyflix."
|
||||
: indexing
|
||||
? "The download is complete. Jellyfin is indexing this title now."
|
||||
: "This title has not reached Jellyfin yet.",
|
||||
? "The download is complete. Grizzlyflix is indexing this title now."
|
||||
: "This title has not reached Grizzlyflix yet.",
|
||||
link: snapshot.raw?.jellyfin?.link,
|
||||
},
|
||||
];
|
||||
@@ -330,10 +329,8 @@ export default function RequestTimelinePage() {
|
||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([]);
|
||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([]);
|
||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null);
|
||||
const [viewer, setViewer] = useState<{ role?: string; features?: Partial<FeatureAccess> } | null>(null);
|
||||
const effectiveRole = useEffectiveRole(viewer?.role);
|
||||
const isAdmin = effectiveRole === "admin";
|
||||
const canReportIssues = canAccess(viewer ? { ...viewer, role: effectiveRole ?? undefined } : null, "issues");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [canReportIssues, setCanReportIssues] = useState(false);
|
||||
const [selectedAdditionalSeasons, setSelectedAdditionalSeasons] = useState<number[]>([]);
|
||||
const awaitingMediaIndex = Boolean(
|
||||
snapshot?.presentation?.pipeline?.some((stage) => stage.id === "available" && stage.state === "active"),
|
||||
@@ -392,13 +389,32 @@ export default function RequestTimelinePage() {
|
||||
throw new Error("Unable to verify your request access.");
|
||||
}
|
||||
const me = await meResponse.json();
|
||||
setViewer(me);
|
||||
const viewerIsAdmin = me?.role === "admin";
|
||||
setIsAdmin(viewerIsAdmin);
|
||||
setCanReportIssues(canAccess(me, "issues"));
|
||||
if (!snapshotResponse.ok) {
|
||||
throw new Error(await readApiError(snapshotResponse, "Unable to load this request."));
|
||||
}
|
||||
const snapshotData = await snapshotResponse.json();
|
||||
if (!isSnapshotPayload(snapshotData)) throw new Error("Unable to load this request.");
|
||||
setSnapshot(snapshotData);
|
||||
if (viewerIsAdmin) {
|
||||
const [historyResponse, actionsResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
|
||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
|
||||
]);
|
||||
if (historyResponse.ok) {
|
||||
const historyData = await historyResponse.json();
|
||||
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots);
|
||||
}
|
||||
if (actionsResponse.ok) {
|
||||
const actionsData = await actionsResponse.json();
|
||||
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions);
|
||||
}
|
||||
} else {
|
||||
setHistorySnapshots([]);
|
||||
setHistoryActions([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load this request.");
|
||||
@@ -409,37 +425,6 @@ export default function RequestTimelinePage() {
|
||||
void load();
|
||||
}, [requestId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !requestId) {
|
||||
setShowDetails(false);
|
||||
setHistorySnapshots([]);
|
||||
setHistoryActions([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const [historyResponse, actionsResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`, { signal: controller.signal }),
|
||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`, { signal: controller.signal }),
|
||||
]);
|
||||
if (historyResponse.ok) {
|
||||
const data = await historyResponse.json();
|
||||
if (!controller.signal.aborted && Array.isArray(data.snapshots)) setHistorySnapshots(data.snapshots);
|
||||
}
|
||||
if (actionsResponse.ok) {
|
||||
const data = await actionsResponse.json();
|
||||
if (!controller.signal.aborted && Array.isArray(data.actions)) setHistoryActions(data.actions);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) console.error(error);
|
||||
}
|
||||
};
|
||||
void loadHistory();
|
||||
return () => controller.abort();
|
||||
}, [isAdmin, requestId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken() || !requestId) return;
|
||||
let stopped = false;
|
||||
@@ -1031,13 +1016,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 Jellyfin.</p>
|
||||
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
|
||||
{mediaServerLink ? (
|
||||
<a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">
|
||||
Watch on Jellyfin <span aria-hidden="true">→</span>
|
||||
Watch on Grizzlyflix <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="request-ready-unavailable">The Jellyfin watch link is not configured.</span>
|
||||
<span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { apiUrl, requestJson } from "../lib/api-client";
|
||||
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
|
||||
import MagentMark from "../ui/MagentMark";
|
||||
import SetupTokenHelp from "./SetupTokenHelp";
|
||||
import { serviceStatusLabel } from "../admin/configNavigation";
|
||||
import {
|
||||
ALL_FIELDS,
|
||||
APPS,
|
||||
PREFERENCES,
|
||||
bootstrapApplicationUrl,
|
||||
configuredApp,
|
||||
settingsPayload,
|
||||
settingsValues,
|
||||
type AppDefinition,
|
||||
type Field,
|
||||
type Setting,
|
||||
type SetupState,
|
||||
type SetupStatus,
|
||||
type SetupStep,
|
||||
type Values,
|
||||
} from "./setup-model";
|
||||
import styles from "./setup.module.css";
|
||||
|
||||
type Check = { status: string; message?: string };
|
||||
type CollectorOptions = { rootFolders: { path: string }[]; qualityProfiles: { id: number; name: string }[] };
|
||||
const steps: { id: SetupStep; label: string }[] = [
|
||||
{ id: "administrator", label: "Administrator" },
|
||||
{ id: "apps", label: "Apps" },
|
||||
{ id: "preferences", label: "Preferences" },
|
||||
{ id: "review", label: "Review" },
|
||||
];
|
||||
const json = (body: unknown, method = "POST"): RequestInit => ({
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const message = (error: unknown) =>
|
||||
error instanceof Error ? error.message : "Something went wrong. Please try again.";
|
||||
|
||||
export default function SetupPage() {
|
||||
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [step, setStep] = useState<SetupStep>("administrator");
|
||||
const [settings, setSettings] = useState<Setting[]>([]);
|
||||
const [draft, setDraft] = useState<Values>({});
|
||||
const [ready, setReady] = useState(false);
|
||||
const [admin, setAdmin] = useState(false);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [setupToken, setSetupToken] = useState("");
|
||||
const [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 {
|
||||
const current = await requestJson<SetupStatus>(
|
||||
"/setup/status",
|
||||
{ signal: controller.signal, cache: "no-store" },
|
||||
authFetch,
|
||||
);
|
||||
setStatus(current);
|
||||
if (current.needs_admin) return;
|
||||
const response = await authFetch(apiUrl("/auth/me"), { signal: controller.signal });
|
||||
if (!response.ok) return;
|
||||
const user = await response.json();
|
||||
if (user.role !== "admin") {
|
||||
setForbidden(true);
|
||||
return;
|
||||
}
|
||||
const [progress, config] = await Promise.all([
|
||||
requestJson<SetupState>("/setup/state", { signal: controller.signal }),
|
||||
requestJson<{ settings: Setting[] }>("/admin/settings", { signal: controller.signal }),
|
||||
]);
|
||||
setAdmin(true);
|
||||
setState(progress);
|
||||
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
|
||||
setSettings(config.settings);
|
||||
} catch (failure) {
|
||||
if (!controller.signal.aborted) setError(message(failure));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setReady(true);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Object.keys(draft).length) return;
|
||||
const warn = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [draft]);
|
||||
|
||||
const run = async (name: string, action: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
setBusy(name);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await action();
|
||||
} catch (failure) {
|
||||
if (failure instanceof UnauthorizedError) {
|
||||
setAdmin(false);
|
||||
setForbidden(false);
|
||||
setAccepted(false);
|
||||
setPassword("");
|
||||
setError("Your session expired. Sign in to continue; your unsaved changes are still here.");
|
||||
} else if (failure instanceof ForbiddenError) {
|
||||
setAdmin(false);
|
||||
setForbidden(true);
|
||||
setAccepted(false);
|
||||
} else setError(message(failure));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async (loginPassword = password) => {
|
||||
const result = await requestJson<{ authenticated: boolean; user?: { role: string } }>(
|
||||
"/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ username: username.trim(), password: loginPassword }),
|
||||
},
|
||||
authFetch,
|
||||
);
|
||||
if (!result.authenticated) throw new Error("Could not sign in. Try your administrator credentials again.");
|
||||
setToken("cookie");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
const user = await requestJson<{ role: string }>("/auth/me");
|
||||
if (user.role !== "admin") {
|
||||
setForbidden(true);
|
||||
return;
|
||||
}
|
||||
const [progress, config] = await Promise.all([
|
||||
requestJson<SetupState>("/setup/state"),
|
||||
requestJson<{ settings: Setting[] }>("/admin/settings"),
|
||||
]);
|
||||
setAdmin(true);
|
||||
setForbidden(false);
|
||||
setNotice("");
|
||||
setState(progress);
|
||||
setSettings(config.settings);
|
||||
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
|
||||
};
|
||||
|
||||
const authenticate = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
void run("account", async () => {
|
||||
let loginPassword = password;
|
||||
if (status?.needs_admin) {
|
||||
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,
|
||||
application_url: confirmedApplicationUrl,
|
||||
}),
|
||||
authFetch,
|
||||
);
|
||||
setPassword(loginPassword);
|
||||
setSetupToken("");
|
||||
setStatus({ setup_required: true, needs_admin: false });
|
||||
setNotice("Administrator created. Signing in...");
|
||||
}
|
||||
await signIn(loginPassword);
|
||||
});
|
||||
};
|
||||
|
||||
const switchAccount = () =>
|
||||
void run("switch-account", async () => {
|
||||
await logout();
|
||||
setAdmin(false);
|
||||
setForbidden(false);
|
||||
setAccepted(false);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
setDraft({});
|
||||
setSettings([]);
|
||||
setChecks({});
|
||||
setOptions({});
|
||||
setNotice("Sign in with a Magent administrator account to continue setup.");
|
||||
});
|
||||
|
||||
const save = async (fields: Field[] = ALL_FIELDS) => {
|
||||
const payload = settingsPayload(draft, fields);
|
||||
if (!Object.keys(payload).length) return;
|
||||
if (values.site_login_show_local_login === false && values.site_login_show_jellyfin_login === false) {
|
||||
throw new Error("Keep at least one sign-in method enabled.");
|
||||
}
|
||||
if (values.magent_notify_email_use_tls === true && values.magent_notify_email_use_ssl === true) {
|
||||
throw new Error("Choose STARTTLS or implicit TLS, not both.");
|
||||
}
|
||||
await requestJson("/admin/settings", json(payload, "PUT"));
|
||||
const config = await requestJson<{ settings: Setting[] }>("/admin/settings");
|
||||
setSettings(config.settings);
|
||||
setDraft((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([key]) => !fields.some((field) => field.key === key))),
|
||||
);
|
||||
};
|
||||
|
||||
const go = (next: SetupStep) =>
|
||||
void run("save", async () => {
|
||||
await save();
|
||||
if (!state?.completed) setState(await requestJson<SetupState>("/setup/state", json({ step: next }, "PUT")));
|
||||
setStep(next);
|
||||
setNotice("Settings saved. You can return to finish setup later.");
|
||||
});
|
||||
|
||||
const test = (app: AppDefinition) =>
|
||||
void run(app.id, async () => {
|
||||
await save(app.fields);
|
||||
const check = await requestJson<Check>(`/status/services/${app.id}/test`, { method: "POST" });
|
||||
setChecks((previous) => ({ ...previous, [app.id]: check }));
|
||||
setNotice(`${app.name}: ${serviceStatusLabel(check.status)}${check.message ? ` — ${check.message}` : ""}`);
|
||||
if ((app.id === "sonarr" || app.id === "radarr") && check.status === "up") {
|
||||
const choices = await requestJson<CollectorOptions>(`/admin/${app.id}/options`);
|
||||
setOptions((previous) => ({ ...previous, [app.id]: choices }));
|
||||
}
|
||||
});
|
||||
|
||||
const update = (field: Field, value: string | boolean) => {
|
||||
setDraft((previous) => ({ ...previous, [field.key]: value }));
|
||||
setAccepted(false);
|
||||
setNotice("");
|
||||
const app = APPS.find((candidate) => candidate.fields.some((item) => item.key === field.key));
|
||||
if (app) setChecks((previous) => ({ ...previous, [app.id]: { status: "unchecked" } }));
|
||||
};
|
||||
|
||||
const fieldControl = (field: Field) => {
|
||||
const saved = settings.some((setting) => setting.key === field.key && setting.isSet);
|
||||
const collectorId = field.key.startsWith("sonarr_") ? "sonarr" : "radarr";
|
||||
const choices = options[collectorId];
|
||||
const profile = field.key.endsWith("_quality_profile_id") && choices?.qualityProfiles.length;
|
||||
const folders = field.key.endsWith("_root_folder") && choices?.rootFolders.length;
|
||||
return (
|
||||
<div key={field.key} className={`${styles.field} ${field.type === "checkbox" ? styles.toggle : ""}`}>
|
||||
<label htmlFor={`setup-${field.key}`}>
|
||||
{field.label}
|
||||
{field.type === "password" && saved && <small>Saved securely</small>}
|
||||
</label>
|
||||
{field.type === "checkbox" ? (
|
||||
<input
|
||||
id={`setup-${field.key}`}
|
||||
type="checkbox"
|
||||
checked={values[field.key] === true}
|
||||
onChange={(event) => update(field, event.target.checked)}
|
||||
disabled={!!busy}
|
||||
/>
|
||||
) : field.type === "textarea" ? (
|
||||
<textarea
|
||||
id={`setup-${field.key}`}
|
||||
value={String(values[field.key] ?? "")}
|
||||
onChange={(event) => update(field, event.target.value)}
|
||||
disabled={!!busy}
|
||||
rows={3}
|
||||
/>
|
||||
) : profile ? (
|
||||
<select
|
||||
id={`setup-${field.key}`}
|
||||
value={String(values[field.key] ?? "")}
|
||||
onChange={(event) => update(field, event.target.value)}
|
||||
disabled={!!busy}
|
||||
>
|
||||
<option value="">Choose a profile</option>
|
||||
{choices.qualityProfiles.map((choice) => (
|
||||
<option key={choice.id} value={choice.id}>
|
||||
{choice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id={`setup-${field.key}`}
|
||||
type={field.type || "text"}
|
||||
value={String(values[field.key] ?? "")}
|
||||
onChange={(event) => update(field, event.target.value)}
|
||||
disabled={!!busy}
|
||||
autoComplete={field.type === "password" ? "new-password" : "off"}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
placeholder={
|
||||
field.type === "password" && saved ? "Leave blank to keep saved credential" : field.placeholder
|
||||
}
|
||||
list={folders ? `options-${field.key}` : undefined}
|
||||
/>
|
||||
)}
|
||||
{folders ? (
|
||||
<datalist id={`options-${field.key}`}>
|
||||
{choices.rootFolders.map((folder) => (
|
||||
<option key={folder.path} value={folder.path} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
{field.hint && <p>{field.hint}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.setup}>
|
||||
<header className={styles.heading}>
|
||||
<div className={styles.brand}>
|
||||
<MagentMark />
|
||||
<span>Magent / Installation</span>
|
||||
</div>
|
||||
<h1>Set up Magent</h1>
|
||||
<p>Connect your media apps, choose your settings and make yourself at home.</p>
|
||||
</header>
|
||||
{!ready ? (
|
||||
<p role="status">Checking installation...</p>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{!status ? (
|
||||
<button type="button" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</button>
|
||||
) : forbidden ? (
|
||||
<section className={`${styles.panel} ${styles.accountPanel}`}>
|
||||
<h2>Administrator access required</h2>
|
||||
<p>Ask an administrator to finish installation.</p>
|
||||
<button type="button" disabled={!!busy} onClick={switchAccount}>
|
||||
{busy === "switch-account" ? "Signing out..." : "Sign in with an administrator account"}
|
||||
</button>
|
||||
</section>
|
||||
) : !admin ? (
|
||||
<section className={`${styles.panel} ${styles.accountPanel}`}>
|
||||
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
|
||||
<p>
|
||||
{status.needs_admin
|
||||
? "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>
|
||||
<input
|
||||
id="setup-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
required
|
||||
minLength={32}
|
||||
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}>
|
||||
<label htmlFor="setup-username">Username</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
autoComplete="username"
|
||||
required
|
||||
maxLength={100}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
disabled={!!busy}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="setup-password">Password</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
autoComplete={status.needs_admin ? "new-password" : "current-password"}
|
||||
required
|
||||
minLength={status.needs_admin ? 12 : undefined}
|
||||
maxLength={1024}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={!!busy}
|
||||
/>
|
||||
</div>
|
||||
{status.needs_admin && (
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="setup-confirm">Confirm password</label>
|
||||
<input
|
||||
id="setup-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={12}
|
||||
maxLength={1024}
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
disabled={!!busy}
|
||||
/>
|
||||
<p>Use at least 12 characters and a unique password.</p>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" disabled={!!busy}>
|
||||
{busy ? "Working..." : status.needs_admin ? "Create administrator" : "Sign in to continue"}
|
||||
</button>
|
||||
</form>
|
||||
{!status.setup_required && <a href="/login?next=/setup">Use Jellyfin sign-in instead</a>}
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{state?.completed ? (
|
||||
<p className={styles.notice}>
|
||||
This installation is already set up. You can use this guide to update its connections.{" "}
|
||||
<a href="/admin">Back to settings</a>
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.notice}>
|
||||
Your administrator is ready. Background imports and automation are paused until you finish. Already
|
||||
have a backup? <a href="/admin/backups">Restore it here</a>.
|
||||
</p>
|
||||
)}
|
||||
<nav aria-label="Setup steps" className={styles.steps}>
|
||||
{steps.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-current={step === item.id ? "step" : undefined}
|
||||
disabled={!!busy || item.id === "administrator"}
|
||||
onClick={() => go(item.id)}
|
||||
>
|
||||
<span>{index + 1}</span>
|
||||
{item.id === "administrator" ? "Administrator ready" : item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
go(step === "apps" ? "preferences" : "review");
|
||||
}}
|
||||
>
|
||||
{step === "apps" && (
|
||||
<section aria-labelledby="apps-title">
|
||||
<h2 id="apps-title">Connect your apps</h2>
|
||||
<p>
|
||||
Each app is optional. Expand the apps you use, save and test their connections, then continue. In
|
||||
Docker, localhost means the Magent container itself.
|
||||
</p>
|
||||
<div className={styles.apps}>
|
||||
{APPS.map((app) => (
|
||||
<details key={app.id} className={styles.panel}>
|
||||
<summary>
|
||||
<span>
|
||||
<strong>{app.name}</strong>
|
||||
<small>{app.description}</small>
|
||||
</span>
|
||||
<span className={styles.badge}>
|
||||
{checks[app.id]
|
||||
? serviceStatusLabel(checks[app.id].status)
|
||||
: configuredApp(app, settings)
|
||||
? "Configured"
|
||||
: "Optional / not set up"}
|
||||
</span>
|
||||
</summary>
|
||||
<div className={styles.fields}>{app.fields.map(fieldControl)}</div>
|
||||
<button type="button" disabled={!!busy} onClick={() => test(app)}>
|
||||
{busy === app.id ? "Testing..." : `Save & test ${app.name}`}
|
||||
</button>
|
||||
{checks[app.id]?.message && <p role="status">{checks[app.id].message}</p>}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{step === "preferences" && (
|
||||
<section aria-labelledby="preferences-title">
|
||||
<h2 id="preferences-title">Choose your preferences</h2>
|
||||
<p>
|
||||
Defaults are loaded from your installation. Advanced notification channels, branding and invite
|
||||
policies are available in Settings afterwards.
|
||||
</p>
|
||||
{PREFERENCES.map((group) => (
|
||||
<section key={group.title} className={styles.panel}>
|
||||
<h3>{group.title}</h3>
|
||||
<div className={styles.fields}>{group.fields.map(fieldControl)}</div>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<section className={styles.panel} aria-labelledby="review-title">
|
||||
<h2 id="review-title">Ready to finish?</h2>
|
||||
<p>Unconfigured apps remain disconnected. You can change every connection later in Settings.</p>
|
||||
<ul className={styles.review}>
|
||||
{APPS.map((app) => (
|
||||
<li key={app.id}>
|
||||
<span>{app.name}</span>
|
||||
<span>
|
||||
{checks[app.id]
|
||||
? serviceStatusLabel(checks[app.id].status)
|
||||
: configuredApp(app, settings)
|
||||
? "Configured (not tested this session)"
|
||||
: "Not configured"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
Finishing starts the configured background imports and automation, unless disabled in your
|
||||
deployment. Save an encrypted backup once you have checked the installation.
|
||||
</p>
|
||||
<label className={styles.confirm}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accepted}
|
||||
onChange={(event) => setAccepted(event.target.checked)}
|
||||
disabled={!!busy}
|
||||
/>
|
||||
I have reviewed the connections and want to finish setup.
|
||||
</label>
|
||||
<p className={styles.hint}>
|
||||
You may remove 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>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
{step !== "apps" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={!!busy}
|
||||
onClick={() => go(step === "review" ? "preferences" : "apps")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<span>{Object.keys(draft).length ? "Unsaved changes" : "Progress is saved"}</span>
|
||||
{step !== "review" ? (
|
||||
<button type="submit" disabled={!!busy}>
|
||||
{busy === "save" ? "Saving..." : "Save & continue"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || !accepted}
|
||||
onClick={() =>
|
||||
void run("finish", async () => {
|
||||
await save();
|
||||
await requestJson("/setup/complete", { method: "POST" });
|
||||
window.location.assign("/admin");
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "finish" ? "Finishing..." : "Finish setup"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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", () => {
|
||||
expect(APPS.map((app) => app.id).sort()).toEqual([
|
||||
"bazarr",
|
||||
"jellyfin",
|
||||
"jellystat",
|
||||
"prowlarr",
|
||||
"qbittorrent",
|
||||
"radarr",
|
||||
"seerr",
|
||||
"sonarr",
|
||||
]);
|
||||
});
|
||||
it("never copies saved secrets into the form or overwrites them with a blank", () => {
|
||||
expect(settingsValues([{ key: "sonarr_api_key", value: "secret", sensitive: true, isSet: true }])).toEqual({
|
||||
sonarr_api_key: "",
|
||||
});
|
||||
expect(settingsPayload({ sonarr_api_key: "", sonarr_base_url: "http://sonarr:8989" })).toEqual({
|
||||
sonarr_base_url: "http://sonarr:8989",
|
||||
});
|
||||
});
|
||||
it("sends only editable fields and validates numeric settings", () => {
|
||||
expect(
|
||||
settingsPayload({ jwt_secret: "no", requests_cleanup_days: "90", site_login_show_signup_link: false }),
|
||||
).toEqual({ requests_cleanup_days: 90, site_login_show_signup_link: false });
|
||||
expect(() => settingsPayload({ requests_cleanup_days: "-1" })).toThrow("whole number");
|
||||
expect(() => settingsPayload({ sonarr_quality_profile_id: "1.5" })).toThrow("whole number");
|
||||
});
|
||||
it("can save just one app without accidentally saving another draft", () => {
|
||||
expect(
|
||||
settingsPayload(
|
||||
{ sonarr_base_url: "http://sonarr:8989", radarr_api_key: "draft-secret" },
|
||||
APPS.find((app) => app.id === "sonarr")?.fields,
|
||||
),
|
||||
).toEqual({ sonarr_base_url: "http://sonarr:8989" });
|
||||
});
|
||||
it("validates URL drafts even when app testing bypasses browser form validation", () => {
|
||||
for (const value of [
|
||||
"sonarr:8989",
|
||||
"/sonarr",
|
||||
"ftp://sonarr:8989",
|
||||
"javascript:alert(1)",
|
||||
"http://sonarr/my library",
|
||||
]) {
|
||||
expect(() => settingsPayload({ sonarr_base_url: value })).toThrow("HTTP or HTTPS URL");
|
||||
}
|
||||
expect(() => settingsPayload({ sonarr_base_url: "https://user:secret@sonarr.test" })).toThrow("credential fields");
|
||||
expect(
|
||||
settingsPayload({
|
||||
sonarr_base_url: " http://sonarr:8989 ",
|
||||
magent_application_url: "https://magent.example.test",
|
||||
}),
|
||||
).toEqual({ sonarr_base_url: "http://sonarr:8989", magent_application_url: "https://magent.example.test" });
|
||||
expect(settingsPayload({ sonarr_base_url: "" })).toEqual({ sonarr_base_url: "" });
|
||||
});
|
||||
it("validates sender email and sync time before step navigation saves", () => {
|
||||
for (const value of ["not-an-email", "two@@example.test", "name@example test", "Name <name@example.test>"]) {
|
||||
expect(() => settingsPayload({ magent_notify_email_from_address: value })).toThrow("valid email address");
|
||||
}
|
||||
for (const value of ["24:00", "12:60", "2:30", "02:30:00"]) {
|
||||
expect(() => settingsPayload({ requests_full_sync_time: value })).toThrow("HH:MM");
|
||||
}
|
||||
expect(
|
||||
settingsPayload({
|
||||
magent_notify_email_from_address: " alerts+admin@example.test ",
|
||||
requests_full_sync_time: "23:59",
|
||||
}),
|
||||
).toEqual({ magent_notify_email_from_address: "alerts+admin@example.test", requests_full_sync_time: "23:59" });
|
||||
expect(settingsPayload({ magent_notify_email_from_address: "", requests_full_sync_time: "" })).toEqual({
|
||||
magent_notify_email_from_address: "",
|
||||
requests_full_sync_time: "",
|
||||
});
|
||||
});
|
||||
it("does not call a URL-only app configured", () => {
|
||||
const app = APPS[0];
|
||||
const url = { key: "jellyfin_base_url", value: "http://jellyfin:8096", sensitive: false, isSet: true };
|
||||
expect(configuredApp(app, [url])).toBe(false);
|
||||
expect(configuredApp(app, [url, { key: "jellyfin_api_key", value: null, sensitive: true, isSet: true }])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
export type SetupStep = "administrator" | "apps" | "preferences" | "review";
|
||||
export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null };
|
||||
export type SetupStatus = { setup_required: boolean; needs_admin: boolean };
|
||||
export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean };
|
||||
export type Values = Record<string, string | boolean>;
|
||||
export type Field = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea";
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
export type AppDefinition = { id: string; name: string; description: string; fields: Field[] };
|
||||
|
||||
const connection = (prefix: string, placeholder: string): Field[] => [
|
||||
{
|
||||
key: `${prefix}_base_url`,
|
||||
label: "Server URL",
|
||||
type: "url",
|
||||
placeholder,
|
||||
hint: "Use an address reachable from the Magent server, not your browser.",
|
||||
},
|
||||
{ key: `${prefix}_api_key`, label: "API key", type: "password" },
|
||||
];
|
||||
const collector = (prefix: string): Field[] => [
|
||||
{
|
||||
key: `${prefix}_quality_profile_id`,
|
||||
label: "Quality profile ID",
|
||||
type: "number",
|
||||
min: 1,
|
||||
hint: "Save and test the connection to load available profiles.",
|
||||
},
|
||||
{
|
||||
key: `${prefix}_root_folder`,
|
||||
label: "Root folder",
|
||||
hint: "The library path as seen by this app, for example /tv or /movies.",
|
||||
},
|
||||
{
|
||||
key: `${prefix}_qbittorrent_category`,
|
||||
label: "Download category",
|
||||
hint: "Match the category configured in the app's download client.",
|
||||
},
|
||||
];
|
||||
|
||||
export const APPS: AppDefinition[] = [
|
||||
{
|
||||
id: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
description: "Playback, library availability and Jellyfin sign-in.",
|
||||
fields: [
|
||||
...connection("jellyfin", "http://jellyfin:8096"),
|
||||
{
|
||||
key: "jellyfin_public_url",
|
||||
label: "Public playback URL",
|
||||
type: "url",
|
||||
hint: "The address your users open to watch media.",
|
||||
},
|
||||
{
|
||||
key: "jellyfin_sync_to_arr",
|
||||
label: "Sync Jellyfin library into Sonarr / Radarr",
|
||||
type: "checkbox",
|
||||
hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seerr",
|
||||
name: "Seerr",
|
||||
description: "Requests, approvals and request history (including Jellyseerr).",
|
||||
fields: connection("jellyseerr", "http://seerr:5055"),
|
||||
},
|
||||
{
|
||||
id: "sonarr",
|
||||
name: "Sonarr",
|
||||
description: "TV requests, seasons and collection progress.",
|
||||
fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")],
|
||||
},
|
||||
{
|
||||
id: "radarr",
|
||||
name: "Radarr",
|
||||
description: "Movie requests and collection progress.",
|
||||
fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")],
|
||||
},
|
||||
{
|
||||
id: "prowlarr",
|
||||
name: "Prowlarr",
|
||||
description: "Indexer searches and release discovery.",
|
||||
fields: connection("prowlarr", "http://prowlarr:9696"),
|
||||
},
|
||||
{
|
||||
id: "qbittorrent",
|
||||
name: "qBittorrent",
|
||||
description: "Download progress and recovery actions.",
|
||||
fields: [
|
||||
{ key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" },
|
||||
{ key: "qbittorrent_username", label: "Username" },
|
||||
{ key: "qbittorrent_password", label: "Password", type: "password" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bazarr",
|
||||
name: "Bazarr",
|
||||
description: "Optional subtitle searches and repairs.",
|
||||
fields: [
|
||||
...connection("bazarr", "http://bazarr:6767"),
|
||||
{ key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "jellystat",
|
||||
name: "Jellystat",
|
||||
description: "Optional personal viewing statistics.",
|
||||
fields: connection("jellystat", "http://jellystat:3000"),
|
||||
},
|
||||
];
|
||||
|
||||
export const PREFERENCES: { title: string; fields: Field[] }[] = [
|
||||
{
|
||||
title: "Site & access",
|
||||
fields: [
|
||||
{
|
||||
key: "magent_application_url",
|
||||
label: "Public Magent URL",
|
||||
type: "url",
|
||||
hint: "Used in invite and notification links. 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" },
|
||||
{
|
||||
key: "site_login_show_local_login",
|
||||
label: "Show Magent account sign-in",
|
||||
type: "checkbox",
|
||||
hint: "Keep this enabled for local administrator access.",
|
||||
},
|
||||
{ key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" },
|
||||
{
|
||||
key: "site_login_show_signup_link",
|
||||
label: "Show invite signup link",
|
||||
type: "checkbox",
|
||||
hint: "Account creation still requires a valid invite. This does not open public registration.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Request updates",
|
||||
fields: [
|
||||
{ key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 },
|
||||
{
|
||||
key: "requests_delta_sync_interval_minutes",
|
||||
label: "Incremental sync interval (minutes)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
},
|
||||
{ key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" },
|
||||
{ key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Email (optional)",
|
||||
fields: [
|
||||
{ key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" },
|
||||
{
|
||||
key: "magent_notify_email_enabled",
|
||||
label: "Enable email delivery",
|
||||
type: "checkbox",
|
||||
hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.",
|
||||
},
|
||||
{ key: "magent_notify_email_smtp_host", label: "SMTP hostname" },
|
||||
{ key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 },
|
||||
{ key: "magent_notify_email_smtp_username", label: "SMTP username" },
|
||||
{ key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" },
|
||||
{ key: "magent_notify_email_from_address", label: "Sender email", type: "email" },
|
||||
{ key: "magent_notify_email_from_name", label: "Sender name" },
|
||||
{ key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" },
|
||||
{
|
||||
key: "magent_notify_email_use_ssl",
|
||||
label: "Use implicit TLS (usually port 465)",
|
||||
type: "checkbox",
|
||||
hint: "Choose either STARTTLS or implicit TLS, not both.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
|
||||
|
||||
export function 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) {
|
||||
const setting = settings.find((candidate) => candidate.key === field.key);
|
||||
if (!setting) continue;
|
||||
values[field.key] =
|
||||
field.type === "password" || setting.sensitive
|
||||
? ""
|
||||
: field.type === "checkbox"
|
||||
? setting.value === true || setting.value === "true" || setting.value === "1"
|
||||
: String(setting.value ?? "");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// Only explicitly edited fields are sent. A blank password never clears a saved
|
||||
// secret (masked values from the settings endpoint are not actual credentials).
|
||||
export function settingsPayload(
|
||||
draft: Values,
|
||||
fields: Field[] = ALL_FIELDS,
|
||||
): Record<string, string | boolean | number> {
|
||||
const payload: Record<string, string | boolean | number> = {};
|
||||
for (const field of fields) {
|
||||
const value = draft[field.key];
|
||||
if (value === undefined || (field.type === "password" && !String(value).trim())) continue;
|
||||
if (field.type === "url" || field.type === "email" || field.type === "time") {
|
||||
const text = String(value).trim();
|
||||
if (text && field.type === "url") {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
|
||||
}
|
||||
if (
|
||||
!/^https?:\/\//i.test(text) ||
|
||||
!["http:", "https:"].includes(url.protocol) ||
|
||||
!url.hostname ||
|
||||
/\s/.test(text)
|
||||
) {
|
||||
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
|
||||
}
|
||||
if (url.username || url.password)
|
||||
throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`);
|
||||
}
|
||||
if (
|
||||
text &&
|
||||
field.type === "email" &&
|
||||
!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
throw new Error(`${field.label} must be a valid email address.`);
|
||||
}
|
||||
if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
|
||||
throw new Error(`${field.label} must be a valid time in HH:MM format.`);
|
||||
}
|
||||
payload[field.key] = text;
|
||||
} else if (field.type === "number" && value !== "") {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) {
|
||||
throw new Error(
|
||||
`${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`,
|
||||
);
|
||||
}
|
||||
payload[field.key] = number;
|
||||
} else payload[field.key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function configuredApp(app: AppDefinition, settings: Setting[]): boolean {
|
||||
return app.fields
|
||||
.filter((field) => field.key.endsWith("_base_url") || field.type === "password")
|
||||
.every((field) => settings.some((setting) => setting.key === field.key && setting.isSet));
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
|
||||
.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; 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); }
|
||||
.panel[open] summary::after { content: "−"; }
|
||||
.panel summary > span:first-child { flex: 1; }
|
||||
.panel summary strong { display: block; font-size: 17px; }
|
||||
.panel summary small { display: block; margin-top: 6px; color: var(--ops-muted); line-height: 1.5; }
|
||||
.panel[open] summary { margin-bottom: 24px; }
|
||||
.badge { font-size: 12px; color: var(--ops-primary-2); }
|
||||
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.field label { color: var(--ops-text); font-size: 13px; }
|
||||
.field label small { margin-left: 8px; color: var(--ops-green); }
|
||||
.field p, .hint { font-size: 12px; margin: 0; }
|
||||
.field input:not([type=checkbox]), .field textarea, .field select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); border-radius: 8px; font: inherit; font-size: 14px; }
|
||||
.field textarea { resize: vertical; }
|
||||
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
|
||||
.toggle p { grid-column: 1 / -1; }
|
||||
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
|
||||
.account { display: grid; gap: 20px; 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); }
|
||||
.steps button span { font-size: 12px; }
|
||||
.actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--ops-line); }
|
||||
.actions > span { flex: 1; color: var(--ops-muted); font-size: 12px; }
|
||||
.error, .notice { padding: 16px 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-bg-2); overflow-wrap: anywhere; }
|
||||
.setup .error { border-color: var(--ops-red); color: var(--ops-red); }
|
||||
.review { list-style: none; padding: 0; margin: 24px 0; }
|
||||
.review li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--ops-line); }
|
||||
.review li span:last-child { font-size: 13px; color: var(--ops-muted); text-align: right; }
|
||||
.confirm { display: flex; align-items: center; gap: 12px; margin: 24px 0; }
|
||||
.setup :is(button, input, textarea, select, a, summary):focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 3px; }
|
||||
.setup button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
@media (max-width: 640px) {
|
||||
.setup { margin-top: 20px; padding: 0 4px; }
|
||||
.fields { grid-template-columns: 1fr; gap: 20px; }
|
||||
.panel { padding: 18px; }
|
||||
.tokenHelpDialog { padding: 18px; }
|
||||
.steps button { flex-basis: 42%; font-size: 12px; }
|
||||
.badge { max-width: 100px; text-align: right; }
|
||||
.panel summary { gap: 10px; }
|
||||
}
|
||||
@@ -143,7 +143,7 @@ function SignupPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
|
||||
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
|
||||
<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 your media library.">
|
||||
<AuthLayout title="Create account" description="Your invite is the first step to Grizzlyflix.">
|
||||
<p role="status">Loading sign-up…</p>
|
||||
</AuthLayout>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user