Compare commits
34
Commits
38169b881e
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153ac86a5a | ||
|
|
fd6671cf7e | ||
|
|
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 |
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Copy to .env for local development. Never reuse these example values in a deployed environment.
|
||||||
|
APP_NAME=Magent
|
||||||
|
CORS_ALLOW_ORIGIN=http://localhost:3000
|
||||||
|
MAGENT_APPLICATION_URL=http://localhost:3000
|
||||||
|
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 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=
|
||||||
|
|
||||||
|
AUTH_COOKIE_SECURE=false
|
||||||
|
AUTH_COOKIE_SAMESITE=strict
|
||||||
|
API_DOCS_ENABLED=false
|
||||||
+39
-37
@@ -6,6 +6,10 @@ on:
|
|||||||
- beta
|
- beta
|
||||||
- main
|
- main
|
||||||
- prod
|
- prod
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- beta
|
||||||
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -17,15 +21,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.14"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
|
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
|
||||||
@@ -37,40 +41,40 @@ jobs:
|
|||||||
- name: Run backend quality gate
|
- name: Run backend quality gate
|
||||||
run: bash scripts/ci_backend_quality_gate.sh
|
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
|
- name: Build frontend
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
deploy-prod:
|
- name: Validate Compose configuration
|
||||||
if: github.ref_name == 'prod'
|
|
||||||
needs: verify
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@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: |
|
run: |
|
||||||
set -euo pipefail
|
cp .env.example .env
|
||||||
mkdir -p ~/.ssh
|
docker compose -f docker-compose.yml config --quiet
|
||||||
chmod 700 ~/.ssh
|
|
||||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy to AMS-DEV01
|
- name: Build and smoke-test container
|
||||||
env:
|
run: bash scripts/ci_container_smoke.sh
|
||||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
|
||||||
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
|
||||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
|
||||||
run: bash scripts/deploy_ams_dev01.sh
|
|
||||||
|
|
||||||
deploy-beta:
|
deploy-beta:
|
||||||
if: github.ref_name == 'beta'
|
if: github.ref_name == 'beta'
|
||||||
@@ -78,7 +82,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Configure SSH key
|
- name: Configure SSH key
|
||||||
env:
|
env:
|
||||||
@@ -86,19 +90,17 @@ jobs:
|
|||||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
chmod 700 ~/.ssh
|
chmod 700 ~/.ssh
|
||||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
|
||||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||||
chmod 644 ~/.ssh/known_hosts
|
chmod 644 ~/.ssh/known_hosts
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy beta to AMS-DEV01
|
- name: Deploy beta to AMS-DEV01
|
||||||
env:
|
env:
|
||||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||||
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
|
||||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
|
||||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
run: bash scripts/deploy_beta_ams_dev01.sh
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.env
|
.env
|
||||||
bootstrap-admin.json
|
bootstrap-admin.json
|
||||||
.venv/
|
.venv/
|
||||||
|
.security-test-venv*/
|
||||||
data/
|
data/
|
||||||
!data/branding/
|
!data/branding/
|
||||||
!data/branding/**
|
!data/branding/**
|
||||||
@@ -8,8 +9,12 @@ backend/__pycache__/
|
|||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
|
*.tsbuildinfo
|
||||||
*.log
|
*.log
|
||||||
**/.pytest_cache/
|
**/.pytest_cache/
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
+27
-12
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-slim AS frontend-builder
|
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
@@ -13,11 +13,12 @@ COPY frontend/app ./app
|
|||||||
COPY frontend/public ./public
|
COPY frontend/public ./public
|
||||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
COPY frontend/next-env.d.ts ./next-env.d.ts
|
||||||
COPY frontend/next.config.js ./next.config.js
|
COPY frontend/next.config.js ./next.config.js
|
||||||
|
COPY frontend/proxy.ts ./proxy.ts
|
||||||
COPY frontend/tsconfig.json ./tsconfig.json
|
COPY frontend/tsconfig.json ./tsconfig.json
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM python:3.14-slim
|
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -32,22 +33,36 @@ RUN apt-get update \
|
|||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG MAGENT_UID=1000
|
||||||
|
ARG MAGENT_GID=1000
|
||||||
|
RUN groupadd --gid ${MAGENT_GID} magent \
|
||||||
|
&& useradd --uid ${MAGENT_UID} --gid magent --create-home --shell /usr/sbin/nologin magent
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY backend/app ./app
|
COPY --chown=magent:magent backend/app ./app
|
||||||
COPY data/branding /app/data/branding
|
COPY --chown=magent:magent data/branding /app/data/branding
|
||||||
|
|
||||||
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
|
COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
|
||||||
COPY --from=frontend-builder /frontend/public /app/frontend/public
|
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
|
||||||
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
||||||
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
||||||
COPY --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
||||||
COPY --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
|
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
|
||||||
COPY --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
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 --chown=magent:magent docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
||||||
|
|
||||||
|
RUN chown -R magent:magent /app
|
||||||
|
USER magent:magent
|
||||||
|
|
||||||
EXPOSE 3000 8000
|
EXPOSE 3000 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
|
||||||
|
CMD curl --fail --silent --show-error http://127.0.0.1:8000/health >/dev/null \
|
||||||
|
&& curl --fail --silent --show-error http://127.0.0.1:3000/login >/dev/null \
|
||||||
|
|| exit 1
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from `main`; use `prod-<short-commit>` tags to identify an exact release.
|
|||||||
|
|
||||||
1. Run the backend tests and frontend production build. Review only the intended
|
1. Run the backend tests and frontend production build. Review only the intended
|
||||||
changes, then commit and push `main`.
|
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
|
2. Build from a clean source export using the root Dockerfile. Never include
|
||||||
`.env`, databases or bootstrap credentials in the build context.
|
`.env`, databases or bootstrap credentials in the build context.
|
||||||
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
|
3. Publish `rephl3xnz/magent:prod-<short-commit>` and `:latest` to Docker Hub.
|
||||||
@@ -33,6 +35,14 @@ from `main`; use `prod-<short-commit>` tags to identify an exact release.
|
|||||||
feature, database integrity and account counts. Do not trigger bulk permission
|
feature, database integrity and account counts. Do not trigger bulk permission
|
||||||
changes, email sends or user imports as a deployment smoke test.
|
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
|
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
|
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.
|
shared Compose or Caddy file without checking for unrelated changes first.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Magent is a friendly, AI-assisted request tracker for Seerr + Arr services. It s
|
|||||||
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
|
- Personal viewing stats from Jellystat: minutes, movies, episodes, streaks, and recent plays alongside requests. See [Jellystat setup](docs/jellystat-integration.md).
|
||||||
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
|
- Admin review and confirmation of account IDs across Jellyfin, Seerr, Jellystat and Magent. See [user identities](docs/user-identities.md).
|
||||||
- Docker-first deployment for easy hosting.
|
- Docker-first deployment for easy hosting.
|
||||||
|
- Guided, resumable first-install setup with app connection tests.
|
||||||
|
- Encrypted backups of configuration, database and optional artwork cache, with restart-only restore.
|
||||||
|
|
||||||
## Quick start (Docker - primary)
|
## Quick start (Docker - primary)
|
||||||
|
|
||||||
@@ -42,10 +44,17 @@ Then open:
|
|||||||
|
|
||||||
### Docker setup steps
|
### Docker setup steps
|
||||||
|
|
||||||
1) Create `.env` with your service URLs and API keys.
|
1) Copy `.env.example` to `.env`. Generate independent `JWT_SECRET`, `SETTINGS_ENCRYPTION_KEY` and `SETUP_TOKEN` values as described below. Do not use the example placeholders.
|
||||||
2) Run `docker compose up --build`.
|
2) Set `CORS_ALLOW_ORIGIN` and `MAGENT_APPLICATION_URL` to your browser-facing origin. For public deployments, use HTTPS and `AUTH_COOKIE_SECURE=true`.
|
||||||
3) Log in at http://localhost:3000.
|
3) Run `docker compose up --build`.
|
||||||
4) Visit Settings to confirm service health.
|
4) Open http://localhost:3000. A fresh database opens the setup wizard automatically. Use your `SETUP_TOKEN` to create a local administrator, then connect and test each app you use.
|
||||||
|
5) Choose site, sign-in, request-sync and email preferences, review the connections, and finish setup. Remove `SETUP_TOKEN` from the deployment environment afterwards.
|
||||||
|
|
||||||
|
Apps may be skipped and configured later. Progress is saved in SQLite. Background imports and automation remain paused until setup is complete; `BACKGROUND_TASKS_ENABLED=false` still takes precedence. Existing installations are automatically treated as configured and are not forced through the wizard. Administrators can reopen it at **Settings → Advanced tools → Setup wizard**.
|
||||||
|
|
||||||
|
If you prefer to seed an administrator through deployment configuration, set a unique `ADMIN_USERNAME` and `ADMIN_PASSWORD` instead of `SETUP_TOKEN`. The wizard then asks you to sign in with that account. Environment credentials create only the first administrator; they do not add another account to a restored installation. Service URLs and API keys can still be supplied through the environment, and the wizard preloads these settings without exposing saved secrets.
|
||||||
|
|
||||||
|
See [installation and recovery](docs/installation-and-recovery.md) for migration, backup limits and restore instructions.
|
||||||
|
|
||||||
### Docker environment variables (sample)
|
### Docker environment variables (sample)
|
||||||
|
|
||||||
@@ -66,8 +75,9 @@ QBIT_URL="http://localhost:8080"
|
|||||||
QBIT_USERNAME="..."
|
QBIT_USERNAME="..."
|
||||||
QBIT_PASSWORD="..."
|
QBIT_PASSWORD="..."
|
||||||
SQLITE_PATH="data/magent.db"
|
SQLITE_PATH="data/magent.db"
|
||||||
JWT_SECRET="replace-with-a-long-random-secret"
|
JWT_SECRET="replace-with-at-least-32-random-characters"
|
||||||
JWT_EXP_MINUTES="720"
|
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
|
||||||
|
JWT_EXP_MINUTES="120"
|
||||||
ADMIN_USERNAME="set-a-real-admin-username"
|
ADMIN_USERNAME="set-a-real-admin-username"
|
||||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||||
```
|
```
|
||||||
@@ -114,8 +124,9 @@ $env:QBIT_URL="http://localhost:8080"
|
|||||||
$env:QBIT_USERNAME="..."
|
$env:QBIT_USERNAME="..."
|
||||||
$env:QBIT_PASSWORD="..."
|
$env:QBIT_PASSWORD="..."
|
||||||
$env:SQLITE_PATH="data/magent.db"
|
$env:SQLITE_PATH="data/magent.db"
|
||||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
|
||||||
$env:JWT_EXP_MINUTES="720"
|
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
|
||||||
|
$env:JWT_EXP_MINUTES="120"
|
||||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||||
```
|
```
|
||||||
@@ -134,6 +145,19 @@ Admin panel: http://localhost:3000/admin
|
|||||||
|
|
||||||
Login uses the admin credentials above (or any other local user you create in SQLite).
|
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 run lint
|
||||||
|
npm run format:check
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
## Public Hosting Notes
|
## Public Hosting Notes
|
||||||
|
|
||||||
The frontend proxies `/api/*` to the backend container. Set:
|
The frontend proxies `/api/*` to the backend container. Set:
|
||||||
@@ -147,21 +171,43 @@ If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BAS
|
|||||||
|
|
||||||
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
||||||
|
|
||||||
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
|
- Push to `beta`: runs the complete quality gate and deploys the isolated beta environment to `AMS-DEV01`.
|
||||||
- Push to `prod`: runs the same verification, then deploys to Docker on `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`.
|
||||||
|
|
||||||
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
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:8000/health`
|
||||||
- `http://127.0.0.1:3000/login`
|
- `http://127.0.0.1:3000/login`
|
||||||
|
|
||||||
Configure these Gitea Actions secrets before enabling the deploy job:
|
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_PRIVATE_KEY`: private key for the deployment account.
|
||||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
||||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
- `PROD_SSH_USER`: target user, for example `zak`.
|
||||||
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
|
||||||
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
|
||||||
|
Beta always deploys to the isolated `/home/<deployment-user>/magent-beta` directory; the production path secret is intentionally ignored.
|
||||||
|
|
||||||
|
## Security and data handling
|
||||||
|
|
||||||
|
Generate independent signing and settings-encryption secrets before first startup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `JWT_SECRET` must contain at least 32 characters. Access sessions expire after 120 minutes by default and are revoked after logout, password, role, or blocked-state changes.
|
||||||
|
- `SETUP_TOKEN` is a separate random value of at least 32 characters, generated using the first command above a second time. It only authorizes first-admin creation on an unfinished, fresh installation. Never put it in a URL or share it with ordinary users. After completion the public bootstrap endpoint remains disabled even if the token is retained.
|
||||||
|
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
|
||||||
|
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
|
||||||
|
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
|
||||||
|
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
|
||||||
|
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
|
||||||
|
- **View as user** is a per-tab interface preview: it hides configuration, user-management pages, diagnostics and moderation tools, including direct admin-page URLs. **Exit user view** restores the administrator interface. It does not impersonate another account or change backend permissions; the displayed data still belongs to the signed-in account. Test real permission boundaries with a separate non-admin account.
|
||||||
|
|
||||||
## History endpoints
|
## History endpoints
|
||||||
|
|
||||||
@@ -172,7 +218,7 @@ Configure these Gitea Actions secrets before enabling the deploy job:
|
|||||||
|
|
||||||
### Login fails
|
### Login fails
|
||||||
|
|
||||||
- Make sure `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`.
|
- For a fresh installation, open `/setup` and use `SETUP_TOKEN`, or sign in with the environment-seeded administrator. Existing installations use the accounts already in the database; changing `ADMIN_PASSWORD` does not reset an existing account.
|
||||||
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
|
- Confirm the backend is reachable: `http://localhost:8000/health` (or see container logs).
|
||||||
|
|
||||||
### Services show as down
|
### Services show as down
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Shared HTTP request and error contracts."""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class StrictRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
|
||||||
|
400: {"model": ErrorResponse, "description": "Invalid request"},
|
||||||
|
401: {"model": ErrorResponse, "description": "Authentication required"},
|
||||||
|
403: {"model": ErrorResponse, "description": "Permission denied"},
|
||||||
|
404: {"model": ErrorResponse, "description": "Resource not found"},
|
||||||
|
409: {"model": ErrorResponse, "description": "Request conflict"},
|
||||||
|
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
|
||||||
|
500: {"model": ErrorResponse, "description": "Unexpected server error"},
|
||||||
|
502: {"model": ErrorResponse, "description": "Upstream service error"},
|
||||||
|
503: {"model": ErrorResponse, "description": "Service unavailable"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SignupRequest(StrictRequest):
|
||||||
|
invite_code: str = Field(min_length=1, max_length=256)
|
||||||
|
username: str = Field(min_length=1, max_length=100)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
email: Optional[str] = Field(default=None, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(StrictRequest):
|
||||||
|
identifier: Optional[str] = Field(default=None, max_length=320)
|
||||||
|
username: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
email: Optional[str] = Field(default=None, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(StrictRequest):
|
||||||
|
token: str = Field(min_length=1, max_length=512)
|
||||||
|
new_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileEmailUpdateRequest(StrictRequest):
|
||||||
|
email: Optional[str] = Field(default=None, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(StrictRequest):
|
||||||
|
current_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
new_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Keep direct service-level tests compatible while FastAPI validates HTTP input."""
|
||||||
|
return payload if isinstance(payload, dict) else payload.model_dump()
|
||||||
@@ -159,6 +159,9 @@ def _load_current_user_from_token(
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||||
if _is_expired(user.get("expires_at")):
|
if _is_expired(user.get("expires_at")):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||||
|
token_version = payload.get("ver")
|
||||||
|
if not isinstance(token_version, int) or token_version != int(user.get("auth_version") or 1):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has been revoked")
|
||||||
|
|
||||||
user = normalize_user_auth_provider(user)
|
user = normalize_user_auth_provider(user)
|
||||||
from .feature_access import permissions
|
from .feature_access import permissions
|
||||||
@@ -183,6 +186,7 @@ def _load_current_user_from_token(
|
|||||||
"is_expired": bool(user.get("is_expired", False)),
|
"is_expired": bool(user.get("is_expired", False)),
|
||||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
"password_change_supported": bool(user.get("password_change_supported", False)),
|
||||||
"password_provider": user.get("password_provider"),
|
"password_provider": user.get("password_provider"),
|
||||||
|
"auth_version": int(user.get("auth_version") or 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import re
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import time
|
|
||||||
from .base import ApiClient, _operation_error_message
|
from .base import ApiClient, _operation_error_message
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||||
|
|
||||||
@@ -185,23 +185,33 @@ class JellyfinClient(ApiClient):
|
|||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
||||||
url = f"{self.base_url}/Items"
|
url = f"{self.base_url}/Items"
|
||||||
params = {
|
params = {
|
||||||
"SearchTerm": term,
|
"SearchTerm": term,
|
||||||
"IncludeItemTypes": ",".join(item_types or []),
|
"IncludeItemTypes": ",".join(item_types or []),
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"Fields": "Path,MediaSources",
|
"Fields": "Path,MediaSources,ProviderIds,OriginalTitle,SortName",
|
||||||
"Limit": limit,
|
"Limit": limit,
|
||||||
}
|
}
|
||||||
headers = self._emby_headers()
|
headers = self._emby_headers()
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.get(url, headers=headers, params=params)
|
normalized = ' '.join(re.sub(r"[^\w\s]", ' ', term, flags=re.UNICODE).split())
|
||||||
|
terms = list(dict.fromkeys([term, normalized]))
|
||||||
|
if normalized != term and normalized.split():
|
||||||
|
terms.append(max(normalized.split(), key=len))
|
||||||
|
items = {}
|
||||||
|
for search_term in dict.fromkeys(terms):
|
||||||
|
if not search_term:
|
||||||
|
continue
|
||||||
|
response = await client.get(url, headers=headers, params={**params, "SearchTerm": search_term})
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
payload = response.json()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
for item in payload.get('Items', []):
|
||||||
|
if isinstance(item, dict) and item.get('Id'):
|
||||||
|
items[item['Id']] = item
|
||||||
|
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
success=True,
|
success=True,
|
||||||
@@ -210,7 +220,6 @@ class JellyfinClient(ApiClient):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
@@ -264,7 +273,6 @@ class JellyfinClient(ApiClient):
|
|||||||
async def refresh_library(self, recursive: bool = True) -> None:
|
async def refresh_library(self, recursive: bool = True) -> None:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||||
url = f"{self.base_url}/Library/Refresh"
|
url = f"{self.base_url}/Library/Refresh"
|
||||||
headers = self._emby_headers()
|
headers = self._emby_headers()
|
||||||
@@ -273,7 +281,6 @@ class JellyfinClient(ApiClient):
|
|||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(url, headers=headers, params=params)
|
response = await client.post(url, headers=headers, params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
success=True,
|
success=True,
|
||||||
@@ -281,7 +288,6 @@ class JellyfinClient(ApiClient):
|
|||||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from .base import ApiClient, _operation_error_message
|
from .base import ApiClient, _operation_error_message
|
||||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||||
|
|
||||||
@@ -89,7 +88,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
@@ -97,7 +95,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
success=True,
|
success=True,
|
||||||
@@ -106,7 +103,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
@@ -119,7 +115,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
operation_event_id = start_remote_call("qBittorrent")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
@@ -127,7 +122,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.text.strip()
|
result = response.text.strip()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
success=True,
|
success=True,
|
||||||
@@ -136,7 +130,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
@@ -149,14 +142,12 @@ class QBittorrentClient(ApiClient):
|
|||||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
return None
|
return None
|
||||||
started_at = time.perf_counter()
|
|
||||||
operation_event_id = start_remote_call("qBittorrent")
|
operation_event_id = start_remote_call("qBittorrent")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
await self._login(client)
|
await self._login(client)
|
||||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
success=True,
|
success=True,
|
||||||
@@ -164,7 +155,6 @@ class QBittorrentClient(ApiClient):
|
|||||||
message=_torrent_action_message(path),
|
message=_torrent_action_message(path),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
|
||||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||||
finish_remote_call(
|
finish_remote_call(
|
||||||
operation_event_id,
|
operation_event_id,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class RadarrClient(ApiClient):
|
|||||||
return await self.get("/api/v3/qualityprofile")
|
return await self.get("/api/v3/qualityprofile")
|
||||||
|
|
||||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
return await self.get("/api/v3/queue", params={"movieIds": movie_id, "pageSize": 1000})
|
||||||
|
|
||||||
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
async def search_releases(self, movie_id: int) -> Optional[Any]:
|
||||||
return await self.get(
|
return await self.get(
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ class SonarrClient(ApiClient):
|
|||||||
timeout_seconds=90.0,
|
timeout_seconds=90.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def search_episode_releases(self, episode_id: int) -> Optional[Any]:
|
||||||
|
return await self.get('/api/v3/release', params={'episodeId': episode_id}, timeout_seconds=90.0)
|
||||||
|
|
||||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -1,9 +1,20 @@
|
|||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from .build_info import BUILD_NUMBER, CHANGELOG
|
from .build_info import BUILD_NUMBER, CHANGELOG
|
||||||
|
|
||||||
|
|
||||||
|
_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_banner_color(value: object) -> Optional[str]:
|
||||||
|
color = str(value or "").strip().lower()
|
||||||
|
return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_prefix="")
|
model_config = SettingsConfigDict(env_prefix="")
|
||||||
app_name: str = "Magent"
|
app_name: str = "Magent"
|
||||||
@@ -13,7 +24,12 @@ class Settings(BaseSettings):
|
|||||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||||
)
|
)
|
||||||
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
|
||||||
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
jwt_exp_minutes: int = Field(default=120, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
|
||||||
|
jwt_issuer: str = Field(default="magent", validation_alias=AliasChoices("JWT_ISSUER"))
|
||||||
|
jwt_audience: str = Field(default="magent-web", validation_alias=AliasChoices("JWT_AUDIENCE"))
|
||||||
|
settings_encryption_key: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("SETTINGS_ENCRYPTION_KEY")
|
||||||
|
)
|
||||||
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
|
||||||
auth_rate_limit_window_seconds: int = Field(
|
auth_rate_limit_window_seconds: int = Field(
|
||||||
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
||||||
@@ -35,6 +51,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
|
||||||
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||||
|
setup_token: str = Field(default="", validation_alias=AliasChoices("SETUP_TOKEN"))
|
||||||
auth_cookie_name: str = Field(
|
auth_cookie_name: str = Field(
|
||||||
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
|
||||||
)
|
)
|
||||||
@@ -42,7 +59,7 @@ class Settings(BaseSettings):
|
|||||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||||
)
|
)
|
||||||
auth_cookie_samesite: str = Field(
|
auth_cookie_samesite: str = Field(
|
||||||
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
default="strict", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
|
||||||
)
|
)
|
||||||
auth_cookie_domain: Optional[str] = Field(
|
auth_cookie_domain: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||||
@@ -51,6 +68,7 @@ class Settings(BaseSettings):
|
|||||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||||
)
|
)
|
||||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||||
|
log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
|
||||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||||
log_file_max_bytes: int = Field(
|
log_file_max_bytes: int = Field(
|
||||||
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
||||||
@@ -67,6 +85,7 @@ class Settings(BaseSettings):
|
|||||||
requests_sync_ttl_minutes: int = Field(
|
requests_sync_ttl_minutes: int = Field(
|
||||||
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
|
||||||
)
|
)
|
||||||
|
requests_stage_refresh_minutes: int = Field(default=15, ge=1, le=1440, validation_alias=AliasChoices("REQUESTS_STAGE_REFRESH_MINUTES"))
|
||||||
requests_poll_interval_seconds: int = Field(
|
requests_poll_interval_seconds: int = Field(
|
||||||
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
|
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
|
||||||
)
|
)
|
||||||
@@ -107,6 +126,15 @@ class Settings(BaseSettings):
|
|||||||
site_banner_tone: str = Field(
|
site_banner_tone: str = Field(
|
||||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
||||||
)
|
)
|
||||||
|
site_banner_background_color: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
|
||||||
|
)
|
||||||
|
site_banner_border_color: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
|
||||||
|
)
|
||||||
|
site_login_message: Optional[str] = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
|
||||||
|
)
|
||||||
site_login_show_jellyfin_login: bool = Field(
|
site_login_show_jellyfin_login: bool = Field(
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
||||||
)
|
)
|
||||||
|
|||||||
+492
-216
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
|||||||
"""Live account permissions. Invite access uses the existing users column."""
|
"""Live account permissions. Invite access uses the existing users column."""
|
||||||
from .db import _connect
|
from .db import _connect
|
||||||
|
|
||||||
FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
|
FEATURES = ("stats", "requests", "new_requests", "issues", "invites", "ignore_profile_limits")
|
||||||
|
|
||||||
|
|
||||||
def permissions(user: dict) -> dict[str, bool]:
|
def permissions(user: dict) -> dict[str, bool]:
|
||||||
if user.get("role") == "admin":
|
if user.get("role") == "admin":
|
||||||
return dict.fromkeys(FEATURES, True)
|
return dict.fromkeys(FEATURES, True)
|
||||||
values = dict.fromkeys(FEATURES, True)
|
values = dict.fromkeys(FEATURES, True)
|
||||||
|
values["ignore_profile_limits"] = False
|
||||||
values["invites"] = bool(user.get("invite_management_enabled", False))
|
values["invites"] = bool(user.get("invite_management_enabled", False))
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
|
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import contextvars
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Any, Mapping, Optional
|
from typing import Any, Mapping, Optional
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
@@ -27,6 +29,9 @@ _SENSITIVE_KEYWORDS = (
|
|||||||
"token",
|
"token",
|
||||||
)
|
)
|
||||||
_MAX_BODY_BYTES = 4096
|
_MAX_BODY_BYTES = 4096
|
||||||
|
_SENSITIVE_PATH_PATTERNS = (
|
||||||
|
re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RequestContextFilter(logging.Filter):
|
class RequestContextFilter(logging.Filter):
|
||||||
@@ -35,6 +40,22 @@ class RequestContextFilter(logging.Filter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JsonLogFormatter(logging.Formatter):
|
||||||
|
"""Stable JSON output for production log collectors."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"request_id": getattr(record, "request_id", "-"),
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
||||||
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
||||||
|
|
||||||
@@ -47,6 +68,13 @@ def current_request_id() -> str:
|
|||||||
return REQUEST_ID_CONTEXT.get("-")
|
return REQUEST_ID_CONTEXT.get("-")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_path(path: str) -> str:
|
||||||
|
sanitized = str(path or "")
|
||||||
|
for pattern in _SENSITIVE_PATH_PATTERNS:
|
||||||
|
sanitized = pattern.sub(r"\1[REDACTED]", sanitized)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_key(key: str) -> bool:
|
def _is_sensitive_key(key: str) -> bool:
|
||||||
lowered = key.strip().lower()
|
lowered = key.strip().lower()
|
||||||
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
||||||
@@ -55,10 +83,7 @@ def _is_sensitive_key(key: str) -> bool:
|
|||||||
def _redact_scalar(value: Any) -> Any:
|
def _redact_scalar(value: Any) -> Any:
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
if value is None or isinstance(value, (int, float, bool)):
|
||||||
return value
|
return value
|
||||||
text = str(value)
|
return "[REDACTED]"
|
||||||
if len(text) <= 4:
|
|
||||||
return "***"
|
|
||||||
return f"{text[:2]}***{text[-2:]}"
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
||||||
@@ -142,6 +167,7 @@ def configure_logging(
|
|||||||
log_file_backup_count: int = 10,
|
log_file_backup_count: int = 10,
|
||||||
log_http_client_level: Optional[str] = "INFO",
|
log_http_client_level: Optional[str] = "INFO",
|
||||||
log_background_sync_level: Optional[str] = "INFO",
|
log_background_sync_level: Optional[str] = "INFO",
|
||||||
|
log_format: Optional[str] = "text",
|
||||||
) -> None:
|
) -> None:
|
||||||
level_name = (log_level or "INFO").upper()
|
level_name = (log_level or "INFO").upper()
|
||||||
level = getattr(logging, level_name, logging.INFO)
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
@@ -161,9 +187,16 @@ def configure_logging(
|
|||||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
backupCount=max(1, int(log_file_backup_count or 10)),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
os.chmod(log_path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
handlers.append(file_handler)
|
handlers.append(file_handler)
|
||||||
|
|
||||||
context_filter = RequestContextFilter()
|
context_filter = RequestContextFilter()
|
||||||
|
if str(log_format or "text").strip().lower() == "json":
|
||||||
|
formatter: logging.Formatter = JsonLogFormatter()
|
||||||
|
else:
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
|||||||
+107
-26
@@ -6,13 +6,17 @@ import uuid
|
|||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.exception_handlers import request_validation_exception_handler
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import has_admin_user, init_db
|
from .db import has_admin_user, init_db
|
||||||
from .routers.requests import (
|
from .routers.requests import (
|
||||||
router as requests_router,
|
router as requests_router,
|
||||||
startup_warmup_requests_cache,
|
startup_warmup_requests_cache,
|
||||||
|
run_local_request_stage_loop,
|
||||||
run_requests_delta_loop,
|
run_requests_delta_loop,
|
||||||
run_daily_requests_full_sync,
|
run_daily_requests_full_sync,
|
||||||
run_daily_db_cleanup,
|
run_daily_db_cleanup,
|
||||||
@@ -31,6 +35,10 @@ from .routers.insights import router as insights_router
|
|||||||
from .routers.identities import router as identities_router
|
from .routers.identities import router as identities_router
|
||||||
from .routers.recaps import router as recaps_router
|
from .routers.recaps import router as recaps_router
|
||||||
from .routers.newsletters import router as newsletters_router
|
from .routers.newsletters import router as newsletters_router
|
||||||
|
from .routers.backups import router as backups_router
|
||||||
|
from .routers.setup import router as setup_router
|
||||||
|
from .services.backups import apply_pending_restore
|
||||||
|
from .services.setup import initialize_setup_state, is_setup_required, setup_token_configured
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .services.issue_resolution import run_issue_confirmation_loop
|
from .services.issue_resolution import run_issue_confirmation_loop
|
||||||
from .services.email_recaps import run_email_recap_loop
|
from .services.email_recaps import run_email_recap_loop
|
||||||
@@ -46,14 +54,17 @@ from .logging_config import (
|
|||||||
configure_logging,
|
configure_logging,
|
||||||
reset_request_id,
|
reset_request_id,
|
||||||
sanitize_headers,
|
sanitize_headers,
|
||||||
sanitize_value,
|
sanitize_path,
|
||||||
summarize_http_body,
|
|
||||||
)
|
)
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
from .metrics import record_api, start_metrics
|
from .metrics import record_api, start_metrics
|
||||||
|
from .request_limits import InstallationBodyLimitMiddleware
|
||||||
|
from .secret_storage import validate_secret_storage_configuration
|
||||||
|
from .services.request_origins import is_allowed_request_origin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_background_tasks: list[asyncio.Task[None]] = []
|
_background_tasks: list[asyncio.Task[None]] = []
|
||||||
|
_background_started = False
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
@@ -69,6 +80,23 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def installation_validation_error(request: Request, exc: RequestValidationError):
|
||||||
|
if request.url.path.rstrip("/") == "/setup/bootstrap" or request.url.path.startswith("/admin/backups"):
|
||||||
|
# Pydantic SecretStr masks parsed values, but FastAPI's default 422 body
|
||||||
|
# includes rejected raw input. Never echo tokens/passwords/passphrases.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={"detail": [
|
||||||
|
{key: error[key] for key in ("type", "loc", "msg") if key in error}
|
||||||
|
for error in exc.errors()
|
||||||
|
]},
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return await request_validation_exception_handler(request, exc)
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -81,22 +109,32 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
|||||||
operation_token = begin_operation(
|
operation_token = begin_operation(
|
||||||
operation_id,
|
operation_id,
|
||||||
label=request.headers.get("X-Magent-Operation-Label"),
|
label=request.headers.get("X-Magent-Operation-Label"),
|
||||||
path=request.url.path,
|
path=sanitize_path(request.url.path),
|
||||||
)
|
)
|
||||||
request.state.request_id = request_id
|
request.state.request_id = request_id
|
||||||
|
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
origin = str(request.headers.get("origin") or "")
|
||||||
|
if origin and not is_allowed_request_origin(origin):
|
||||||
|
record_api(request, 403, 0.0)
|
||||||
|
if operation_id and operation_token is not None:
|
||||||
|
finish_operation(operation_id, success=False, status_code=403)
|
||||||
|
reset_operation(operation_token)
|
||||||
|
reset_request_id(token)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "Cross-origin state change rejected"},
|
||||||
|
headers={"X-Request-ID": request_id},
|
||||||
|
)
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
body = await request.body()
|
body_summary = {
|
||||||
body_summary = summarize_http_body(body, request.headers.get("content-type"))
|
"content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
|
||||||
|
"declared_bytes": request.headers.get("content-length"),
|
||||||
async def receive() -> dict:
|
}
|
||||||
return {"type": "http.request", "body": body, "more_body": False}
|
|
||||||
|
|
||||||
request._receive = receive
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"request started method=%s path=%s query=%s client=%s headers=%s body=%s",
|
"request started method=%s path=%s query_keys=%s client=%s headers=%s body=%s",
|
||||||
request.method,
|
request.method,
|
||||||
request.url.path,
|
sanitize_path(request.url.path),
|
||||||
sanitize_value(dict(request.query_params)),
|
sorted(set(request.query_params.keys())),
|
||||||
request.client.host if request.client else "-",
|
request.client.host if request.client else "-",
|
||||||
sanitize_headers(
|
sanitize_headers(
|
||||||
{
|
{
|
||||||
@@ -123,7 +161,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
|||||||
logger.exception(
|
logger.exception(
|
||||||
"request failed method=%s path=%s duration_ms=%s",
|
"request failed method=%s path=%s duration_ms=%s",
|
||||||
request.method,
|
request.method,
|
||||||
request.url.path,
|
sanitize_path(request.url.path),
|
||||||
duration_ms,
|
duration_ms,
|
||||||
)
|
)
|
||||||
if operation_id and operation_token is not None:
|
if operation_id and operation_token is not None:
|
||||||
@@ -139,6 +177,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
|||||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||||
|
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
# Keep API responses non-executable and non-embeddable by default.
|
# Keep API responses non-executable and non-embeddable by default.
|
||||||
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
||||||
response.headers.setdefault(
|
response.headers.setdefault(
|
||||||
@@ -148,7 +187,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
|||||||
logger.info(
|
logger.info(
|
||||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
||||||
request.method,
|
request.method,
|
||||||
request.url.path,
|
sanitize_path(request.url.path),
|
||||||
response.status_code,
|
response.status_code,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
sanitize_headers(
|
sanitize_headers(
|
||||||
@@ -202,14 +241,14 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
|
|||||||
|
|
||||||
def _log_security_configuration_warnings() -> None:
|
def _log_security_configuration_warnings() -> None:
|
||||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||||
if not jwt_secret or jwt_secret == "change-me":
|
if len(jwt_secret) < 32 or jwt_secret == "change-me":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"security configuration warning: JWT_SECRET is unset or still set to the default value"
|
"security configuration warning: JWT_SECRET is missing, short, or still set to the default value"
|
||||||
)
|
)
|
||||||
admin_password = str(settings.admin_password or "")
|
admin_password = str(settings.admin_password or "")
|
||||||
if not admin_password or admin_password == "adminadmin":
|
if admin_password == "adminadmin":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
|
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
|
||||||
)
|
)
|
||||||
if bool(settings.api_docs_enabled):
|
if bool(settings.api_docs_enabled):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -217,14 +256,24 @@ def _log_security_configuration_warnings() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _enforce_secure_startup_configuration() -> None:
|
def _enforce_secret_configuration() -> None:
|
||||||
jwt_secret = str(settings.jwt_secret or "").strip()
|
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||||
if not jwt_secret or jwt_secret == "change-me":
|
if len(jwt_secret) < 32 or jwt_secret == "change-me":
|
||||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
raise RuntimeError(
|
||||||
|
"JWT_SECRET must be a strong, non-default value of at least 32 characters before startup."
|
||||||
|
)
|
||||||
|
validate_secret_storage_configuration()
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_secure_startup_configuration() -> None:
|
||||||
|
_enforce_secret_configuration()
|
||||||
admin_password = str(settings.admin_password or "")
|
admin_password = str(settings.admin_password or "")
|
||||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||||
|
if is_setup_required() and setup_token_configured():
|
||||||
|
return
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
|
"First startup requires a strong SETUP_TOKEN (at least 32 characters) for the setup wizard, "
|
||||||
|
"or a secure ADMIN_PASSWORD, until an admin account exists."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -238,9 +287,14 @@ async def startup() -> None:
|
|||||||
log_file_backup_count=settings.log_file_backup_count,
|
log_file_backup_count=settings.log_file_backup_count,
|
||||||
log_http_client_level=settings.log_http_client_level,
|
log_http_client_level=settings.log_http_client_level,
|
||||||
log_background_sync_level=settings.log_background_sync_level,
|
log_background_sync_level=settings.log_background_sync_level,
|
||||||
|
log_format=settings.log_format,
|
||||||
)
|
)
|
||||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||||
_log_security_configuration_warnings()
|
_log_security_configuration_warnings()
|
||||||
|
_enforce_secret_configuration()
|
||||||
|
# Restore offline, before any schema migration, database reader or worker.
|
||||||
|
apply_pending_restore()
|
||||||
|
initialize_setup_state()
|
||||||
init_db()
|
init_db()
|
||||||
_enforce_secure_startup_configuration()
|
_enforce_secure_startup_configuration()
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
@@ -251,6 +305,7 @@ async def startup() -> None:
|
|||||||
log_file_backup_count=runtime.log_file_backup_count,
|
log_file_backup_count=runtime.log_file_backup_count,
|
||||||
log_http_client_level=runtime.log_http_client_level,
|
log_http_client_level=runtime.log_http_client_level,
|
||||||
log_background_sync_level=runtime.log_background_sync_level,
|
log_background_sync_level=runtime.log_background_sync_level,
|
||||||
|
log_format=runtime.log_format,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
||||||
@@ -262,18 +317,42 @@ async def startup() -> None:
|
|||||||
runtime.log_background_sync_level,
|
runtime.log_background_sync_level,
|
||||||
runtime.requests_data_source,
|
runtime.requests_data_source,
|
||||||
)
|
)
|
||||||
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
app.state.on_setup_complete = _start_background_tasks
|
||||||
logger.info("Background imports and automation paused for initial setup")
|
await _start_background_tasks()
|
||||||
|
logger.info("startup complete")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_background_tasks() -> None:
|
||||||
|
global _background_started
|
||||||
|
if _background_started:
|
||||||
return
|
return
|
||||||
|
if is_setup_required():
|
||||||
|
logger.info("Background imports and automation paused until setup is complete")
|
||||||
|
return
|
||||||
|
if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false":
|
||||||
|
logger.info("Background imports and automation disabled by configuration")
|
||||||
|
return
|
||||||
|
_background_started = True
|
||||||
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
|
||||||
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
_launch_background_task("requests-warmup", startup_warmup_requests_cache)
|
||||||
|
_launch_background_task("request-local-stages", run_local_request_stage_loop)
|
||||||
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
|
||||||
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
|
||||||
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
_launch_background_task("db-cleanup", run_daily_db_cleanup)
|
||||||
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
|
||||||
_launch_background_task("email-recaps", run_email_recap_loop)
|
_launch_background_task("email-recaps", run_email_recap_loop)
|
||||||
_launch_background_task("newsletters", run_newsletter_loop)
|
_launch_background_task("newsletters", run_newsletter_loop)
|
||||||
logger.info("startup complete")
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown() -> None:
|
||||||
|
global _background_started
|
||||||
|
for task in _background_tasks:
|
||||||
|
task.cancel()
|
||||||
|
if _background_tasks:
|
||||||
|
await asyncio.gather(*_background_tasks, return_exceptions=True)
|
||||||
|
_background_tasks.clear()
|
||||||
|
_background_started = False
|
||||||
|
|
||||||
|
|
||||||
app.include_router(requests_router)
|
app.include_router(requests_router)
|
||||||
@@ -292,3 +371,5 @@ app.include_router(insights_router)
|
|||||||
app.include_router(identities_router)
|
app.include_router(identities_router)
|
||||||
app.include_router(recaps_router)
|
app.include_router(recaps_router)
|
||||||
app.include_router(newsletters_router)
|
app.include_router(newsletters_router)
|
||||||
|
app.include_router(backups_router)
|
||||||
|
app.include_router(setup_router)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Bound security-sensitive request bodies before JSON/multipart parsing."""
|
||||||
|
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
|
||||||
|
# Encrypted backup limit is 32 MiB. Allow a bounded margin for the multipart
|
||||||
|
# envelope; count streamed chunks as well as checking the untrusted header.
|
||||||
|
RESTORE_BODY_LIMIT = 34 * 1024 * 1024
|
||||||
|
BOOTSTRAP_BODY_LIMIT = 16 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class InstallationBodyLimitMiddleware:
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http" or scope.get("method") != "POST":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
path = scope.get("path", "").rstrip("/")
|
||||||
|
limit = {
|
||||||
|
"/admin/backups/restore": RESTORE_BODY_LIMIT,
|
||||||
|
"/admin/backups/export": BOOTSTRAP_BODY_LIMIT,
|
||||||
|
"/setup/bootstrap": BOOTSTRAP_BODY_LIMIT,
|
||||||
|
}.get(path)
|
||||||
|
if limit is None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
headers = dict(scope.get("headers", []))
|
||||||
|
try:
|
||||||
|
length = int(headers.get(b"content-length", b"0"))
|
||||||
|
except ValueError:
|
||||||
|
length = -1
|
||||||
|
if length < 0 or length > limit:
|
||||||
|
await JSONResponse({"detail": "Request body is too large or has an invalid length."}, status_code=413)(scope, receive, send)
|
||||||
|
return
|
||||||
|
received = 0
|
||||||
|
|
||||||
|
async def bounded_receive() -> Message:
|
||||||
|
nonlocal received
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.request":
|
||||||
|
received += len(message.get("body", b""))
|
||||||
|
if received > limit:
|
||||||
|
raise HTTPException(status_code=413, detail="Request body is too large.")
|
||||||
|
return message
|
||||||
|
|
||||||
|
await self.app(scope, bounded_receive, send)
|
||||||
@@ -20,7 +20,8 @@ from ..auth import (
|
|||||||
normalize_user_auth_provider,
|
normalize_user_auth_provider,
|
||||||
resolve_user_auth_provider,
|
resolve_user_auth_provider,
|
||||||
)
|
)
|
||||||
from ..config import settings as env_settings
|
from ..config import normalize_banner_color, settings as env_settings
|
||||||
|
from ..api_models import COMMON_ERROR_RESPONSES
|
||||||
from ..network_security import validate_notification_target_url
|
from ..network_security import validate_notification_target_url
|
||||||
from ..db import (
|
from ..db import (
|
||||||
delete_setting,
|
delete_setting,
|
||||||
@@ -35,12 +36,9 @@ from ..db import (
|
|||||||
get_user_by_id,
|
get_user_by_id,
|
||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
get_user_request_stats,
|
get_user_request_stats,
|
||||||
create_user_if_missing,
|
|
||||||
set_user_jellyseerr_id,
|
|
||||||
set_setting,
|
set_setting,
|
||||||
set_user_blocked,
|
set_user_blocked,
|
||||||
delete_user_by_username,
|
delete_user_data_by_username,
|
||||||
delete_user_activity_by_username,
|
|
||||||
set_user_auto_search_enabled,
|
set_user_auto_search_enabled,
|
||||||
set_auto_search_enabled_for_non_admin_users,
|
set_auto_search_enabled_for_non_admin_users,
|
||||||
set_user_email,
|
set_user_email,
|
||||||
@@ -49,6 +47,7 @@ from ..db import (
|
|||||||
set_user_profile_id,
|
set_user_profile_id,
|
||||||
set_user_expires_at,
|
set_user_expires_at,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
|
increment_user_auth_version,
|
||||||
sync_jellyfin_password_state,
|
sync_jellyfin_password_state,
|
||||||
set_user_role,
|
set_user_role,
|
||||||
run_integrity_check,
|
run_integrity_check,
|
||||||
@@ -59,7 +58,6 @@ from ..db import (
|
|||||||
cleanup_history,
|
cleanup_history,
|
||||||
update_request_cache_title,
|
update_request_cache_title,
|
||||||
repair_request_cache_titles,
|
repair_request_cache_titles,
|
||||||
delete_non_admin_users,
|
|
||||||
list_user_profiles,
|
list_user_profiles,
|
||||||
get_user_profile,
|
get_user_profile,
|
||||||
create_user_profile,
|
create_user_profile,
|
||||||
@@ -69,9 +67,11 @@ from ..db import (
|
|||||||
get_signup_invite_by_id,
|
get_signup_invite_by_id,
|
||||||
create_signup_invite,
|
create_signup_invite,
|
||||||
update_signup_invite,
|
update_signup_invite,
|
||||||
|
rotate_signup_invite_code,
|
||||||
delete_signup_invite,
|
delete_signup_invite,
|
||||||
get_signup_invite_by_code,
|
get_signup_invite_by_code,
|
||||||
disable_signup_invites_by_creator,
|
disable_signup_invites_by_creator,
|
||||||
|
delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.sonarr import SonarrClient
|
from ..clients.sonarr import SonarrClient
|
||||||
@@ -80,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient
|
|||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..services.jellyfin_sync import sync_jellyfin_users
|
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||||
from ..services.user_cache import (
|
from ..services.user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
|
||||||
extract_jellyseerr_user_email,
|
|
||||||
find_matching_jellyseerr_user,
|
|
||||||
get_cached_jellyfin_users,
|
get_cached_jellyfin_users,
|
||||||
get_cached_jellyseerr_users,
|
get_cached_jellyseerr_users,
|
||||||
match_jellyseerr_user_id,
|
|
||||||
save_jellyfin_users_cache,
|
save_jellyfin_users_cache,
|
||||||
save_jellyseerr_users_cache,
|
save_jellyseerr_users_cache,
|
||||||
clear_user_import_caches,
|
clear_user_import_caches,
|
||||||
@@ -108,7 +104,12 @@ from ..logging_config import configure_logging
|
|||||||
from ..routers import requests as requests_router
|
from ..routers import requests as requests_router
|
||||||
from ..routers.branding import save_branding_image
|
from ..routers.branding import save_branding_image
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
router = APIRouter(
|
||||||
|
prefix="/admin",
|
||||||
|
tags=["admin"],
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
responses=COMMON_ERROR_RESPONSES,
|
||||||
|
)
|
||||||
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||||
@@ -174,6 +175,11 @@ NOTIFICATION_URL_SETTING_KEYS = {
|
|||||||
"magent_notify_webhook_url",
|
"magent_notify_webhook_url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BANNER_COLOR_SETTING_KEYS = {
|
||||||
|
"site_banner_background_color",
|
||||||
|
"site_banner_border_color",
|
||||||
|
}
|
||||||
|
|
||||||
SETTING_KEYS: List[str] = [
|
SETTING_KEYS: List[str] = [
|
||||||
"jellystat_base_url",
|
"jellystat_base_url",
|
||||||
"jellystat_api_key",
|
"jellystat_api_key",
|
||||||
@@ -241,6 +247,7 @@ SETTING_KEYS: List[str] = [
|
|||||||
"qbittorrent_username",
|
"qbittorrent_username",
|
||||||
"qbittorrent_password",
|
"qbittorrent_password",
|
||||||
"log_level",
|
"log_level",
|
||||||
|
"log_format",
|
||||||
"log_file",
|
"log_file",
|
||||||
"log_file_max_bytes",
|
"log_file_max_bytes",
|
||||||
"log_file_backup_count",
|
"log_file_backup_count",
|
||||||
@@ -248,6 +255,7 @@ SETTING_KEYS: List[str] = [
|
|||||||
"log_background_sync_level",
|
"log_background_sync_level",
|
||||||
"requests_sync_ttl_minutes",
|
"requests_sync_ttl_minutes",
|
||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
|
"requests_stage_refresh_minutes",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_full_sync_time",
|
"requests_full_sync_time",
|
||||||
"requests_cleanup_time",
|
"requests_cleanup_time",
|
||||||
@@ -259,6 +267,9 @@ SETTING_KEYS: List[str] = [
|
|||||||
"site_banner_enabled",
|
"site_banner_enabled",
|
||||||
"site_banner_message",
|
"site_banner_message",
|
||||||
"site_banner_tone",
|
"site_banner_tone",
|
||||||
|
"site_banner_background_color",
|
||||||
|
"site_banner_border_color",
|
||||||
|
"site_login_message",
|
||||||
"site_login_show_jellyfin_login",
|
"site_login_show_jellyfin_login",
|
||||||
"site_login_show_local_login",
|
"site_login_show_local_login",
|
||||||
"site_login_show_forgot_password",
|
"site_login_show_forgot_password",
|
||||||
@@ -683,6 +694,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
changed_keys.append(key)
|
changed_keys.append(key)
|
||||||
continue
|
continue
|
||||||
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
|
||||||
|
if key == "requests_stage_refresh_minutes":
|
||||||
|
try:
|
||||||
|
interval = int(value_to_store)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Local stage refresh must be a whole number from 1 to 1440 minutes") from exc
|
||||||
|
if not 1 <= interval <= 1440:
|
||||||
|
raise HTTPException(status_code=400, detail="Local stage refresh must be from 1 to 1440 minutes")
|
||||||
|
value_to_store = str(interval)
|
||||||
if key == "issue_confirmation_contact_attempts":
|
if key == "issue_confirmation_contact_attempts":
|
||||||
try:
|
try:
|
||||||
attempts = int(value_to_store)
|
attempts = int(value_to_store)
|
||||||
@@ -703,6 +722,11 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
value_to_store = value_to_store.lower()
|
value_to_store = value_to_store.lower()
|
||||||
if value_to_store not in {"days", "weeks", "months"}:
|
if value_to_store not in {"days", "weeks", "months"}:
|
||||||
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
|
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
|
||||||
|
if key in BANNER_COLOR_SETTING_KEYS:
|
||||||
|
normalized_color = normalize_banner_color(value_to_store)
|
||||||
|
if not normalized_color:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must be a six-digit hex colour such as #ffc857")
|
||||||
|
value_to_store = normalized_color
|
||||||
if key in URL_SETTING_KEYS and value_to_store:
|
if key in URL_SETTING_KEYS and value_to_store:
|
||||||
try:
|
try:
|
||||||
value_to_store = _normalize_service_url(value_to_store)
|
value_to_store = _normalize_service_url(value_to_store)
|
||||||
@@ -718,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
set_setting(key, value_to_store)
|
set_setting(key, value_to_store)
|
||||||
updates += 1
|
updates += 1
|
||||||
changed_keys.append(key)
|
changed_keys.append(key)
|
||||||
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
|
if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
|
||||||
touched_logging = True
|
touched_logging = True
|
||||||
if touched_logging:
|
if touched_logging:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
@@ -729,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
log_file_backup_count=runtime.log_file_backup_count,
|
log_file_backup_count=runtime.log_file_backup_count,
|
||||||
log_http_client_level=runtime.log_http_client_level,
|
log_http_client_level=runtime.log_http_client_level,
|
||||||
log_background_sync_level=runtime.log_background_sync_level,
|
log_background_sync_level=runtime.log_background_sync_level,
|
||||||
|
log_format=runtime.log_format,
|
||||||
)
|
)
|
||||||
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
|
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
|
||||||
return {"status": "ok", "updated": updates}
|
return {"status": "ok", "updated": updates}
|
||||||
@@ -757,7 +782,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]:
|
|||||||
result = await send_test_email(recipient_email=recipient_email)
|
result = await send_test_email(recipient_email=recipient_email)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
|
logger.info("Admin triggered SMTP test")
|
||||||
return {"status": "ok", **result}
|
return {"status": "ok", **result}
|
||||||
|
|
||||||
|
|
||||||
@@ -880,28 +905,10 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
|
|||||||
if not jellyseerr_users:
|
if not jellyseerr_users:
|
||||||
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
|
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
|
||||||
|
|
||||||
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
|
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||||
|
imported = await sync_jellyfin_users()
|
||||||
|
return {"status": "ok", "matched": len(jellyseerr_users), "skipped": 0, "imported": imported, "total": len(jellyseerr_users)}
|
||||||
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
users = get_all_users()
|
|
||||||
for user in users:
|
|
||||||
if user.get("jellyseerr_user_id") is not None:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
username = user.get("username") or ""
|
|
||||||
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
|
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
|
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
|
||||||
if matched_id is not None:
|
|
||||||
set_user_jellyseerr_id(username, matched_id)
|
|
||||||
if matched_email:
|
|
||||||
set_user_email(username, matched_email)
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
|
|
||||||
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
|
|
||||||
|
|
||||||
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
|
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
|
||||||
for key in ("email", "username", "displayName", "name"):
|
for key in ("email", "username", "displayName", "name"):
|
||||||
@@ -922,33 +929,9 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
|
|||||||
if not jellyseerr_users:
|
if not jellyseerr_users:
|
||||||
return {"status": "ok", "imported": 0, "cleared": 0}
|
return {"status": "ok", "imported": 0, "cleared": 0}
|
||||||
|
|
||||||
cleared = delete_non_admin_users()
|
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||||
imported = 0
|
imported = await sync_jellyfin_users()
|
||||||
for user in jellyseerr_users:
|
return {"status": "ok", "imported": imported, "cleared": 0}
|
||||||
user_id = user.get("id") or user.get("userId") or user.get("Id")
|
|
||||||
try:
|
|
||||||
user_id = int(user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
username = _pick_jellyseerr_username(user)
|
|
||||||
if not username:
|
|
||||||
continue
|
|
||||||
email = extract_jellyseerr_user_email(user)
|
|
||||||
created = create_user_if_missing(
|
|
||||||
username,
|
|
||||||
"jellyseerr-user",
|
|
||||||
role="user",
|
|
||||||
email=email,
|
|
||||||
auth_provider="jellyseerr",
|
|
||||||
jellyseerr_user_id=user_id,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
imported += 1
|
|
||||||
else:
|
|
||||||
set_user_jellyseerr_id(username, user_id)
|
|
||||||
if email:
|
|
||||||
set_user_email(username, email)
|
|
||||||
return {"status": "ok", "imported": imported, "cleared": cleared}
|
|
||||||
|
|
||||||
@router.post("/requests/sync")
|
@router.post("/requests/sync")
|
||||||
async def requests_sync() -> Dict[str, Any]:
|
async def requests_sync() -> Dict[str, Any]:
|
||||||
@@ -1327,12 +1310,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
|
|||||||
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
|
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
|
||||||
|
|
||||||
if action == "remove":
|
if action == "remove":
|
||||||
deleted = delete_user_by_username(username)
|
deletion = delete_user_data_by_username(username)
|
||||||
activity_deleted = delete_user_activity_by_username(username)
|
deleted = bool(deletion.get("deleted"))
|
||||||
result["local"] = {
|
result["local"] = {
|
||||||
"status": "ok" if deleted else "not_found",
|
"status": "ok" if deleted else "not_found",
|
||||||
"deleted": bool(deleted),
|
"deleted": bool(deleted),
|
||||||
"activity_deleted": activity_deleted,
|
"data_cleanup": deletion,
|
||||||
}
|
}
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
@@ -1594,6 +1577,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
||||||
sync_jellyfin_password_state(username, new_password_clean)
|
sync_jellyfin_password_state(username, new_password_clean)
|
||||||
|
increment_user_auth_version(username)
|
||||||
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -1937,6 +1921,11 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
|
role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')),
|
||||||
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
|
expires_at=invite.get('expires_at'), recipient_email=recipient_email,
|
||||||
)
|
)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=404, detail='Invite not found')
|
||||||
|
invite = rotate_signup_invite_code(int(invite['id']), _generate_invite_code())
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=409, detail='Invite is unavailable')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await send_templated_email(
|
result = await send_templated_email(
|
||||||
@@ -1950,9 +1939,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
logger.info(
|
logger.info(
|
||||||
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
|
"Admin sent invite email template: template=%s invite_id=%s username=%s",
|
||||||
template_key,
|
template_key,
|
||||||
result.get("recipient_email"),
|
|
||||||
invite.get("id") if invite else None,
|
invite.get("id") if invite else None,
|
||||||
user.get("username") if user else None,
|
user.get("username") if user else None,
|
||||||
)
|
)
|
||||||
@@ -2018,15 +2006,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
email_error = str(exc)
|
email_error = str(exc)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
|
"Admin created invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
|
||||||
invite.get("id"),
|
invite.get("id"),
|
||||||
invite.get("code"),
|
|
||||||
invite.get("label"),
|
invite.get("label"),
|
||||||
invite.get("profile_id"),
|
invite.get("profile_id"),
|
||||||
invite.get("role"),
|
invite.get("role"),
|
||||||
invite.get("max_uses"),
|
invite.get("max_uses"),
|
||||||
invite.get("enabled"),
|
invite.get("enabled"),
|
||||||
invite.get("recipient_email"),
|
bool(invite.get("recipient_email")),
|
||||||
send_email,
|
send_email,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -2049,7 +2036,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
existing = get_signup_invite_by_id(invite_id)
|
existing = get_signup_invite_by_id(invite_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Invite not found")
|
raise HTTPException(status_code=404, detail="Invite not found")
|
||||||
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
|
requested_code = _normalize_optional_text(payload.get("code"))
|
||||||
|
if requested_code and not requested_code.startswith("••••") and requested_code != "Protected invite":
|
||||||
|
code = _normalize_invite_code(requested_code)
|
||||||
|
else:
|
||||||
|
code = str(existing.get("code") or "")
|
||||||
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
|
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
|
||||||
enabled = payload.get("enabled")
|
enabled = payload.get("enabled")
|
||||||
if not isinstance(enabled, bool):
|
if not isinstance(enabled, bool):
|
||||||
@@ -2083,6 +2074,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
email_error = None
|
email_error = None
|
||||||
if send_email:
|
if send_email:
|
||||||
try:
|
try:
|
||||||
|
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
|
||||||
|
if not rotated:
|
||||||
|
raise ValueError("Invite is unavailable")
|
||||||
|
invite = rotated
|
||||||
email_result = await send_templated_email(
|
email_result = await send_templated_email(
|
||||||
"invited",
|
"invited",
|
||||||
invite=invite,
|
invite=invite,
|
||||||
@@ -2092,15 +2087,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
email_error = str(exc)
|
email_error = str(exc)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
|
"Admin updated invite: invite_id=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s has_recipient=%s send_email=%s",
|
||||||
invite.get("id"),
|
invite.get("id"),
|
||||||
invite.get("code"),
|
|
||||||
invite.get("label"),
|
invite.get("label"),
|
||||||
invite.get("profile_id"),
|
invite.get("profile_id"),
|
||||||
invite.get("role"),
|
invite.get("role"),
|
||||||
invite.get("max_uses"),
|
invite.get("max_uses"),
|
||||||
invite.get("enabled"),
|
invite.get("enabled"),
|
||||||
invite.get("recipient_email"),
|
bool(invite.get("recipient_email")),
|
||||||
send_email,
|
send_email,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -2116,6 +2110,22 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invites/{invite_id}/rotate")
|
||||||
|
async def rotate_invite(
|
||||||
|
invite_id: int,
|
||||||
|
current_user: Dict[str, Any] = Depends(require_admin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=409, detail="Invite is unavailable")
|
||||||
|
logger.info(
|
||||||
|
"Admin rotated invite: invite_id=%s actor=%s",
|
||||||
|
invite_id,
|
||||||
|
current_user.get("username"),
|
||||||
|
)
|
||||||
|
return {"status": "ok", "invite": invite}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/invites/{invite_id}")
|
@router.delete("/invites/{invite_id}")
|
||||||
async def remove_invite(invite_id: int) -> Dict[str, Any]:
|
async def remove_invite(invite_id: int) -> Dict[str, Any]:
|
||||||
deleted = delete_signup_invite(invite_id)
|
deleted = delete_signup_invite(invite_id)
|
||||||
|
|||||||
+120
-115
@@ -1,11 +1,8 @@
|
|||||||
from ..feature_guards import require_invites
|
from ..feature_guards import require_invites
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from collections import defaultdict, deque
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
import time
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||||
@@ -28,6 +25,7 @@ from ..db import (
|
|||||||
list_signup_invites,
|
list_signup_invites,
|
||||||
create_signup_invite,
|
create_signup_invite,
|
||||||
update_signup_invite,
|
update_signup_invite,
|
||||||
|
rotate_signup_invite_code,
|
||||||
delete_signup_invite,
|
delete_signup_invite,
|
||||||
reserve_signup_invite_use,
|
reserve_signup_invite_use,
|
||||||
release_signup_invite_use,
|
release_signup_invite_use,
|
||||||
@@ -39,6 +37,10 @@ from ..db import (
|
|||||||
get_global_request_total,
|
get_global_request_total,
|
||||||
get_setting,
|
get_setting,
|
||||||
sync_jellyfin_password_state,
|
sync_jellyfin_password_state,
|
||||||
|
increment_user_auth_version,
|
||||||
|
get_rate_limit_status,
|
||||||
|
record_rate_limit_event,
|
||||||
|
clear_rate_limit_events,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
@@ -58,6 +60,15 @@ from ..auth import (
|
|||||||
set_auth_cookies,
|
set_auth_cookies,
|
||||||
)
|
)
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from ..api_models import (
|
||||||
|
COMMON_ERROR_RESPONSES,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
PasswordResetRequest,
|
||||||
|
ProfileEmailUpdateRequest,
|
||||||
|
SignupRequest,
|
||||||
|
request_data,
|
||||||
|
)
|
||||||
from ..network_security import request_trusts_forwarded_headers
|
from ..network_security import request_trusts_forwarded_headers
|
||||||
from ..services.user_cache import (
|
from ..services.user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
build_jellyseerr_candidate_map,
|
||||||
@@ -79,7 +90,7 @@ from ..services.password_reset import (
|
|||||||
verify_password_reset_token,
|
verify_password_reset_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||||
STREAM_TOKEN_TTL_SECONDS = 120
|
STREAM_TOKEN_TTL_SECONDS = 120
|
||||||
@@ -87,14 +98,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
|
|||||||
"If an account exists for that username or email, a password reset link has been sent."
|
"If an account exists for that username or email, a password reset link has been sent."
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGIN_RATE_LOCK = Lock()
|
|
||||||
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
|
|
||||||
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
|
|
||||||
_RESET_RATE_LOCK = Lock()
|
|
||||||
_RESET_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
|
|
||||||
_RESET_ATTEMPTS_BY_IDENTIFIER: dict[str, deque[float]] = defaultdict(deque)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_recipient_email(value: object) -> str:
|
def _require_recipient_email(value: object) -> str:
|
||||||
normalized = normalize_delivery_email(value)
|
normalized = normalize_delivery_email(value)
|
||||||
if normalized:
|
if normalized:
|
||||||
@@ -145,12 +148,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str:
|
|||||||
return (identifier or "").strip().lower()[:256] or "<empty>"
|
return (identifier or "").strip().lower()[:256] or "<empty>"
|
||||||
|
|
||||||
|
|
||||||
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
|
|
||||||
cutoff = now - window_seconds
|
|
||||||
while bucket and bucket[0] < cutoff:
|
|
||||||
bucket.popleft()
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
|
def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) -> dict | None:
|
||||||
if not users:
|
if not users:
|
||||||
return None
|
return None
|
||||||
@@ -172,56 +169,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) ->
|
|||||||
|
|
||||||
|
|
||||||
def _record_login_failure(request: Request, username: str) -> None:
|
def _record_login_failure(request: Request, username: str) -> None:
|
||||||
now = time.monotonic()
|
|
||||||
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
|
|
||||||
ip_key = _auth_client_ip(request)
|
ip_key = _auth_client_ip(request)
|
||||||
user_key = _login_rate_key_user(username)
|
user_key = _login_rate_key_user(username)
|
||||||
with _LOGIN_RATE_LOCK:
|
record_rate_limit_event("login-ip", ip_key)
|
||||||
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
|
record_rate_limit_event("login-user", user_key)
|
||||||
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
|
logger.warning("login failure recorded")
|
||||||
_prune_attempts(ip_bucket, now, window)
|
|
||||||
_prune_attempts(user_bucket, now, window)
|
|
||||||
ip_bucket.append(now)
|
|
||||||
user_bucket.append(now)
|
|
||||||
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_login_failures(request: Request, username: str) -> None:
|
def _clear_login_failures(request: Request, username: str) -> None:
|
||||||
ip_key = _auth_client_ip(request)
|
ip_key = _auth_client_ip(request)
|
||||||
user_key = _login_rate_key_user(username)
|
user_key = _login_rate_key_user(username)
|
||||||
with _LOGIN_RATE_LOCK:
|
clear_rate_limit_events("login-ip", ip_key)
|
||||||
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
|
clear_rate_limit_events("login-user", user_key)
|
||||||
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _enforce_login_rate_limit(request: Request, username: str) -> None:
|
def _enforce_login_rate_limit(request: Request, username: str) -> None:
|
||||||
now = time.monotonic()
|
|
||||||
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
|
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
|
||||||
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
|
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
|
||||||
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
|
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
|
||||||
ip_key = _auth_client_ip(request)
|
ip_key = _auth_client_ip(request)
|
||||||
user_key = _login_rate_key_user(username)
|
user_key = _login_rate_key_user(username)
|
||||||
with _LOGIN_RATE_LOCK:
|
ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
|
||||||
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
|
user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
|
||||||
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
|
exceeded = ip_exceeded or user_exceeded
|
||||||
_prune_attempts(ip_bucket, now, window)
|
retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
|
||||||
_prune_attempts(user_bucket, now, window)
|
|
||||||
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
|
|
||||||
retry_after = 1
|
|
||||||
if exceeded:
|
|
||||||
retry_candidates = []
|
|
||||||
if ip_bucket:
|
|
||||||
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
|
|
||||||
if user_bucket:
|
|
||||||
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
|
|
||||||
if retry_candidates:
|
|
||||||
retry_after = max(retry_candidates)
|
|
||||||
if exceeded:
|
if exceeded:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"login rate limit exceeded username=%s client=%s retry_after=%s",
|
"login rate limit exceeded retry_after=%s", retry_after,
|
||||||
user_key,
|
|
||||||
ip_key,
|
|
||||||
retry_after,
|
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
@@ -231,48 +205,28 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
|
def _record_password_reset_attempt(request: Request, identifier: str) -> None:
|
||||||
now = time.monotonic()
|
|
||||||
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
|
|
||||||
ip_key = _auth_client_ip(request)
|
ip_key = _auth_client_ip(request)
|
||||||
identifier_key = _password_reset_rate_key_identifier(identifier)
|
identifier_key = _password_reset_rate_key_identifier(identifier)
|
||||||
with _RESET_RATE_LOCK:
|
record_rate_limit_event("reset-ip", ip_key)
|
||||||
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
|
record_rate_limit_event("reset-identifier", identifier_key)
|
||||||
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
|
logger.info("password reset rate event recorded")
|
||||||
_prune_attempts(ip_bucket, now, window)
|
|
||||||
_prune_attempts(identifier_bucket, now, window)
|
|
||||||
ip_bucket.append(now)
|
|
||||||
identifier_bucket.append(now)
|
|
||||||
logger.info("password reset rate event recorded identifier=%s client=%s", identifier_key, ip_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
|
def _enforce_password_reset_rate_limit(request: Request, identifier: str) -> None:
|
||||||
now = time.monotonic()
|
|
||||||
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
|
window = max(int(settings.password_reset_rate_limit_window_seconds or 300), 1)
|
||||||
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
|
max_ip = max(int(settings.password_reset_rate_limit_max_attempts_ip or 6), 1)
|
||||||
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
|
max_identifier = max(int(settings.password_reset_rate_limit_max_attempts_identifier or 3), 1)
|
||||||
ip_key = _auth_client_ip(request)
|
ip_key = _auth_client_ip(request)
|
||||||
identifier_key = _password_reset_rate_key_identifier(identifier)
|
identifier_key = _password_reset_rate_key_identifier(identifier)
|
||||||
with _RESET_RATE_LOCK:
|
ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
|
||||||
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
|
identifier_exceeded, identifier_retry = get_rate_limit_status(
|
||||||
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
|
"reset-identifier", identifier_key, window, max_identifier
|
||||||
_prune_attempts(ip_bucket, now, window)
|
)
|
||||||
_prune_attempts(identifier_bucket, now, window)
|
exceeded = ip_exceeded or identifier_exceeded
|
||||||
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
|
retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
|
||||||
retry_after = 1
|
|
||||||
if exceeded:
|
|
||||||
retry_candidates = []
|
|
||||||
if ip_bucket:
|
|
||||||
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
|
|
||||||
if identifier_bucket:
|
|
||||||
retry_candidates.append(max(1, int(window - (now - identifier_bucket[0]))))
|
|
||||||
if retry_candidates:
|
|
||||||
retry_after = max(retry_candidates)
|
|
||||||
if exceeded:
|
if exceeded:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
|
"password reset rate limit exceeded retry_after=%s", retry_after,
|
||||||
identifier_key,
|
|
||||||
ip_key,
|
|
||||||
retry_after,
|
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
@@ -400,6 +354,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
|
|||||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"code": invite.get("code"),
|
"code": invite.get("code"),
|
||||||
|
"code_available": bool(invite.get("code_available")),
|
||||||
"email_bound": bool(invite.get("recipient_email")),
|
"email_bound": bool(invite.get("recipient_email")),
|
||||||
"label": invite.get("label"),
|
"label": invite.get("label"),
|
||||||
"description": invite.get("description"),
|
"description": invite.get("description"),
|
||||||
@@ -493,6 +448,7 @@ def _serialize_self_invite(invite: dict) -> dict:
|
|||||||
return {
|
return {
|
||||||
"id": invite.get("id"),
|
"id": invite.get("id"),
|
||||||
"code": invite.get("code"),
|
"code": invite.get("code"),
|
||||||
|
"code_available": bool(invite.get("code_available")),
|
||||||
"label": invite.get("label"),
|
"label": invite.get("label"),
|
||||||
"description": invite.get("description"),
|
"description": invite.get("description"),
|
||||||
"profile_id": invite.get("profile_id"),
|
"profile_id": invite.get("profile_id"),
|
||||||
@@ -576,6 +532,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
|
|||||||
return {
|
return {
|
||||||
"id": invite.get("id"),
|
"id": invite.get("id"),
|
||||||
"code": invite.get("code"),
|
"code": invite.get("code"),
|
||||||
|
"code_available": bool(invite.get("code_available")),
|
||||||
"label": invite.get("label"),
|
"label": invite.get("label"),
|
||||||
"description": invite.get("description"),
|
"description": invite.get("description"),
|
||||||
"profile_id": invite.get("profile_id"),
|
"profile_id": invite.get("profile_id"),
|
||||||
@@ -664,7 +621,9 @@ async def login(
|
|||||||
detail="This account uses external sign-in. Use the external sign-in option.",
|
detail="This account uses external sign-in. Use the external sign-in option.",
|
||||||
)
|
)
|
||||||
_assert_user_can_login(user)
|
_assert_user_can_login(user)
|
||||||
token = create_access_token(user["username"], user["role"])
|
token = create_access_token(
|
||||||
|
user["username"], user["role"], auth_version=int(user.get("auth_version") or 1)
|
||||||
|
)
|
||||||
_clear_login_failures(request, form_data.username)
|
_clear_login_failures(request, form_data.username)
|
||||||
set_last_login(user["username"])
|
set_last_login(user["username"])
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -708,7 +667,9 @@ async def jellyfin_login(
|
|||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
||||||
_assert_user_can_login(user)
|
_assert_user_can_login(user)
|
||||||
if user and _has_valid_jellyfin_cache(user, password):
|
if user and _has_valid_jellyfin_cache(user, password):
|
||||||
token = create_access_token(canonical_username, "user")
|
token = create_access_token(
|
||||||
|
canonical_username, "user", auth_version=int(user.get("auth_version") or 1)
|
||||||
|
)
|
||||||
_clear_login_failures(request, username)
|
_clear_login_failures(request, username)
|
||||||
set_last_login(canonical_username)
|
set_last_login(canonical_username)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -733,6 +694,13 @@ async def jellyfin_login(
|
|||||||
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
if not isinstance(auth_response, dict) or not auth_response.get("User"):
|
||||||
_record_login_failure(request, username)
|
_record_login_failure(request, username)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||||
|
from ..services.jellyfin_identity import user_for_identity
|
||||||
|
identity_owner = user_for_identity(auth_response['User'].get('Id'), runtime.jellyfin_base_url)
|
||||||
|
if identity_owner:
|
||||||
|
preferred_match = identity_owner
|
||||||
|
user = identity_owner
|
||||||
|
canonical_username = identity_owner['username']
|
||||||
|
_assert_user_can_login(user)
|
||||||
if not preferred_match:
|
if not preferred_match:
|
||||||
create_user_if_missing(
|
create_user_if_missing(
|
||||||
canonical_username,
|
canonical_username,
|
||||||
@@ -768,7 +736,10 @@ async def jellyfin_login(
|
|||||||
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
||||||
if matched_id is not None:
|
if matched_id is not None:
|
||||||
set_user_jellyseerr_id(canonical_username, matched_id)
|
set_user_jellyseerr_id(canonical_username, matched_id)
|
||||||
token = create_access_token(canonical_username, "user")
|
refreshed_user = get_user_by_username(canonical_username) or user or {}
|
||||||
|
token = create_access_token(
|
||||||
|
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
|
||||||
|
)
|
||||||
_clear_login_failures(request, username)
|
_clear_login_failures(request, username)
|
||||||
set_last_login(canonical_username)
|
set_last_login(canonical_username)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -815,8 +786,13 @@ async def jellyseerr_login(
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
|
||||||
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
|
||||||
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
|
||||||
|
id_matches = [row for row in get_all_users() if jellyseerr_user_id is not None and row.get('jellyseerr_user_id') == jellyseerr_user_id]
|
||||||
|
if len(id_matches) > 1:
|
||||||
|
raise HTTPException(409, 'Multiple Magent accounts claim this Seerr identity. Ask an administrator to repair the links.')
|
||||||
ci_matches = get_users_by_username_ci(form_data.username)
|
ci_matches = get_users_by_username_ci(form_data.username)
|
||||||
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
preferred_match = id_matches[0] if id_matches else _pick_preferred_ci_user_match(ci_matches, form_data.username)
|
||||||
|
if preferred_match and preferred_match.get('jellyseerr_user_id') not in (None, jellyseerr_user_id):
|
||||||
|
raise HTTPException(409, 'The account name and authenticated identity disagree. Ask an administrator to repair the links.')
|
||||||
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
|
||||||
if not preferred_match:
|
if not preferred_match:
|
||||||
create_user_if_missing(
|
create_user_if_missing(
|
||||||
@@ -839,7 +815,10 @@ async def jellyseerr_login(
|
|||||||
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
|
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
|
||||||
if jellyseerr_email:
|
if jellyseerr_email:
|
||||||
set_user_email(canonical_username, jellyseerr_email)
|
set_user_email(canonical_username, jellyseerr_email)
|
||||||
token = create_access_token(canonical_username, "user")
|
refreshed_user = get_user_by_username(canonical_username) or user or {}
|
||||||
|
token = create_access_token(
|
||||||
|
canonical_username, "user", auth_version=int(refreshed_user.get("auth_version") or 1)
|
||||||
|
)
|
||||||
_clear_login_failures(request, form_data.username)
|
_clear_login_failures(request, form_data.username)
|
||||||
set_last_login(canonical_username)
|
set_last_login(canonical_username)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -861,7 +840,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(response: Response) -> dict:
|
async def logout(
|
||||||
|
response: Response, current_user: dict = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
increment_user_auth_version(str(current_user.get("username") or ""))
|
||||||
clear_auth_cookies(response)
|
clear_auth_cookies(response)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -872,6 +854,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
current_user["username"],
|
current_user["username"],
|
||||||
current_user["role"],
|
current_user["role"],
|
||||||
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
|
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
|
||||||
|
auth_version=int(current_user.get("auth_version") or 1),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"stream_token": token,
|
"stream_token": token,
|
||||||
@@ -895,7 +878,8 @@ async def invite_details(code: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/signup")
|
@router.post("/signup")
|
||||||
async def signup(payload: dict, response: Response) -> dict:
|
async def signup(payload: SignupRequest, response: Response) -> dict:
|
||||||
|
payload = request_data(payload)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
invite_code = str(payload.get("invite_code") or "").strip()
|
invite_code = str(payload.get("invite_code") or "").strip()
|
||||||
@@ -911,11 +895,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
|||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
if get_user_by_username(username):
|
if get_user_by_username(username):
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
|
||||||
logger.info(
|
logger.info("signup attempt username=%s", username)
|
||||||
"signup attempt username=%s invite_code=%s",
|
|
||||||
username,
|
|
||||||
invite_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
invite = get_signup_invite_by_code(invite_code)
|
invite = get_signup_invite_by_code(invite_code)
|
||||||
if not invite:
|
if not invite:
|
||||||
@@ -1027,7 +1007,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
|||||||
auto_search_enabled=auto_search_enabled,
|
auto_search_enabled=auto_search_enabled,
|
||||||
profile_id=int(profile_id) if profile_id is not None else None,
|
profile_id=int(profile_id) if profile_id is not None else None,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
invited_by_code=invite.get("code"),
|
invited_by_code=f"invite:{invite.get('id')}",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
@@ -1054,15 +1034,18 @@ async def signup(payload: dict, response: Response) -> dict:
|
|||||||
# Welcome email delivery is best-effort and must not break signup.
|
# Welcome email delivery is best-effort and must not break signup.
|
||||||
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
||||||
_assert_user_can_login(created_user)
|
_assert_user_can_login(created_user)
|
||||||
token = create_access_token(username, role)
|
refreshed_user = get_user_by_username(username) or created_user or {}
|
||||||
|
token = create_access_token(
|
||||||
|
username, role, auth_version=int(refreshed_user.get("auth_version") or 1)
|
||||||
|
)
|
||||||
set_last_login(username)
|
set_last_login(username)
|
||||||
logger.info(
|
logger.info(
|
||||||
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
|
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_id=%s",
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
created_user.get("auth_provider") if created_user else auth_provider,
|
created_user.get("auth_provider") if created_user else auth_provider,
|
||||||
created_user.get("profile_id") if created_user else None,
|
created_user.get("profile_id") if created_user else None,
|
||||||
invite.get("code"),
|
invite.get("id"),
|
||||||
)
|
)
|
||||||
return _auth_success_response(
|
return _auth_success_response(
|
||||||
response,
|
response,
|
||||||
@@ -1081,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/password/forgot")
|
@router.post("/password/forgot")
|
||||||
async def forgot_password(payload: dict, request: Request) -> dict:
|
async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
|
||||||
|
payload = request_data(payload)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
|
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
|
||||||
@@ -1098,8 +1082,7 @@ async def forgot_password(payload: dict, request: Request) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
client_ip = _auth_client_ip(request)
|
client_ip = _auth_client_ip(request)
|
||||||
safe_identifier = identifier.strip().lower()[:256]
|
logger.info("password reset requested")
|
||||||
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
|
|
||||||
try:
|
try:
|
||||||
reset_result = await request_password_reset(
|
reset_result = await request_password_reset(
|
||||||
identifier,
|
identifier,
|
||||||
@@ -1108,24 +1091,17 @@ async def forgot_password(payload: dict, request: Request) -> dict:
|
|||||||
)
|
)
|
||||||
if reset_result.get("issued"):
|
if reset_result.get("issued"):
|
||||||
logger.info(
|
logger.info(
|
||||||
"password reset issued username=%s provider=%s recipient=%s client=%s",
|
"password reset issued username=%s provider=%s",
|
||||||
reset_result.get("username"),
|
reset_result.get("username"),
|
||||||
reset_result.get("auth_provider"),
|
reset_result.get("auth_provider"),
|
||||||
reset_result.get("recipient_email"),
|
|
||||||
client_ip,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"password reset request completed with no eligible account identifier=%s client=%s",
|
"password reset request completed with no eligible account",
|
||||||
safe_identifier,
|
|
||||||
client_ip,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"password reset email dispatch failed identifier=%s client=%s detail=%s",
|
"password reset email dispatch failed detail=%s", type(exc).__name__,
|
||||||
safe_identifier,
|
|
||||||
client_ip,
|
|
||||||
str(exc),
|
|
||||||
)
|
)
|
||||||
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
|
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
|
||||||
|
|
||||||
@@ -1141,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/password/reset")
|
@router.post("/password/reset")
|
||||||
async def password_reset(payload: dict) -> dict:
|
async def password_reset(payload: PasswordResetRequest) -> dict:
|
||||||
|
payload = request_data(payload)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
token = payload.get("token")
|
token = payload.get("token")
|
||||||
@@ -1204,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/profile/email")
|
@router.put("/profile/email")
|
||||||
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
async def update_profile_email(
|
||||||
|
payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
payload = request_data(payload)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
username = str(current_user.get("username") or "").strip()
|
username = str(current_user.get("username") or "").strip()
|
||||||
@@ -1359,8 +1339,13 @@ async def update_profile_invite(
|
|||||||
_require_self_service_invite_access(current_user)
|
_require_self_service_invite_access(current_user)
|
||||||
existing = _get_owned_invite(invite_id, current_user)
|
existing = _get_owned_invite(invite_id, current_user)
|
||||||
|
|
||||||
requested_code = payload.get("code", existing.get("code"))
|
requested_code = payload.get("code")
|
||||||
if isinstance(requested_code, str) and requested_code.strip():
|
if (
|
||||||
|
isinstance(requested_code, str)
|
||||||
|
and requested_code.strip()
|
||||||
|
and not requested_code.strip().startswith("••••")
|
||||||
|
and requested_code.strip() != "Protected invite"
|
||||||
|
):
|
||||||
code = _normalize_invite_code(requested_code)
|
code = _normalize_invite_code(requested_code)
|
||||||
else:
|
else:
|
||||||
code = str(existing.get("code") or "").strip()
|
code = str(existing.get("code") or "").strip()
|
||||||
@@ -1415,6 +1400,10 @@ async def update_profile_invite(
|
|||||||
email_error = None
|
email_error = None
|
||||||
if send_email:
|
if send_email:
|
||||||
try:
|
try:
|
||||||
|
rotated = rotate_signup_invite_code(invite_id, _generate_invite_code())
|
||||||
|
if not rotated:
|
||||||
|
raise ValueError("Invite is unavailable")
|
||||||
|
invite = rotated
|
||||||
email_result = await send_templated_email(
|
email_result = await send_templated_email(
|
||||||
"invited",
|
"invited",
|
||||||
invite=invite,
|
invite=invite,
|
||||||
@@ -1438,6 +1427,18 @@ async def update_profile_invite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile/invites/{invite_id}/rotate")
|
||||||
|
async def rotate_profile_invite(
|
||||||
|
invite_id: int, current_user: dict = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
|
_get_owned_invite(invite_id, current_user)
|
||||||
|
invite = rotate_signup_invite_code(invite_id, _generate_invite_code())
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite is unavailable")
|
||||||
|
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/profile/invites/{invite_id}")
|
@router.delete("/profile/invites/{invite_id}")
|
||||||
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
_require_self_service_invite_access(current_user)
|
_require_self_service_invite_access(current_user)
|
||||||
@@ -1449,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/password")
|
@router.post("/password")
|
||||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
async def change_password(
|
||||||
|
payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
|
||||||
|
) -> dict:
|
||||||
|
payload = request_data(payload)
|
||||||
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
||||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||||
@@ -1519,6 +1523,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
|||||||
|
|
||||||
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
|
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
|
||||||
sync_jellyfin_password_state(username, new_password_clean)
|
sync_jellyfin_password_state(username, new_password_clean)
|
||||||
|
increment_user_auth_version(username)
|
||||||
logger.info("password change completed username=%s provider=jellyfin", username)
|
logger.info("password change completed username=%s provider=jellyfin", username)
|
||||||
return {"status": "ok", "provider": "jellyfin"}
|
return {"status": "ok", "provider": "jellyfin"}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Administrator-only encrypted backup downloads and staged restores."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from ..auth import require_admin
|
||||||
|
from ..db import get_rate_limit_status, record_rate_limit_event
|
||||||
|
from ..services import backups
|
||||||
|
|
||||||
|
def _no_store(response: Response) -> None:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin/backups", tags=["backups"],
|
||||||
|
dependencies=[Depends(require_admin), Depends(_no_store)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
passphrase: SecretStr = Field(min_length=12, max_length=1024)
|
||||||
|
include_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit(user: dict) -> None:
|
||||||
|
key = str(user["username"])
|
||||||
|
exceeded, retry = get_rate_limit_status("backups", key, 300, 3)
|
||||||
|
if exceeded:
|
||||||
|
raise HTTPException(429, "Too many backup operations; try again shortly", headers={"Retry-After": str(retry)})
|
||||||
|
record_rate_limit_event("backups", key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def status() -> dict:
|
||||||
|
return backups.backup_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/export")
|
||||||
|
def export(payload: ExportRequest, user: dict = Depends(require_admin)) -> Response:
|
||||||
|
_rate_limit(user)
|
||||||
|
try:
|
||||||
|
content, filename = backups.create_backup(payload.passphrase.get_secret_value(), payload.include_cache)
|
||||||
|
except backups.BackupError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return Response(content, media_type="application/octet-stream", headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Cache-Control": "no-store", "Pragma": "no-cache",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restore", status_code=202)
|
||||||
|
async def restore(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
passphrase: str = Form(..., min_length=12, max_length=1024),
|
||||||
|
confirmation: Literal["RESTORE"] = Form(...),
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
_rate_limit(user)
|
||||||
|
try:
|
||||||
|
if file.size is not None and file.size > backups.MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(413, "Backup exceeds the 32 MiB upload limit")
|
||||||
|
metadata = await run_in_threadpool(backups.stage_restore, file.file, passphrase)
|
||||||
|
except backups.BackupError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
return {
|
||||||
|
"status": "staged", "restart_required": True, "backup": metadata,
|
||||||
|
"message": "Backup validated. Restart Magent to apply it. Current data remains active until restart.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/restore")
|
||||||
|
def cancel() -> dict:
|
||||||
|
try:
|
||||||
|
backups.cancel_restore()
|
||||||
|
except backups.BackupError as exc:
|
||||||
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
return {"status": "cancelled"}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
import warnings
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
from fastapi import APIRouter, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ _BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "as
|
|||||||
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
|
||||||
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
||||||
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
|
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
|
||||||
|
_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||||
|
_MAX_IMAGE_PIXELS = 25_000_000
|
||||||
|
_ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||||
|
_ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||||
|
|
||||||
|
|
||||||
def _ensure_branding_dir() -> None:
|
def _ensure_branding_dir() -> None:
|
||||||
@@ -110,14 +115,27 @@ async def branding_favicon() -> FileResponse:
|
|||||||
|
|
||||||
|
|
||||||
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
content_type = str(file.content_type or "").lower()
|
||||||
raise HTTPException(status_code=400, detail="Please upload an image file.")
|
extension = os.path.splitext(str(file.filename or ""))[1].lower()
|
||||||
content = await file.read()
|
if content_type not in _ALLOWED_IMAGE_TYPES or extension not in _ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=400, detail="Upload a PNG, JPEG, or WebP image.")
|
||||||
|
content = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
||||||
|
if len(content) > _MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Image is too large (maximum 5 MB).")
|
||||||
try:
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||||
|
candidate = Image.open(BytesIO(content))
|
||||||
|
if candidate.format not in {"PNG", "JPEG", "WEBP"}:
|
||||||
|
raise ValueError("Unsupported image format")
|
||||||
|
if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
|
||||||
|
raise Image.DecompressionBombError("Image pixel limit exceeded")
|
||||||
|
candidate.verify()
|
||||||
image = Image.open(BytesIO(content))
|
image = Image.open(BytesIO(content))
|
||||||
except OSError as exc:
|
image.load()
|
||||||
|
except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
|
||||||
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
|
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
|
||||||
|
|
||||||
_ensure_branding_dir()
|
_ensure_branding_dir()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import re
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, Response
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from ..services.public_urls import magent_public_url
|
||||||
from ..auth import get_current_user, require_admin
|
from ..auth import get_current_user, require_admin
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
|
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
|
||||||
@@ -19,7 +20,7 @@ class Settings(StrictPayload):
|
|||||||
weekday: int = Field(ge=0, le=6)
|
weekday: int = Field(ge=0, le=6)
|
||||||
hour: int = Field(ge=0, le=23)
|
hour: int = Field(ge=0, le=23)
|
||||||
limit_titles: int = Field(ge=1, le=24)
|
limit_titles: int = Field(ge=1, le=24)
|
||||||
public_url: str = Field(max_length=500)
|
public_url: str = Field(default="", max_length=500)
|
||||||
intro: str = Field(default='', max_length=2000)
|
intro: str = Field(default='', max_length=2000)
|
||||||
revision: int = Field(ge=1)
|
revision: int = Field(ge=1)
|
||||||
_url = field_validator('public_url')(RecapSettings.origin_only.__func__)
|
_url = field_validator('public_url')(RecapSettings.origin_only.__func__)
|
||||||
@@ -114,10 +115,11 @@ def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = De
|
|||||||
@router.put('/admin/newsletters')
|
@router.put('/admin/newsletters')
|
||||||
def settings(payload: Settings, user: dict = Depends(require_admin)):
|
def settings(payload: Settings, user: dict = Depends(require_admin)):
|
||||||
try:
|
try:
|
||||||
ready, detail = service.delivery_ready(payload.public_url)
|
public_url = magent_public_url(payload.public_url or store.settings()['public_url'])
|
||||||
|
ready, detail = service.delivery_ready(public_url)
|
||||||
if payload.enabled and not ready:
|
if payload.enabled and not ready:
|
||||||
raise service.NewsletterError(detail)
|
raise service.NewsletterError(detail)
|
||||||
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
|
return store.save_settings({**payload.model_dump(), "public_url": public_url}, datetime.now(timezone.utc))
|
||||||
except (service.NewsletterError, store.Conflict) as exc:
|
except (service.NewsletterError, store.Conflict) as exc:
|
||||||
fail(exc)
|
fail(exc)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
|
from ..api_models import COMMON_ERROR_RESPONSES
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import (
|
from ..db import (
|
||||||
add_portal_item_activity,
|
add_portal_item_activity,
|
||||||
@@ -34,7 +35,12 @@ from ..services.issue_resolution import (
|
|||||||
from ..services.notifications import send_portal_notification
|
from ..services.notifications import send_portal_notification
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
|
router = APIRouter(
|
||||||
|
prefix="/portal",
|
||||||
|
tags=["portal"],
|
||||||
|
dependencies=[Depends(get_current_user), Depends(require_portal_access)],
|
||||||
|
responses=COMMON_ERROR_RESPONSES,
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PORTAL_KINDS = {"request", "issue", "feature"}
|
PORTAL_KINDS = {"request", "issue", "feature"}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from ..auth import get_current_user, require_admin
|
from ..services.public_urls import magent_public_url
|
||||||
|
from ..auth import require_admin
|
||||||
from ..feature_guards import require_stats
|
from ..feature_guards import require_stats
|
||||||
from ..services import email_recaps as recaps, recap_store as store
|
from ..services import email_recaps as recaps, recap_store as store
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ class RecapSettings(StrictPayload):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
day: int = Field(ge=1, le=28)
|
day: int = Field(ge=1, le=28)
|
||||||
hour: int = Field(ge=0, le=23)
|
hour: int = Field(ge=0, le=23)
|
||||||
public_url: str = Field(max_length=500)
|
public_url: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
@field_validator("public_url")
|
@field_validator("public_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -114,9 +115,9 @@ def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dic
|
|||||||
# Validate against the proposed URL without writing any partial settings.
|
# Validate against the proposed URL without writing any partial settings.
|
||||||
ready, detail = recaps.smtp_email_config_ready()
|
ready, detail = recaps.smtp_email_config_ready()
|
||||||
runtime = recaps.get_runtime_settings()
|
runtime = recaps.get_runtime_settings()
|
||||||
if not payload.public_url or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
|
if not magent_public_url(payload.public_url or store.settings()["public_url"]) or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key:
|
||||||
raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
|
raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail)
|
||||||
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
|
return store.save_settings({**payload.model_dump(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/email-recaps/preview")
|
@router.get("/admin/email-recaps/preview")
|
||||||
|
|||||||
+468
-250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
|||||||
|
"""Initial install bootstrap and authenticated setup wizard endpoints."""
|
||||||
|
|
||||||
|
from inspect import isawaitable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from pydantic import Field, SecretStr
|
||||||
|
|
||||||
|
from ..api_models import COMMON_ERROR_RESPONSES, StrictRequest
|
||||||
|
from ..auth import _extract_client_ip, require_admin
|
||||||
|
from ..services import setup as setup_service
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/setup", tags=["setup"], responses=COMMON_ERROR_RESPONSES)
|
||||||
|
|
||||||
|
|
||||||
|
class BootstrapRequest(StrictRequest):
|
||||||
|
setup_token: SecretStr = Field(min_length=1, max_length=1024)
|
||||||
|
username: str = Field(min_length=1, max_length=100)
|
||||||
|
password: SecretStr = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupProgress(StrictRequest):
|
||||||
|
step: setup_service.SetupStep
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def public_status(response: Response) -> dict:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return setup_service.get_public_setup_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bootstrap", status_code=201)
|
||||||
|
def bootstrap(payload: BootstrapRequest, request: Request) -> dict:
|
||||||
|
status = setup_service.get_public_setup_status()
|
||||||
|
if not status["needs_admin"]:
|
||||||
|
raise HTTPException(status_code=409, detail="Initial administrator setup is no longer available.")
|
||||||
|
retry_after = setup_service.consume_bootstrap_attempt(_extract_client_ip(request))
|
||||||
|
if retry_after is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many setup attempts. Try again later.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
setup_service.bootstrap_administrator(
|
||||||
|
payload.setup_token.get_secret_value(), payload.username, payload.password.get_secret_value()
|
||||||
|
)
|
||||||
|
except setup_service.InvalidSetupTokenError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
except setup_service.SetupUnavailableError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"status": "created", "username": payload.username.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/state", dependencies=[Depends(require_admin)])
|
||||||
|
def get_state() -> dict:
|
||||||
|
return setup_service.get_setup_state()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/state", dependencies=[Depends(require_admin)])
|
||||||
|
def update_state(payload: SetupProgress) -> dict:
|
||||||
|
return setup_service.update_setup_step(payload.step)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete", dependencies=[Depends(require_admin)])
|
||||||
|
async def finish_setup(request: Request) -> dict:
|
||||||
|
try:
|
||||||
|
state = setup_service.complete_setup()
|
||||||
|
except setup_service.SetupUnavailableError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
# Startup owns worker lifecycle. Its callback must be idempotent so retries
|
||||||
|
# after a network interruption cannot start duplicate import/automation jobs.
|
||||||
|
callback = getattr(request.app.state, "on_setup_complete", None)
|
||||||
|
if callback is not None:
|
||||||
|
result = callback()
|
||||||
|
if isawaitable(result):
|
||||||
|
await result
|
||||||
|
return state
|
||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..build_info import BUILD_NUMBER, CHANGELOG
|
from ..build_info import BUILD_NUMBER, CHANGELOG
|
||||||
|
from ..config import normalize_banner_color
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/site", tags=["site"])
|
router = APIRouter(prefix="/site", tags=["site"])
|
||||||
@@ -15,6 +16,7 @@ _BANNER_TONES = {"info", "warning", "error", "maintenance"}
|
|||||||
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
banner_message = (runtime.site_banner_message or "").strip()
|
banner_message = (runtime.site_banner_message or "").strip()
|
||||||
|
login_message = (runtime.site_login_message or "").strip()
|
||||||
tone = (runtime.site_banner_tone or "info").strip().lower()
|
tone = (runtime.site_banner_tone or "info").strip().lower()
|
||||||
if tone not in _BANNER_TONES:
|
if tone not in _BANNER_TONES:
|
||||||
tone = "info"
|
tone = "info"
|
||||||
@@ -24,8 +26,11 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|||||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||||
"message": banner_message,
|
"message": banner_message,
|
||||||
"tone": tone,
|
"tone": tone,
|
||||||
|
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
|
||||||
|
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
|
"message": login_message,
|
||||||
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
||||||
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
||||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ _INT_FIELDS = {
|
|||||||
"log_file_backup_count",
|
"log_file_backup_count",
|
||||||
"requests_sync_ttl_minutes",
|
"requests_sync_ttl_minutes",
|
||||||
"requests_poll_interval_seconds",
|
"requests_poll_interval_seconds",
|
||||||
|
"requests_stage_refresh_minutes",
|
||||||
"requests_delta_sync_interval_minutes",
|
"requests_delta_sync_interval_minutes",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"issue_confirmation_contact_attempts",
|
"issue_confirmation_contact_attempts",
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Transactional, versioned SQLite schema migrations for Magent."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
MigrationStep = Callable[[sqlite3.Connection], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Migration:
|
||||||
|
version: int
|
||||||
|
name: str
|
||||||
|
apply: MigrationStep
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||||
|
return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
|
||||||
|
column = definition.split(maxsplit=1)[0].strip('"')
|
||||||
|
if column not in _column_names(conn, table):
|
||||||
|
conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
|
||||||
|
for definition in (
|
||||||
|
"email TEXT",
|
||||||
|
"last_login_at TEXT",
|
||||||
|
"is_blocked INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"auth_provider TEXT NOT NULL DEFAULT 'local'",
|
||||||
|
"jellyfin_password_hash TEXT",
|
||||||
|
"last_jellyfin_auth_at TEXT",
|
||||||
|
"jellyseerr_user_id INTEGER",
|
||||||
|
"auto_search_enabled INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"invite_management_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"profile_id INTEGER",
|
||||||
|
"expires_at TEXT",
|
||||||
|
"invited_by_code TEXT",
|
||||||
|
"invited_at TEXT",
|
||||||
|
"auth_version INTEGER NOT NULL DEFAULT 1",
|
||||||
|
):
|
||||||
|
_add_column(conn, "users", definition)
|
||||||
|
|
||||||
|
for definition in ("recipient_email TEXT", "code_hint TEXT"):
|
||||||
|
_add_column(conn, "signup_invites", definition)
|
||||||
|
|
||||||
|
for definition in (
|
||||||
|
"related_item_id INTEGER",
|
||||||
|
"workflow_request_status TEXT",
|
||||||
|
"workflow_media_status TEXT",
|
||||||
|
"issue_type TEXT",
|
||||||
|
"issue_resolved_at TEXT",
|
||||||
|
"metadata_json TEXT",
|
||||||
|
):
|
||||||
|
_add_column(conn, "portal_items", definition)
|
||||||
|
|
||||||
|
_add_column(conn, "requests_cache", "requested_by_id INTEGER")
|
||||||
|
|
||||||
|
statements = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
|
||||||
|
"(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
|
||||||
|
"(related_item_id, updated_at DESC, id DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at ON requests_cache "
|
||||||
|
"(requested_by_id, created_at DESC, request_id DESC)",
|
||||||
|
)
|
||||||
|
for statement in statements:
|
||||||
|
conn.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATIONS = (
|
||||||
|
Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
|
||||||
|
completed: list[int] = []
|
||||||
|
for migration in MIGRATIONS:
|
||||||
|
if migration.version in applied:
|
||||||
|
continue
|
||||||
|
savepoint = f"magent_migration_{migration.version}"
|
||||||
|
conn.execute(f"SAVEPOINT {savepoint}")
|
||||||
|
try:
|
||||||
|
migration.apply(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||||
|
(migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||||
|
except Exception:
|
||||||
|
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
||||||
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||||
|
raise
|
||||||
|
completed.append(migration.version)
|
||||||
|
return completed
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ENCRYPTED_PREFIX = "enc:v1:"
|
||||||
|
SENSITIVE_SETTING_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"jellystat_api_key", "magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
|
||||||
|
"magent_notify_email_smtp_password", "magent_notify_discord_webhook_url",
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet_key() -> bytes:
|
||||||
|
configured = str(settings.settings_encryption_key or "").strip()
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(configured.encode("ascii"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must be a valid Fernet key") from exc
|
||||||
|
if len(decoded) != 32:
|
||||||
|
raise RuntimeError("SETTINGS_ENCRYPTION_KEY must decode to exactly 32 bytes")
|
||||||
|
return configured.encode("ascii")
|
||||||
|
jwt_secret = str(settings.jwt_secret or "").strip()
|
||||||
|
if len(jwt_secret) < 32 or jwt_secret == "change-me":
|
||||||
|
raise RuntimeError(
|
||||||
|
"SETTINGS_ENCRYPTION_KEY is required when JWT_SECRET is not a strong migration key"
|
||||||
|
)
|
||||||
|
derived = hashlib.sha256(("magent-settings-v1:" + jwt_secret).encode("utf-8")).digest()
|
||||||
|
return base64.urlsafe_b64encode(derived)
|
||||||
|
|
||||||
|
|
||||||
|
def is_sensitive_setting(key: str) -> bool:
|
||||||
|
return str(key or "").strip().lower() in SENSITIVE_SETTING_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_secret_storage_configuration() -> None:
|
||||||
|
"""Validate the configured or JWT-derived Fernet key without touching stored data."""
|
||||||
|
Fernet(_fernet_key())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
|
||||||
|
if value is None or not is_sensitive_setting(key):
|
||||||
|
return value
|
||||||
|
text = str(value)
|
||||||
|
if text.startswith(ENCRYPTED_PREFIX):
|
||||||
|
return text
|
||||||
|
token = Fernet(_fernet_key()).encrypt(text.encode("utf-8")).decode("ascii")
|
||||||
|
return ENCRYPTED_PREFIX + token
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_setting_value(key: str, value: Optional[str]) -> Optional[str]:
|
||||||
|
if value is None or not is_sensitive_setting(key):
|
||||||
|
return value
|
||||||
|
text = str(value)
|
||||||
|
if not text.startswith(ENCRYPTED_PREFIX):
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
return Fernet(_fernet_key()).decrypt(
|
||||||
|
text[len(ENCRYPTED_PREFIX) :].encode("ascii")
|
||||||
|
).decode("utf-8")
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Stored secret '{key}' cannot be decrypted with the configured key"
|
||||||
|
) from exc
|
||||||
+50
-7
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import uuid
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
@@ -7,9 +8,15 @@ from jwt import InvalidTokenError
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
_pwd_context = CryptContext(
|
||||||
|
schemes=["argon2", "pbkdf2_sha256"],
|
||||||
|
deprecated=["pbkdf2_sha256"],
|
||||||
|
argon2__memory_cost=65536,
|
||||||
|
argon2__time_cost=3,
|
||||||
|
argon2__parallelism=4,
|
||||||
|
)
|
||||||
_ALGORITHM = "HS256"
|
_ALGORITHM = "HS256"
|
||||||
MIN_PASSWORD_LENGTH = 8
|
MIN_PASSWORD_LENGTH = 12
|
||||||
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +25,17 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
try:
|
||||||
return _pwd_context.verify(plain_password, hashed_password)
|
return _pwd_context.verify(plain_password, hashed_password)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_and_update_password(plain_password: str, hashed_password: str) -> tuple[bool, Optional[str]]:
|
||||||
|
try:
|
||||||
|
return _pwd_context.verify_and_update(plain_password, hashed_password)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
def validate_password_policy(password: str) -> str:
|
def validate_password_policy(password: str) -> str:
|
||||||
@@ -34,32 +51,58 @@ def _create_token(
|
|||||||
*,
|
*,
|
||||||
expires_at: datetime,
|
expires_at: datetime,
|
||||||
token_type: str = "access",
|
token_type: str = "access",
|
||||||
|
auth_version: int = 1,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
issued_at = datetime.now(timezone.utc)
|
||||||
payload: Dict[str, Any] = {
|
payload: Dict[str, Any] = {
|
||||||
"sub": subject,
|
"sub": subject,
|
||||||
"role": role,
|
"role": role,
|
||||||
"typ": token_type,
|
"typ": token_type,
|
||||||
"exp": expires_at,
|
"exp": expires_at,
|
||||||
|
"iat": issued_at,
|
||||||
|
"jti": uuid.uuid4().hex,
|
||||||
|
"iss": settings.jwt_issuer,
|
||||||
|
"aud": settings.jwt_audience,
|
||||||
|
"ver": max(1, int(auth_version or 1)),
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
def create_access_token(
|
||||||
|
subject: str,
|
||||||
|
role: str,
|
||||||
|
expires_minutes: Optional[int] = None,
|
||||||
|
*,
|
||||||
|
auth_version: int = 1,
|
||||||
|
) -> str:
|
||||||
if not settings.jwt_secret:
|
if not settings.jwt_secret:
|
||||||
raise ValueError("JWT_SECRET is not configured")
|
raise ValueError("JWT_SECRET is not configured")
|
||||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
return _create_token(subject, role, expires_at=expires, token_type="access", auth_version=auth_version)
|
||||||
|
|
||||||
|
|
||||||
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
|
def create_stream_token(
|
||||||
|
subject: str,
|
||||||
|
role: str,
|
||||||
|
expires_seconds: int = 120,
|
||||||
|
*,
|
||||||
|
auth_version: int = 1,
|
||||||
|
) -> str:
|
||||||
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
|
||||||
return _create_token(subject, role, expires_at=expires, token_type="sse")
|
return _create_token(subject, role, expires_at=expires, token_type="sse", auth_version=auth_version)
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Dict[str, Any]:
|
def decode_token(token: str) -> Dict[str, Any]:
|
||||||
if not settings.jwt_secret:
|
if not settings.jwt_secret:
|
||||||
raise ValueError("JWT_SECRET is not configured")
|
raise ValueError("JWT_SECRET is not configured")
|
||||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithms=[_ALGORITHM],
|
||||||
|
audience=settings.jwt_audience,
|
||||||
|
issuer=settings.jwt_issuer,
|
||||||
|
options={"require": ["exp", "iat", "jti", "iss", "aud", "sub", "typ", "ver"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TokenError(Exception):
|
class TokenError(Exception):
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Shared Sonarr/Radarr configuration helpers."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class RootFolderNotFoundError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
|
||||||
|
configured = str(root_folder or "").strip()
|
||||||
|
if not configured.isdigit():
|
||||||
|
return configured
|
||||||
|
folders = await client.get_root_folders()
|
||||||
|
if isinstance(folders, list):
|
||||||
|
for folder in folders:
|
||||||
|
if isinstance(folder, dict) and folder.get("id") == int(configured):
|
||||||
|
path = str(folder.get("path") or "").strip()
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
"""Encrypted, portable backups and restart-only SQLite restores.
|
||||||
|
|
||||||
|
Restore is deliberately a two-step operation: the authenticated request validates
|
||||||
|
and stages it, then a single backend process applies it before opening the DB.
|
||||||
|
A durable journal and a private rollback copy protect interrupted installations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import closing, contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, BinaryIO, Iterator
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
||||||
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
|
from ..config import Settings, settings
|
||||||
|
from ..db import _db_path
|
||||||
|
from ..schema_migrations import MIGRATIONS
|
||||||
|
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
|
||||||
|
|
||||||
|
FORMAT_VERSION = 1
|
||||||
|
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||||
|
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
|
||||||
|
MAX_ENTRIES = 20_000
|
||||||
|
MAGIC = b"MAGENT-BACKUP\x00\x01"
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||||
|
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||||
|
# Host identity, process controls and local file locations belong to the target.
|
||||||
|
_LOCAL_FIELDS = {
|
||||||
|
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
|
||||||
|
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
|
||||||
|
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
|
||||||
|
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
|
||||||
|
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
|
||||||
|
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
|
||||||
|
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
|
||||||
|
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
|
||||||
|
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(ValueError):
|
||||||
|
"""A safe-to-display backup validation or state error."""
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _assets_root() -> Path:
|
||||||
|
# Matches the image and branding routers, independently of SQLITE_PATH.
|
||||||
|
return Path.cwd() / "data"
|
||||||
|
|
||||||
|
|
||||||
|
def _control_root() -> Path:
|
||||||
|
return Path(_db_path()).absolute().parent / "backups"
|
||||||
|
|
||||||
|
|
||||||
|
def _private_dir(path: Path) -> None:
|
||||||
|
if path.is_symlink():
|
||||||
|
raise BackupError("Backup directories must not be symbolic links")
|
||||||
|
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
path.chmod(0o700)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_private(path: Path, content: bytes) -> None:
|
||||||
|
with path.open("xb") as handle:
|
||||||
|
path.chmod(0o600)
|
||||||
|
handle.write(content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, data: dict) -> None:
|
||||||
|
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
|
||||||
|
try:
|
||||||
|
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
_sync_directory(path.parent)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_directory(path: Path) -> None:
|
||||||
|
if os.name != "nt":
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_tree(path: Path) -> None:
|
||||||
|
for parent, _directories, files in os.walk(path, topdown=False):
|
||||||
|
for filename in files:
|
||||||
|
with (Path(parent) / filename).open("r+b") as handle:
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
_sync_directory(Path(parent))
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _exclusive_operation() -> Iterator[None]:
|
||||||
|
if not _LOCK.acquire(blocking=False):
|
||||||
|
raise BackupError("Another backup or restore operation is in progress")
|
||||||
|
handle = None
|
||||||
|
locked = False
|
||||||
|
try:
|
||||||
|
root = _control_root()
|
||||||
|
_private_dir(root)
|
||||||
|
handle = (root / "operation.lock").open("a+b")
|
||||||
|
os.chmod(handle.name, 0o600)
|
||||||
|
# OS locks are released even if a process crashes; support the dev host too.
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
handle.seek(0)
|
||||||
|
if not handle.read(1):
|
||||||
|
handle.write(b"0")
|
||||||
|
handle.flush()
|
||||||
|
handle.seek(0)
|
||||||
|
try:
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||||
|
except OSError as exc:
|
||||||
|
raise BackupError("Another backup or restore operation is in progress") from exc
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError as exc:
|
||||||
|
raise BackupError("Another backup or restore operation is in progress") from exc
|
||||||
|
locked = True
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if handle is not None:
|
||||||
|
if locked:
|
||||||
|
if os.name == "nt":
|
||||||
|
import msvcrt
|
||||||
|
handle.seek(0)
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
handle.close()
|
||||||
|
_LOCK.release()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_passphrase(passphrase: str) -> None:
|
||||||
|
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
|
||||||
|
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
|
||||||
|
|
||||||
|
|
||||||
|
def _key(passphrase: str, salt: bytes) -> bytes:
|
||||||
|
validate_passphrase(passphrase)
|
||||||
|
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt(content: bytes, passphrase: str) -> bytes:
|
||||||
|
salt, nonce = os.urandom(16), os.urandom(12)
|
||||||
|
header = MAGIC + salt + nonce
|
||||||
|
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt(content: bytes, passphrase: str) -> bytes:
|
||||||
|
header_size = len(MAGIC) + 28
|
||||||
|
if len(content) > MAX_UPLOAD_BYTES:
|
||||||
|
raise BackupError("Backup exceeds the 32 MiB upload limit")
|
||||||
|
if len(content) < header_size + 16 or not content.startswith(MAGIC):
|
||||||
|
raise BackupError("This is not a supported encrypted Magent backup")
|
||||||
|
salt = content[len(MAGIC):len(MAGIC) + 16]
|
||||||
|
nonce = content[len(MAGIC) + 16:header_size]
|
||||||
|
try:
|
||||||
|
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
|
||||||
|
except InvalidTag as exc:
|
||||||
|
raise BackupError("Incorrect passphrase or damaged backup") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _database_copy(source: Path, destination: Path) -> None:
|
||||||
|
if not source.is_file() or source.is_symlink():
|
||||||
|
raise BackupError("The configured database is unavailable or is a symbolic link")
|
||||||
|
deadline = time.monotonic() + 60
|
||||||
|
|
||||||
|
def progress(_status: int, _remaining: int, _total: int) -> None:
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise BackupError("Database is too busy to back up; try again shortly")
|
||||||
|
|
||||||
|
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
|
||||||
|
with closing(sqlite3.connect(destination)) as dst:
|
||||||
|
destination.chmod(0o600)
|
||||||
|
src.backup(dst, pages=256, progress=progress, sleep=0.05)
|
||||||
|
dst.execute("PRAGMA journal_mode=DELETE")
|
||||||
|
|
||||||
|
|
||||||
|
def _portable_database(path: Path) -> None:
|
||||||
|
"""Materialize env-backed settings and remove source-specific encryption."""
|
||||||
|
with closing(sqlite3.connect(path)) as conn, conn:
|
||||||
|
conn.execute("PRAGMA secure_delete=ON")
|
||||||
|
# init_db recreates application-owned triggers after restoration; never
|
||||||
|
# distribute executable schema objects in a data backup.
|
||||||
|
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
|
||||||
|
quoted = str(trigger).replace('"', '""')
|
||||||
|
conn.execute(f'DROP TRIGGER "{quoted}"')
|
||||||
|
overrides = dict(conn.execute("SELECT key, value FROM settings"))
|
||||||
|
for key, default in settings.model_dump().items():
|
||||||
|
if key in _LOCAL_FIELDS:
|
||||||
|
continue
|
||||||
|
value = overrides.get(key)
|
||||||
|
value = default if value is None else decrypt_setting_value(key, value)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
|
||||||
|
(key, "" if value is None else str(value), _now()),
|
||||||
|
)
|
||||||
|
for key in _LOCAL_FIELDS:
|
||||||
|
conn.execute("DELETE FROM settings WHERE key=?", (key,))
|
||||||
|
# Future secret keys may not yet be exposed through Settings.
|
||||||
|
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
|
||||||
|
if key in SENSITIVE_SETTING_KEYS:
|
||||||
|
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
|
||||||
|
conn.commit()
|
||||||
|
conn.execute("VACUUM")
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_allowed(name: str, include_cache: bool) -> bool:
|
||||||
|
parts = PurePosixPath(name).parts
|
||||||
|
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
|
||||||
|
return True
|
||||||
|
return bool(
|
||||||
|
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
|
||||||
|
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
|
||||||
|
and parts[4] not in {".", ".."}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
|
||||||
|
root = _assets_root()
|
||||||
|
for directory in ("branding", "artwork") if include_cache else ("branding",):
|
||||||
|
base = root / directory
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
if base.is_symlink() or root.is_symlink():
|
||||||
|
raise BackupError("Asset directories must not be symbolic links")
|
||||||
|
for parent, directories, files in os.walk(base, followlinks=False):
|
||||||
|
if any((Path(parent) / name).is_symlink() for name in directories + files):
|
||||||
|
raise BackupError("Symbolic links are not supported in backup assets")
|
||||||
|
for filename in files:
|
||||||
|
path = Path(parent) / filename
|
||||||
|
archive_name = "files/" + path.relative_to(root).as_posix()
|
||||||
|
if _asset_allowed(archive_name, include_cache):
|
||||||
|
yield path, archive_name
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
|
||||||
|
validate_passphrase(passphrase)
|
||||||
|
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
directory.chmod(0o700)
|
||||||
|
database = directory / "database.sqlite3"
|
||||||
|
_database_copy(Path(_db_path()).absolute(), database)
|
||||||
|
_portable_database(database)
|
||||||
|
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
|
||||||
|
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
|
||||||
|
raise BackupError("Backup is too large; retry without the artwork cache")
|
||||||
|
archive_path = directory / "payload.zip"
|
||||||
|
manifest = {
|
||||||
|
"format_version": FORMAT_VERSION, "created_at": _now(),
|
||||||
|
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
|
||||||
|
"files": {},
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||||
|
archive_path.chmod(0o600)
|
||||||
|
total = 0
|
||||||
|
for path, name in files:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with path.open("rb") as source, archive.open(name, "w") as destination:
|
||||||
|
while chunk := source.read(1024 * 1024):
|
||||||
|
total += len(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
if total > MAX_EXPANDED_BYTES:
|
||||||
|
raise BackupError("Backup is too large; retry without the artwork cache")
|
||||||
|
digest.update(chunk)
|
||||||
|
destination.write(chunk)
|
||||||
|
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
|
||||||
|
archive.writestr("manifest.json", json.dumps(manifest))
|
||||||
|
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
|
||||||
|
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
|
||||||
|
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
return encrypted, f"magent-backup-{stamp}.magent-backup"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
|
||||||
|
try:
|
||||||
|
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
|
||||||
|
conn.execute("PRAGMA trusted_schema=OFF")
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
|
||||||
|
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
|
||||||
|
raise BackupError("Backup database failed its integrity check")
|
||||||
|
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
|
||||||
|
if len(schema) > 500 or any(
|
||||||
|
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
|
||||||
|
for kind, _name, sql in schema
|
||||||
|
):
|
||||||
|
raise BackupError("Backup contains an unsupported database schema")
|
||||||
|
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||||
|
raise BackupError("Backup database contains broken references")
|
||||||
|
required = {
|
||||||
|
"settings": {"key", "value", "updated_at"},
|
||||||
|
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
|
||||||
|
"signup_invites": {"id", "code", "enabled"},
|
||||||
|
"requests_cache": {"request_id", "payload_json"},
|
||||||
|
"schema_migrations": {"version", "name", "applied_at"},
|
||||||
|
"password_reset_tokens": {"id", "token_hash"},
|
||||||
|
}
|
||||||
|
for table, fields in required.items():
|
||||||
|
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
|
||||||
|
if not fields <= columns:
|
||||||
|
raise BackupError("Backup does not contain a compatible Magent database")
|
||||||
|
optional = {
|
||||||
|
"installation_setup": {"id", "completed", "step", "completed_at"},
|
||||||
|
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
|
||||||
|
}
|
||||||
|
table_names = {name for kind, name, _sql in schema if kind == "table"}
|
||||||
|
for table, fields in optional.items():
|
||||||
|
if table in table_names:
|
||||||
|
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
|
||||||
|
if not fields <= columns:
|
||||||
|
raise BackupError("Backup setup state has an incompatible schema")
|
||||||
|
# An admin can stage a restore only after target initialization. Its
|
||||||
|
# schema is a trusted reference for *all* runtime columns, including
|
||||||
|
# versioned migrations that init_db will not rerun on a restored DB.
|
||||||
|
target = Path(_db_path()).absolute()
|
||||||
|
if target.is_file() and target != path:
|
||||||
|
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
|
||||||
|
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||||
|
for table in tables:
|
||||||
|
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
|
||||||
|
continue
|
||||||
|
quoted = str(table).replace('"', '""')
|
||||||
|
expected = {
|
||||||
|
row[1]: (row[2].upper(), bool(row[3]), row[5])
|
||||||
|
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
|
||||||
|
}
|
||||||
|
actual = {
|
||||||
|
row[1]: (row[2].upper(), bool(row[3]), row[5])
|
||||||
|
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
|
||||||
|
}
|
||||||
|
if expected != actual:
|
||||||
|
raise BackupError("Backup is missing database columns required by this installation")
|
||||||
|
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
|
||||||
|
if versions != {migration.version for migration in MIGRATIONS}:
|
||||||
|
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
|
||||||
|
if not conn.execute(
|
||||||
|
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
|
||||||
|
).fetchone():
|
||||||
|
raise BackupError("Backup must contain an active administrator account")
|
||||||
|
values = dict(conn.execute("SELECT key,value FROM settings"))
|
||||||
|
if _LOCAL_FIELDS.intersection(values):
|
||||||
|
raise BackupError("Backup contains host-specific configuration")
|
||||||
|
# Pydantic checks the types of portable settings without reading env values.
|
||||||
|
for key, value in values.items():
|
||||||
|
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
|
||||||
|
value = decrypt_setting_value(key, value)
|
||||||
|
if key in Settings.model_fields and value not in {None, ""}:
|
||||||
|
field = Settings.model_fields[key]
|
||||||
|
TypeAdapter(field.rebuild_annotation()).validate_python(value)
|
||||||
|
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
|
||||||
|
if isinstance(exc, BackupError):
|
||||||
|
raise
|
||||||
|
raise BackupError("Backup database or configuration is invalid") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||||
|
entries = archive.infolist()
|
||||||
|
if not entries or len(entries) > MAX_ENTRIES:
|
||||||
|
raise BackupError("Backup contains too many files")
|
||||||
|
names = [entry.filename for entry in entries]
|
||||||
|
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
|
||||||
|
raise BackupError("Backup manifest is missing or contains duplicate files")
|
||||||
|
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
|
||||||
|
raise BackupError("Expanded backup exceeds the 128 MiB limit")
|
||||||
|
for entry in entries:
|
||||||
|
parts = PurePosixPath(entry.filename).parts
|
||||||
|
mode = entry.external_attr >> 16
|
||||||
|
if (
|
||||||
|
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
|
||||||
|
or str(PurePosixPath(entry.filename)) != entry.filename
|
||||||
|
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
|
||||||
|
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
|
||||||
|
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
|
||||||
|
):
|
||||||
|
raise BackupError("Backup contains an unsafe archive entry")
|
||||||
|
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
|
||||||
|
raise BackupError("Backup manifest is too large")
|
||||||
|
manifest = json.loads(archive.read("manifest.json"))
|
||||||
|
if (
|
||||||
|
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
|
||||||
|
or not isinstance(manifest.get("include_cache"), bool)
|
||||||
|
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
|
||||||
|
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
|
||||||
|
or not isinstance(manifest.get("files"), dict)
|
||||||
|
or set(manifest["files"]) != set(names) - {"manifest.json"}
|
||||||
|
):
|
||||||
|
raise BackupError("Backup manifest is invalid or unsupported")
|
||||||
|
extracted_bytes = 0
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.filename
|
||||||
|
if name == "manifest.json":
|
||||||
|
continue
|
||||||
|
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
|
||||||
|
raise BackupError("Backup contains an unsupported file")
|
||||||
|
expected = manifest["files"][name]
|
||||||
|
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
|
||||||
|
raise BackupError("Backup file does not match its manifest")
|
||||||
|
target = directory.joinpath(*PurePosixPath(name).parts)
|
||||||
|
_private_dir(target.parent)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with archive.open(entry) as source, target.open("xb") as destination:
|
||||||
|
target.chmod(0o600)
|
||||||
|
while chunk := source.read(1024 * 1024):
|
||||||
|
extracted_bytes += len(chunk)
|
||||||
|
if extracted_bytes > MAX_EXPANDED_BYTES:
|
||||||
|
raise BackupError("Expanded backup exceeds the 128 MiB limit")
|
||||||
|
digest.update(chunk)
|
||||||
|
destination.write(chunk)
|
||||||
|
destination.flush()
|
||||||
|
os.fsync(destination.fileno())
|
||||||
|
if digest.hexdigest() != expected.get("sha256"):
|
||||||
|
raise BackupError("Backup file failed its checksum")
|
||||||
|
_validate_database(directory / "database.sqlite3")
|
||||||
|
return manifest
|
||||||
|
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
|
||||||
|
if isinstance(exc, BackupError):
|
||||||
|
raise
|
||||||
|
raise BackupError("Backup archive is invalid or damaged") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
|
||||||
|
validate_passphrase(passphrase)
|
||||||
|
with _exclusive_operation():
|
||||||
|
root = _control_root()
|
||||||
|
pending = root / "pending"
|
||||||
|
if pending.exists():
|
||||||
|
raise BackupError("A restore is already staged; cancel it before uploading another")
|
||||||
|
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
|
||||||
|
stage = Path(temporary)
|
||||||
|
stage.chmod(0o700)
|
||||||
|
manifest = _extract_archive(payload, stage)
|
||||||
|
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
|
||||||
|
conn.execute("PRAGMA secure_delete=ON")
|
||||||
|
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
|
||||||
|
if key in SENSITIVE_SETTING_KEYS:
|
||||||
|
if value and str(value).startswith("enc:v1:"):
|
||||||
|
raise BackupError("Backup settings are not portable")
|
||||||
|
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
|
||||||
|
# Do not revive reset links or existing browser sessions. Invites remain intact.
|
||||||
|
conn.execute("DELETE FROM password_reset_tokens")
|
||||||
|
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
|
||||||
|
if not manifest["include_cache"]:
|
||||||
|
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
|
||||||
|
conn.commit()
|
||||||
|
# Remove plaintext secret remnants from replaced/free SQLite pages.
|
||||||
|
conn.execute("VACUUM")
|
||||||
|
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
|
||||||
|
metadata["staged_at"] = _now()
|
||||||
|
_write_json(stage / "metadata.json", metadata)
|
||||||
|
# Stage survives reboot; it contains only secrets encrypted for this installation.
|
||||||
|
os.replace(stage, pending)
|
||||||
|
_sync_directory(root)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def backup_status() -> dict[str, Any]:
|
||||||
|
root = _control_root()
|
||||||
|
pending_path = root / "pending" / "metadata.json"
|
||||||
|
last_path = root / "last-restore.json"
|
||||||
|
return {
|
||||||
|
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
|
||||||
|
"max_expanded_bytes": MAX_EXPANDED_BYTES,
|
||||||
|
"include_cache_default": False,
|
||||||
|
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
|
||||||
|
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_restore() -> None:
|
||||||
|
with _exclusive_operation():
|
||||||
|
pending = _control_root() / "pending"
|
||||||
|
if pending.is_symlink():
|
||||||
|
raise BackupError("Invalid staged restore directory")
|
||||||
|
if pending.exists():
|
||||||
|
shutil.rmtree(pending)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_file(source: Path, target: Path) -> None:
|
||||||
|
_private_dir(target.parent)
|
||||||
|
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
|
||||||
|
try:
|
||||||
|
shutil.copyfile(source, temporary)
|
||||||
|
temporary.chmod(0o600)
|
||||||
|
with temporary.open("r+b") as handle:
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, target)
|
||||||
|
_sync_directory(target.parent)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_assets(source: Path, target: Path) -> None:
|
||||||
|
if target.is_symlink():
|
||||||
|
raise BackupError("Asset directories must not be symbolic links")
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
if source.exists():
|
||||||
|
shutil.copytree(source, target, copy_function=shutil.copyfile)
|
||||||
|
for parent, _directories, files in os.walk(target):
|
||||||
|
Path(parent).chmod(0o700)
|
||||||
|
for filename in files:
|
||||||
|
(Path(parent) / filename).chmod(0o600)
|
||||||
|
_sync_tree(target)
|
||||||
|
if target.parent.exists():
|
||||||
|
_sync_directory(target.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _recover(journal: dict, root: Path) -> None:
|
||||||
|
rollback_name = journal.get("rollback_directory", "")
|
||||||
|
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
|
||||||
|
raise BackupError("Restore recovery journal is invalid")
|
||||||
|
rollback = root / rollback_name
|
||||||
|
database = Path(_db_path()).absolute()
|
||||||
|
if journal["had_database"]:
|
||||||
|
_replace_file(rollback / "database.sqlite3", database)
|
||||||
|
else:
|
||||||
|
database.unlink(missing_ok=True)
|
||||||
|
for suffix in ("-wal", "-shm", "-journal"):
|
||||||
|
Path(str(database) + suffix).unlink(missing_ok=True)
|
||||||
|
for name in journal["asset_roots"]:
|
||||||
|
if name not in {"branding", "artwork"}:
|
||||||
|
raise BackupError("Restore recovery journal is invalid")
|
||||||
|
_replace_assets(rollback / "files" / name, _assets_root() / name)
|
||||||
|
_write_json(root / "last-restore.json", {
|
||||||
|
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
|
||||||
|
"message": "An interrupted or failed restore was rolled back automatically.",
|
||||||
|
})
|
||||||
|
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
|
||||||
|
pending = root / "pending"
|
||||||
|
if pending.exists():
|
||||||
|
shutil.rmtree(pending)
|
||||||
|
(root / "restore-journal.json").unlink()
|
||||||
|
_sync_directory(root)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pending_restore() -> bool:
|
||||||
|
"""Call once before init_db, with no other backend processes using the DB."""
|
||||||
|
with _exclusive_operation():
|
||||||
|
root = _control_root()
|
||||||
|
journal_path = root / "restore-journal.json"
|
||||||
|
if journal_path.exists():
|
||||||
|
journal = json.loads(journal_path.read_text())
|
||||||
|
if journal.get("phase") in {"complete", "rolled_back"}:
|
||||||
|
if (root / "pending").exists():
|
||||||
|
shutil.rmtree(root / "pending")
|
||||||
|
journal_path.unlink()
|
||||||
|
_sync_directory(root)
|
||||||
|
return journal["phase"] == "complete"
|
||||||
|
_recover(journal, root)
|
||||||
|
return False
|
||||||
|
pending = root / "pending"
|
||||||
|
if not pending.exists():
|
||||||
|
return False
|
||||||
|
if pending.is_symlink():
|
||||||
|
raise BackupError("Invalid staged restore directory")
|
||||||
|
metadata = json.loads((pending / "metadata.json").read_text())
|
||||||
|
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
|
||||||
|
database = Path(_db_path()).absolute()
|
||||||
|
rollback = root / ("rollback-" + uuid.uuid4().hex)
|
||||||
|
_private_dir(rollback)
|
||||||
|
# Ensure all disk-space/permission failures in backup happen before replacement.
|
||||||
|
if database.exists():
|
||||||
|
_database_copy(database, rollback / "database.sqlite3")
|
||||||
|
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
|
||||||
|
# Reject links anywhere before copying or deleting the controlled asset trees.
|
||||||
|
list(_asset_files(metadata["include_cache"]))
|
||||||
|
for name in names:
|
||||||
|
source = _assets_root() / name
|
||||||
|
if source.exists():
|
||||||
|
shutil.copytree(source, rollback / "files" / name)
|
||||||
|
_sync_tree(rollback)
|
||||||
|
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
|
||||||
|
_write_json(journal_path, journal)
|
||||||
|
try:
|
||||||
|
for suffix in ("-wal", "-shm", "-journal"):
|
||||||
|
Path(str(database) + suffix).unlink(missing_ok=True)
|
||||||
|
_replace_file(pending / "database.sqlite3", database)
|
||||||
|
for name in names:
|
||||||
|
_replace_assets(pending / "files" / name, _assets_root() / name)
|
||||||
|
_write_json(root / "last-restore.json", {
|
||||||
|
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
|
||||||
|
"backup_created_at": metadata["created_at"],
|
||||||
|
})
|
||||||
|
_write_json(journal_path, {**journal, "phase": "complete"})
|
||||||
|
except Exception:
|
||||||
|
_recover(journal, root)
|
||||||
|
raise
|
||||||
|
shutil.rmtree(pending)
|
||||||
|
journal_path.unlink()
|
||||||
|
_sync_directory(root)
|
||||||
|
return True
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Reviewed consolidation of same-name Jellyfin accounts, entirely within Magent."""
|
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
@@ -30,14 +30,19 @@ def account_state(conn, ids):
|
|||||||
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
|
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
|
||||||
|
|
||||||
|
|
||||||
|
def identity_group(report, target):
|
||||||
|
identity = target['candidate_jellyfin_id']
|
||||||
|
return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
|
||||||
|
|
||||||
|
|
||||||
def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
||||||
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
|
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
|
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
|
||||||
group = [row for row in report['rows'] if review.name_key(row['user']['username']) == review.name_key(target['user']['username'])]
|
group = identity_group(report, target)
|
||||||
ids = {row['user']['id'] for row in group}
|
ids = {row['user']['id'] for row in group}
|
||||||
if len(ids) < 2:
|
if len(ids) < 2:
|
||||||
raise HTTPException(409, 'No same-name duplicate group remains. Run the account check again.')
|
raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
|
||||||
jf_id = target['candidate_jellyfin_id']
|
jf_id = target['candidate_jellyfin_id']
|
||||||
source = source_key(runtime.jellyfin_base_url)
|
source = source_key(runtime.jellyfin_base_url)
|
||||||
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
|
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
|
||||||
@@ -50,14 +55,14 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
|||||||
problems.append('Restore all three media-service connections before consolidating accounts.')
|
problems.append('Restore all three media-service connections before consolidating accounts.')
|
||||||
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
|
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
|
||||||
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
|
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
|
||||||
if target['jellyfin'] and review.name_key(target['jellyfin']['name']) != review.name_key(target['user']['username']):
|
|
||||||
problems.append('The current Jellyfin name does not match this duplicate group.')
|
|
||||||
if len([account for account in report['jellyfin_users'] if review.name_key(account['name']) == review.name_key(target['user']['username'])]) != 1:
|
|
||||||
problems.append('The name must identify exactly one current Jellyfin account.')
|
|
||||||
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
|
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
|
||||||
for row in group:
|
for row in group:
|
||||||
if row['user']['role'] != 'user' or row['user']['auth_provider'] != 'jellyfin':
|
if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
|
||||||
problems.append('Only non-admin Jellyfin sign-in accounts can use duplicate consolidation.')
|
problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
|
||||||
|
if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
|
||||||
|
problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
|
||||||
|
if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
|
||||||
|
problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
|
||||||
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
|
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
|
||||||
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
|
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
|
||||||
for link in local['links']:
|
for link in local['links']:
|
||||||
@@ -76,12 +81,12 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
|||||||
problems.append('Another confirmation owns this identity.')
|
problems.append('Another confirmation owns this identity.')
|
||||||
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
|
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
|
||||||
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
|
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
|
||||||
problems.append('An account outside this same-name group also claims the identity.')
|
problems.append('An account outside this identity group also claims the identity.')
|
||||||
accounts = [account for account in state['users'] if account['id'] in ids]
|
accounts = [account for account in state['users'] if account['id'] in ids]
|
||||||
kept = next(account for account in accounts if account['id'] == keep_id)
|
kept = next(account for account in accounts if account['id'] == keep_id)
|
||||||
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
||||||
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
|
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
|
||||||
overrides.get((account['id'], key), True) for account in accounts) for key in FEATURES}
|
overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
|
||||||
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
|
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
|
||||||
try:
|
try:
|
||||||
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
|
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
|
||||||
@@ -105,7 +110,8 @@ async def prepare(user_id, keep_id=None):
|
|||||||
target = next((row for row in local['users'] if row['id'] == user_id), None)
|
target = next((row for row in local['users'] if row['id'] == user_id), None)
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, 'Account not found.')
|
raise HTTPException(404, 'Account not found.')
|
||||||
ids = sorted(row['id'] for row in local['users'] if review.name_key(row['username']) == review.name_key(target['username']))
|
report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
|
||||||
|
ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
|
||||||
with closing(db._connect()) as conn:
|
with closing(db._connect()) as conn:
|
||||||
conn.execute('BEGIN')
|
conn.execute('BEGIN')
|
||||||
if review.digest(review.snapshot(conn)) != review.digest(local):
|
if review.digest(review.snapshot(conn)) != review.digest(local):
|
||||||
@@ -146,7 +152,7 @@ def consolidate(preview, report, local, runtime, state, admin):
|
|||||||
for name in old_values:
|
for name in old_values:
|
||||||
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
|
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
|
||||||
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
|
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
|
||||||
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if review.name_key(row['username']) == review.name_key(values['username'])]
|
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
|
||||||
for entry in activity:
|
for entry in activity:
|
||||||
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
|
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
|
||||||
for entry in activity:
|
for entry in activity:
|
||||||
@@ -165,7 +171,7 @@ def consolidate(preview, report, local, runtime, state, admin):
|
|||||||
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
|
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
|
||||||
conn.execute('DELETE FROM users WHERE id=?', (identity,))
|
conn.execute('DELETE FROM users WHERE id=?', (identity,))
|
||||||
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
|
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
|
||||||
conn.execute('''UPDATE users SET username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
|
conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
|
||||||
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
|
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
|
||||||
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
|
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
|
||||||
values['features']['invites'], values['expires_at'], last_login, keep))
|
values['features']['invites'], values['expires_at'], last_login, keep))
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def worker_enabled() -> bool:
|
|||||||
def delivery_ready() -> tuple[bool, str]:
|
def delivery_ready() -> tuple[bool, str]:
|
||||||
config = store.settings()
|
config = store.settings()
|
||||||
if not config["public_url"]:
|
if not config["public_url"]:
|
||||||
return False, "Set the public Magent address for email links."
|
return False, "Set the application URL in Hosting & proxy for email links."
|
||||||
ready, detail = smtp_email_config_ready()
|
ready, detail = smtp_email_config_ready()
|
||||||
if not ready:
|
if not ready:
|
||||||
return False, detail
|
return False, detail
|
||||||
@@ -142,12 +142,40 @@ def completed_month(month: str | None) -> str:
|
|||||||
return period["month"]
|
return period["month"]
|
||||||
|
|
||||||
|
|
||||||
|
async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
|
||||||
|
"""Embed only signed artwork from this account's report; missing art is optional."""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from .insights_artwork import get_artwork
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
images = []
|
||||||
|
report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
|
||||||
|
|
||||||
|
async def picture(index, row):
|
||||||
|
match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data, mime = await get_artwork(account, runtime, *match.groups())
|
||||||
|
cid = f"recap-title-{index}@magent"
|
||||||
|
row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
|
||||||
|
images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
|
||||||
|
except Exception:
|
||||||
|
pass # An unavailable poster must never prevent a personal report.
|
||||||
|
|
||||||
|
await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
|
||||||
|
rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
|
||||||
|
if not preview:
|
||||||
|
rendered["inline_images"] = images
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
async def preview(user: dict, month: str | None) -> dict:
|
async def preview(user: dict, month: str | None) -> dict:
|
||||||
account = current_account(user)
|
account = current_account(user)
|
||||||
selected = completed_month(month)
|
selected = completed_month(month)
|
||||||
config = store.settings()
|
config = store.settings()
|
||||||
if not config["public_url"]:
|
if not config["public_url"]:
|
||||||
raise RecapError("Save the public Magent address before previewing an email.")
|
raise RecapError("Set the application URL in Hosting & proxy before previewing an email.")
|
||||||
try:
|
try:
|
||||||
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
|
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
|
||||||
except HistoryLimitError as exc:
|
except HistoryLimitError as exc:
|
||||||
@@ -156,8 +184,8 @@ async def preview(user: dict, month: str | None) -> dict:
|
|||||||
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
|
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
|
||||||
if report["state"] != "ready":
|
if report["state"] != "ready":
|
||||||
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
|
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
|
||||||
return {"month": selected, "email": account.get("email"), **mail.render_recap(
|
return {"month": selected, "email": account.get("email"), **await illustrated_recap(
|
||||||
report, account["username"], config["public_url"], config["public_url"] + "/profile#monthly-recaps")}
|
report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
|
||||||
|
|
||||||
|
|
||||||
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
|
||||||
@@ -200,7 +228,7 @@ async def process_delivery(delivery: dict) -> None:
|
|||||||
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
|
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
|
||||||
raise mail.DeliveryError("failed", "A complete personal report is not available.")
|
raise mail.DeliveryError("failed", "A complete personal report is not available.")
|
||||||
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
|
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
|
||||||
rendered = mail.render_recap(report, account["username"], delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
|
rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
|
||||||
|
|
||||||
def before_data():
|
def before_data():
|
||||||
eligible_delivery(delivery)
|
eligible_delivery(delivery)
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive
|
|||||||
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
|
||||||
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
||||||
daily_seconds = defaultdict(float)
|
daily_seconds = defaultdict(float)
|
||||||
|
weekdays = [0.0] * 7
|
||||||
|
media_minutes = defaultdict(float)
|
||||||
|
longest_play = 0.0
|
||||||
clients = defaultdict(float)
|
clients = defaultdict(float)
|
||||||
methods = defaultdict(float)
|
methods = defaultdict(float)
|
||||||
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
|
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
|
||||||
@@ -157,6 +160,9 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
|
|||||||
episode_ids.add(str(episode_id))
|
episode_ids.add(str(episode_id))
|
||||||
elif media_type == "movie":
|
elif media_type == "movie":
|
||||||
movie_ids.add(item_id)
|
movie_ids.add(item_id)
|
||||||
|
weekdays[date.weekday()] += duration / 60
|
||||||
|
media_minutes[media_type] += duration / 60
|
||||||
|
longest_play = max(longest_play, duration / 60)
|
||||||
seconds += duration
|
seconds += duration
|
||||||
daily_seconds[date.date().isoformat()] += duration
|
daily_seconds[date.date().isoformat()] += duration
|
||||||
client = str(row.get("Client") or "Unknown player")[:200]
|
client = str(row.get("Client") or "Unknown player")[:200]
|
||||||
@@ -166,7 +172,7 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
|
|||||||
methods[method] += duration
|
methods[method] += duration
|
||||||
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
||||||
series = str(row.get("SeriesName") or "")[:500]
|
series = str(row.get("SeriesName") or "")[:500]
|
||||||
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0})
|
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
|
||||||
title["minutes"] += duration / 60
|
title["minutes"] += duration / 60
|
||||||
title["plays"] += 1
|
title["plays"] += 1
|
||||||
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
|
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
|
||||||
@@ -193,6 +199,11 @@ def summarize(history: list, libraries: list, start: datetime, end: datetime, *,
|
|||||||
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
||||||
"episodes": len(episode_ids), "active_days": len(active_days),
|
"episodes": len(episode_ids), "active_days": len(active_days),
|
||||||
"current_streak": current, "longest_streak": longest},
|
"current_streak": current, "longest_streak": longest},
|
||||||
|
"patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0,
|
||||||
|
"longest_play_minutes": round(longest_play, 1),
|
||||||
|
"weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0,
|
||||||
|
"weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))],
|
||||||
|
"media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]},
|
||||||
"daily": daily, "top_titles": top,
|
"daily": daily, "top_titles": top,
|
||||||
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
|
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
|
||||||
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
|
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ def signature(user, runtime, media_id, expires):
|
|||||||
|
|
||||||
def with_artwork(data, user, runtime):
|
def with_artwork(data, user, runtime):
|
||||||
expires = int(time.time()) + TOKEN_SECONDS
|
expires = int(time.time()) + TOKEN_SECONDS
|
||||||
recent = []
|
result = {**data}
|
||||||
for play in data.get("recent", []):
|
for field in ("recent", "top_titles"):
|
||||||
|
rows = []
|
||||||
|
for play in data.get(field, []):
|
||||||
row = {**play}
|
row = {**play}
|
||||||
media_id = row.pop("artwork_item_id", None)
|
media_id = row.pop("artwork_item_id", None)
|
||||||
row["artwork_url"] = None
|
row["artwork_url"] = None
|
||||||
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
|
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
|
||||||
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
|
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
|
||||||
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
|
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
|
||||||
recent.append(row)
|
rows.append(row)
|
||||||
return {**data, "recent": recent}
|
result[field] = rows
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def verify_artwork_token(user, runtime, media_id, token):
|
def verify_artwork_token(user, runtime, media_id, token):
|
||||||
|
|||||||
@@ -1025,16 +1025,12 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
raise RuntimeError("SMTP email settings are incomplete.")
|
raise RuntimeError("SMTP email settings are incomplete.")
|
||||||
local_hostname = _derive_mail_hostname(from_address=from_address)
|
local_hostname = _derive_mail_hostname(from_address=from_address)
|
||||||
logger.info(
|
logger.info(
|
||||||
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
|
"smtp send started host=%s port=%s tls=%s ssl=%s auth=%s",
|
||||||
recipient_email,
|
|
||||||
from_address,
|
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
use_tls,
|
use_tls,
|
||||||
use_ssl,
|
use_ssl,
|
||||||
bool(username and password),
|
bool(username and password),
|
||||||
subject,
|
|
||||||
local_hostname,
|
|
||||||
)
|
)
|
||||||
if delivery_warning:
|
if delivery_warning:
|
||||||
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
||||||
@@ -1083,11 +1079,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s",
|
"smtp send accepted host=%s mode=ssl", host,
|
||||||
recipient_email,
|
|
||||||
host,
|
|
||||||
receipt.get("provider_message_id"),
|
|
||||||
receipt.get("provider_internal_id"),
|
|
||||||
)
|
)
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
@@ -1100,7 +1092,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
logger.debug("smtp starttls negotiated host=%s port=%s", host, port)
|
logger.debug("smtp starttls negotiated host=%s port=%s", host, port)
|
||||||
if username and password:
|
if username and password:
|
||||||
smtp.login(username, password)
|
smtp.login(username, password)
|
||||||
logger.debug("smtp login succeeded host=%s username=%s", host, username)
|
logger.debug("smtp login succeeded host=%s", host)
|
||||||
receipt = _send_via_smtp_session(
|
receipt = _send_via_smtp_session(
|
||||||
smtp,
|
smtp,
|
||||||
from_address=from_address,
|
from_address=from_address,
|
||||||
@@ -1108,11 +1100,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s",
|
"smtp send accepted host=%s mode=plain", host,
|
||||||
recipient_email,
|
|
||||||
host,
|
|
||||||
receipt.get("provider_message_id"),
|
|
||||||
receipt.get("provider_internal_id"),
|
|
||||||
)
|
)
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
@@ -1153,7 +1141,7 @@ async def send_templated_email(
|
|||||||
body_text=rendered["body_text"],
|
body_text=rendered["body_text"],
|
||||||
body_html=rendered["body_html"],
|
body_html=rendered["body_html"],
|
||||||
)
|
)
|
||||||
logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email)
|
logger.info("Email template sent: template=%s", template_key)
|
||||||
return {
|
return {
|
||||||
"recipient_email": resolved_email,
|
"recipient_email": resolved_email,
|
||||||
"subject": rendered["subject"],
|
"subject": rendered["subject"],
|
||||||
@@ -1185,7 +1173,7 @@ async def send_generic_email(
|
|||||||
body_text=body_text.strip(),
|
body_text=body_text.strip(),
|
||||||
body_html=body_html.strip(),
|
body_html=body_html.strip(),
|
||||||
)
|
)
|
||||||
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
|
logger.info("Generic email sent")
|
||||||
return {
|
return {
|
||||||
"recipient_email": resolved_email,
|
"recipient_email": resolved_email,
|
||||||
"subject": subject.strip() or f"{env_settings.app_name} notification",
|
"subject": subject.strip() or f"{env_settings.app_name} notification",
|
||||||
@@ -1284,7 +1272,7 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
|
|||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
)
|
)
|
||||||
logger.info("SMTP test email sent: recipient=%s", resolved_email)
|
logger.info("SMTP test email sent")
|
||||||
result = {"recipient_email": resolved_email, "subject": subject}
|
result = {"recipient_email": resolved_email, "subject": subject}
|
||||||
result.update(
|
result.update(
|
||||||
{
|
{
|
||||||
@@ -1383,9 +1371,8 @@ async def send_password_reset_email(
|
|||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Password reset email sent: username=%s recipient=%s provider=%s",
|
"Password reset email sent: username=%s provider=%s",
|
||||||
username,
|
username,
|
||||||
resolved_email,
|
|
||||||
auth_provider,
|
auth_provider,
|
||||||
)
|
)
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
@@ -36,3 +36,16 @@ def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> Non
|
|||||||
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
|
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
|
||||||
(source_key(base_url), user["id"], str(jellyfin_user_id)),
|
(source_key(base_url), user["id"], str(jellyfin_user_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_for_identity(jellyfin_user_id: str, base_url: str | None):
|
||||||
|
"""Resolve a verified upstream login to its existing local account."""
|
||||||
|
if not jellyfin_user_id or not base_url:
|
||||||
|
return None
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
|
||||||
|
(source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
|
||||||
|
if len(rows) > 1:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
|
||||||
|
return db.get_user_by_id(rows[0][0]) if rows else None
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
from contextlib import closing
|
||||||
|
from .. import db
|
||||||
|
from .jellyfin_identity import source_key
|
||||||
|
from .identity_review import normalized_id, name_key
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -6,18 +11,14 @@ from ..clients.jellyfin import JellyfinClient
|
|||||||
from ..db import (
|
from ..db import (
|
||||||
create_user_if_missing,
|
create_user_if_missing,
|
||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
set_user_email,
|
|
||||||
set_user_auth_provider,
|
set_user_auth_provider,
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from .jellyfin_identity import link_user
|
from .jellyfin_identity import link_user
|
||||||
from .user_cache import (
|
from .user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
|
||||||
extract_jellyseerr_user_email,
|
extract_jellyseerr_user_email,
|
||||||
find_matching_jellyseerr_user,
|
|
||||||
get_cached_jellyseerr_users,
|
get_cached_jellyseerr_users,
|
||||||
match_jellyseerr_user_id,
|
|
||||||
save_jellyfin_users_cache,
|
save_jellyfin_users_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,43 +37,52 @@ async def sync_jellyfin_users() -> int:
|
|||||||
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
# Jellyfin is the canonical source for local user objects; Seerr IDs are
|
||||||
# matched as enrichment when possible.
|
# matched as enrichment when possible.
|
||||||
jellyseerr_users = get_cached_jellyseerr_users()
|
jellyseerr_users = get_cached_jellyseerr_users()
|
||||||
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
|
|
||||||
imported = 0
|
imported = 0
|
||||||
|
name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
|
||||||
|
with closing(db._connect()) as conn:
|
||||||
|
links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
|
||||||
|
'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
|
||||||
for user in users:
|
for user in users:
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
continue
|
continue
|
||||||
name = user.get("Name")
|
name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
|
||||||
if not name:
|
if not name or not jf_id or name_counts[name_key(name)] != 1:
|
||||||
continue
|
continue
|
||||||
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
|
matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
|
||||||
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
|
if len(matches) > 1:
|
||||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
continue
|
||||||
created = create_user_if_missing(
|
matched = matches[0] if matches else None
|
||||||
name,
|
matched_id = matched.get('id') if matched else None
|
||||||
"jellyfin-user",
|
owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
|
||||||
role="user",
|
if len(owners) > 1:
|
||||||
email=matched_email,
|
continue
|
||||||
auth_provider="jellyfin",
|
existing = db.get_user_by_id(owners[0]) if owners else None
|
||||||
jellyseerr_user_id=matched_id,
|
if not existing and matched_id is not None:
|
||||||
)
|
candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
|
||||||
if created:
|
if len(candidates) > 1:
|
||||||
imported += 1
|
continue
|
||||||
else:
|
existing = candidates[0] if candidates else None
|
||||||
|
if not existing:
|
||||||
existing = get_user_by_username(name)
|
existing = get_user_by_username(name)
|
||||||
if (
|
if existing:
|
||||||
existing
|
existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
|
||||||
and str(existing.get("role") or "user").strip().lower() != "admin"
|
if existing_links and any(value != jf_id for value in existing_links):
|
||||||
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
|
continue
|
||||||
):
|
if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
|
||||||
set_user_auth_provider(name, "jellyfin")
|
continue
|
||||||
|
canonical = existing['username']
|
||||||
|
# Never overwrite a stored Seerr identity on name evidence.
|
||||||
|
if existing.get('jellyseerr_user_id') not in (None, matched_id):
|
||||||
|
continue
|
||||||
|
set_user_auth_provider(canonical, 'jellyfin')
|
||||||
|
else:
|
||||||
|
canonical = name
|
||||||
|
if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
|
||||||
|
jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
|
||||||
|
imported += 1
|
||||||
if matched_id is not None:
|
if matched_id is not None:
|
||||||
set_user_jellyseerr_id(name, matched_id)
|
set_user_jellyseerr_id(canonical, matched_id)
|
||||||
if matched_email:
|
link_user(canonical, jf_id, runtime.jellyfin_base_url)
|
||||||
set_user_email(name, matched_email)
|
|
||||||
if user.get("Id"):
|
|
||||||
local_user = get_user_by_username(name)
|
|
||||||
if local_user and local_user.get("auth_provider") == "jellyfin":
|
|
||||||
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
|
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Manual collector decisions and short-lived, request-bound selection receipts."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def can_override(user):
|
||||||
|
return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
|
||||||
|
|
||||||
|
|
||||||
|
def decision(item):
|
||||||
|
reasons = [str(r) for r in (item.get('rejections') or [])]
|
||||||
|
accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
|
||||||
|
and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
|
||||||
|
# Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
|
||||||
|
profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
|
||||||
|
'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
|
||||||
|
'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
|
||||||
|
'larger than', 'smaller than', 'size limit', 'release profile',
|
||||||
|
)) for reason in reasons)
|
||||||
|
override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
|
||||||
|
return accepted, override, reasons
|
||||||
|
|
||||||
|
|
||||||
|
def source_id(url):
|
||||||
|
return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def issue_selection(release, request_id, user, source, item_id):
|
||||||
|
return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
|
||||||
|
'source': source_id(source), 'item': item_id, 'guid': release['guid'],
|
||||||
|
'indexer': release['indexerId'], 'title': release.get('title'),
|
||||||
|
'override': release['requiresOverride'], 'rejections': release['rejections'],
|
||||||
|
'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
|
||||||
|
settings.jwt_secret, algorithm='HS256')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_selection(payload, request_id, user, source, item_id):
|
||||||
|
try:
|
||||||
|
receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
|
||||||
|
algorithms=['HS256'], audience='manual-release')
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
|
||||||
|
if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
|
||||||
|
or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
|
||||||
|
or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
|
||||||
|
raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
|
||||||
|
if receipt.get('override'):
|
||||||
|
if not can_override(user):
|
||||||
|
raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
|
||||||
|
if payload.get('ignoreProfileLimits') is not True:
|
||||||
|
raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
|
||||||
|
return receipt
|
||||||
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from .. import db
|
from .. import db
|
||||||
from . import email_queue
|
from . import email_queue
|
||||||
from .recap_store import read_one, transaction
|
from .recap_store import read_one, transaction
|
||||||
|
from .public_urls import magent_public_url
|
||||||
|
|
||||||
|
|
||||||
class Conflict(ValueError):
|
class Conflict(ValueError):
|
||||||
@@ -63,6 +64,7 @@ def init_schema(conn):
|
|||||||
|
|
||||||
def settings() -> dict:
|
def settings() -> dict:
|
||||||
result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
|
result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
|
||||||
|
result['public_url'] = magent_public_url(result['public_url'])
|
||||||
result['enabled'] = bool(result['enabled'])
|
result['enabled'] = bool(result['enabled'])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ def next_due(now: datetime, weekday: int, hour: int) -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def save_settings(values: dict, now: datetime):
|
def save_settings(values: dict, now: datetime):
|
||||||
|
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
|
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
|
||||||
if old['revision'] != values['revision']:
|
if old['revision'] != values['revision']:
|
||||||
@@ -246,7 +249,8 @@ def enqueue_test(sub, identity, revision, request_id, public_url, now):
|
|||||||
|
|
||||||
def enqueue_due(now):
|
def enqueue_due(now):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
config = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
|
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
|
||||||
|
config['public_url'] = magent_public_url(config['public_url'])
|
||||||
rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
|
rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
|
||||||
for raw in rows:
|
for raw in rows:
|
||||||
row = unpack(raw)
|
row = unpack(raw)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def playback_url(runtime) -> str:
|
|||||||
def delivery_ready(public_url=None):
|
def delivery_ready(public_url=None):
|
||||||
config = store.settings()
|
config = store.settings()
|
||||||
if not (public_url if public_url is not None else config['public_url']):
|
if not (public_url if public_url is not None else config['public_url']):
|
||||||
return False, 'Set the public Magent address for newsletter email links.'
|
return False, 'Set the application URL in Hosting & proxy for newsletter email links.'
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
|
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
|
||||||
return False, 'Connect Jellyfin to collect new arrivals.'
|
return False, 'Connect Jellyfin to collect new arrivals.'
|
||||||
@@ -150,7 +150,7 @@ async def preview(identity, revision):
|
|||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
config = store.settings()
|
config = store.settings()
|
||||||
if not config['public_url'] or not playback_url(runtime):
|
if not config['public_url'] or not playback_url(runtime):
|
||||||
raise NewsletterError('Set the public Magent and Jellyfin addresses before previewing.')
|
raise NewsletterError('Check the application URL in Hosting & proxy and the public playback URL in Jellyfin settings before previewing.')
|
||||||
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
|
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
|
||||||
raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
|
raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
|
||||||
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
|
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ..db import (
|
|||||||
mark_password_reset_token_used,
|
mark_password_reset_token_used,
|
||||||
set_user_auth_provider,
|
set_user_auth_provider,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
|
increment_user_auth_version,
|
||||||
sync_jellyfin_password_state,
|
sync_jellyfin_password_state,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
@@ -243,7 +244,7 @@ async def request_password_reset(
|
|||||||
delete_expired_password_reset_tokens()
|
delete_expired_password_reset_tokens()
|
||||||
target = await _resolve_reset_target(identifier)
|
target = await _resolve_reset_target(identifier)
|
||||||
if not target:
|
if not target:
|
||||||
logger.info("password reset requested with no eligible match identifier=%s", identifier.strip().lower()[:256])
|
logger.info("password reset requested with no eligible match")
|
||||||
return {"status": "ok", "issued": False}
|
return {"status": "ok", "issued": False}
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -324,6 +325,7 @@ async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
|
|||||||
raise ValueError("Password reset link is invalid or has expired.")
|
raise ValueError("Password reset link is invalid or has expired.")
|
||||||
await client.set_user_password(user_id, new_password)
|
await client.set_user_password(user_id, new_password)
|
||||||
sync_jellyfin_password_state(username, new_password)
|
sync_jellyfin_password_state(username, new_password)
|
||||||
|
increment_user_auth_version(username)
|
||||||
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
|
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
|
||||||
set_user_auth_provider(username, "jellyfin")
|
set_user_auth_provider(username, "jellyfin")
|
||||||
mark_password_reset_token_used(token)
|
mark_password_reset_token_used(token)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Configured public email links, independent of request Host/forwarded headers."""
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
|
|
||||||
|
def valid_public_url(value):
|
||||||
|
value = str(value or '').strip().rstrip('/')
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if (parsed.scheme in {'http', 'https'} and parsed.hostname
|
||||||
|
and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
|
||||||
|
and (parsed.port is None or parsed.port > 0)
|
||||||
|
and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)):
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
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 getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
|
||||||
|
return valid_public_url(proxy)
|
||||||
|
if str(application or '').strip():
|
||||||
|
return valid_public_url(application)
|
||||||
|
# Preserve pre-existing installations until Hosting & proxy has been configured.
|
||||||
|
return valid_public_url(legacy_url)
|
||||||
@@ -88,10 +88,26 @@ def render_recap(report: dict, username: str, public_url: str, unsubscribe_url:
|
|||||||
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
|
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
|
||||||
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
|
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
|
||||||
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
|
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
|
||||||
|
patterns = report.get("patterns", {})
|
||||||
|
if patterns:
|
||||||
|
detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%."
|
||||||
|
lines.append(detail)
|
||||||
|
content += f'<p style="padding:18px;background:#242334;border-radius:12px;color:#d8cfff;line-height:1.8">{esc(detail)}</p>'
|
||||||
|
for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])):
|
||||||
|
peak = max(1, *(row["minutes"] for row in rows))
|
||||||
|
content += f'<h2 style="font-size:18px;color:#e5e1e4">{heading}</h2><table role="presentation" width="100%" cellspacing="0" cellpadding="0">'
|
||||||
|
for row in rows:
|
||||||
|
width = round(row["minutes"] / peak * 100)
|
||||||
|
content += f'<tr><td style="padding:8px 0;color:#bdb6c3;font-size:12px;width:100px">{esc(row["name"])}</td><td style="padding:8px"><table role="presentation" width="{width}%" cellspacing="0" cellpadding="0"><tr><td height="8" style="background:{"#8cdbdd" if width else "transparent"};border-radius:4px;font-size:0"> </td></tr></table></td><td style="width:65px;color:#e0d8ff;font-size:12px;text-align:right">{number(row["minutes"])} min</td></tr>'
|
||||||
|
lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
|
||||||
|
content += '</table>'
|
||||||
top = report.get("top_titles", [])[:3]
|
top = report.get("top_titles", [])[:3]
|
||||||
if top:
|
if top:
|
||||||
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
|
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
|
||||||
for item in top:
|
for item in top:
|
||||||
|
artwork = item.get("email_artwork", "")
|
||||||
|
if artwork.startswith(("cid:", "data:image/")):
|
||||||
|
content += f'<img src="{esc(artwork, quote=True)}" alt="{esc(item["title"], quote=True)}" width="80" style="display:block;border-radius:10px;margin-top:20px" />'
|
||||||
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
|
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
|
||||||
else:
|
else:
|
||||||
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
|
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
|
||||||
@@ -130,8 +146,8 @@ def send_email(recipient: str, rendered: dict, message_id: str, before_data=lamb
|
|||||||
html_part = message.get_payload()[-1]
|
html_part = message.get_payload()[-1]
|
||||||
for attachment in rendered.get('inline_images', []):
|
for attachment in rendered.get('inline_images', []):
|
||||||
html_part.add_related(
|
html_part.add_related(
|
||||||
attachment['data'], maintype='image', subtype='jpeg', cid=f"<{attachment['cid']}>",
|
attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
|
||||||
filename=attachment['cid'].split('@')[0] + '.jpg', disposition='inline')
|
filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
|
||||||
payload = message.as_bytes()
|
payload = message.as_bytes()
|
||||||
smtp, stage = None, "connect"
|
smtp, stage = None, "connect"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import datetime
|
|||||||
from .. import db
|
from .. import db
|
||||||
from .monthly_reports import shift_month
|
from .monthly_reports import shift_month
|
||||||
from . import email_queue
|
from . import email_queue
|
||||||
|
from .public_urls import magent_public_url
|
||||||
|
|
||||||
|
|
||||||
def init_schema(conn: sqlite3.Connection) -> None:
|
def init_schema(conn: sqlite3.Connection) -> None:
|
||||||
@@ -73,6 +74,7 @@ def read_one(sql: str, args=()) -> dict | None:
|
|||||||
|
|
||||||
def settings() -> dict:
|
def settings() -> dict:
|
||||||
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
|
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
|
||||||
|
row["public_url"] = magent_public_url(row["public_url"])
|
||||||
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
|
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ def next_due(now: datetime, day: int, hour: int) -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
def save_settings(values: dict, now: datetime) -> dict:
|
def save_settings(values: dict, now: datetime) -> dict:
|
||||||
|
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
|
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
|
||||||
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
|
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
|
||||||
@@ -174,6 +177,7 @@ def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: f
|
|||||||
def enqueue_due(now: datetime) -> int:
|
def enqueue_due(now: datetime) -> int:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
|
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
|
||||||
|
config["public_url"] = magent_public_url(config["public_url"])
|
||||||
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
|
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
|
||||||
return 0
|
return 0
|
||||||
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
|
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
|
||||||
|
|||||||
@@ -58,3 +58,72 @@ async def original_profile(client, default_id):
|
|||||||
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
|
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
|
||||||
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
|
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
|
||||||
return result["id"]
|
return result["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_original_to_movie(client, tmdb_id):
|
||||||
|
movies = await client.get_movie_by_tmdb_id(tmdb_id)
|
||||||
|
if not isinstance(movies, list):
|
||||||
|
raise HTTPException(502, "Radarr did not return the movie list.")
|
||||||
|
matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise HTTPException(409, "Radarr returned multiple movies for this identity.")
|
||||||
|
movie = matches[0]
|
||||||
|
profile_id = await original_profile(client, movie['qualityProfileId'])
|
||||||
|
if movie['qualityProfileId'] != profile_id:
|
||||||
|
movie['qualityProfileId'] = profile_id
|
||||||
|
await client.update_movie(movie)
|
||||||
|
verified = await client.get_movie(movie['id'])
|
||||||
|
if not verified or verified.get('qualityProfileId') != profile_id:
|
||||||
|
raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
|
||||||
|
return profile_id
|
||||||
|
|
||||||
|
|
||||||
|
async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
|
||||||
|
command_id = command.get('id') if isinstance(command, dict) else None
|
||||||
|
if not isinstance(command_id, int):
|
||||||
|
return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
|
||||||
|
for attempt in range(attempts):
|
||||||
|
state = await client.get(f'/api/v3/command/{command_id}')
|
||||||
|
status = str((state or {}).get('status', '')).lower()
|
||||||
|
queue = await client.get_queue(movie_id)
|
||||||
|
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
|
||||||
|
matching = [item for item in records if item.get('movieId') == movie_id]
|
||||||
|
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
|
||||||
|
return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
|
||||||
|
if matching:
|
||||||
|
return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
|
||||||
|
if status in {'failed', 'aborted', 'cancelled'}:
|
||||||
|
return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
|
||||||
|
if status == 'completed':
|
||||||
|
movie = await client.get_movie(movie_id)
|
||||||
|
if (movie or {}).get('hasFile'):
|
||||||
|
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
|
||||||
|
# Command completion precedes download-client queue refresh. Keep polling.
|
||||||
|
pass
|
||||||
|
if attempt + 1 < attempts:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
|
||||||
|
|
||||||
|
|
||||||
|
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
|
||||||
|
ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
|
||||||
|
if not ids:
|
||||||
|
return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
|
||||||
|
for attempt in range(attempts):
|
||||||
|
states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
|
||||||
|
queue = await client.get_queue(series_id)
|
||||||
|
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
|
||||||
|
matching = [item for item in records if item.get('seriesId') == series_id]
|
||||||
|
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
|
||||||
|
return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
|
||||||
|
if matching:
|
||||||
|
return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
|
||||||
|
statuses = {str((state or {}).get('status', '')).lower() for state in states}
|
||||||
|
if statuses & {'failed', 'aborted', 'cancelled'}:
|
||||||
|
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
|
||||||
|
# Even completed commands can precede Sonarr's download queue refresh.
|
||||||
|
if attempt + 1 < attempts:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""State-changing requests may originate only from explicitly configured sites.
|
||||||
|
|
||||||
|
The public Hosting & proxy URL can be stored in the database, while the CORS
|
||||||
|
environment setting still has its localhost default on an upgraded install.
|
||||||
|
Never infer a trusted origin from request Host or forwarded headers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from .public_urls import magent_public_url, valid_public_url
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(value: str, *, configured_url: bool = False) -> tuple[str, str, int] | None:
|
||||||
|
value = str(value or "")
|
||||||
|
if any(character.isspace() or ord(character) < 33 or ord(character) == 127 for character in value):
|
||||||
|
return None
|
||||||
|
if "?" in value or "#" in value:
|
||||||
|
return None
|
||||||
|
validated = valid_public_url(value)
|
||||||
|
if not validated:
|
||||||
|
return None
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
return None
|
||||||
|
if not configured_url and parsed.path:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
parsed.scheme.lower(),
|
||||||
|
parsed.hostname.lower(),
|
||||||
|
parsed.port or (443 if parsed.scheme == "https" else 80),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_request_origin(origin: str) -> bool:
|
||||||
|
candidate = _origin(origin)
|
||||||
|
if candidate is None:
|
||||||
|
return False
|
||||||
|
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
|
||||||
|
return True
|
||||||
|
return candidate == _origin(magent_public_url(), configured_url=True)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Persistent, operator-authorized first-install setup.
|
||||||
|
|
||||||
|
Initialize the marker before the main schema: an existing users table identifies
|
||||||
|
an upgraded installation, while a new database must finish the setup wizard.
|
||||||
|
The marker and first administrator are protected by SQLite write transactions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hmac
|
||||||
|
from math import ceil
|
||||||
|
from time import time
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .. import db
|
||||||
|
from ..config import settings
|
||||||
|
from ..security import hash_password, validate_password_policy
|
||||||
|
|
||||||
|
|
||||||
|
SetupStep = Literal["administrator", "apps", "preferences", "review"]
|
||||||
|
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
|
||||||
|
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
|
||||||
|
BOOTSTRAP_IP_ATTEMPTS = 5
|
||||||
|
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
|
||||||
|
|
||||||
|
|
||||||
|
class SetupUnavailableError(ValueError):
|
||||||
|
"""Setup has finished, or another administrator already exists."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSetupTokenError(ValueError):
|
||||||
|
"""The operator's setup token was absent or did not match."""
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_setup_state() -> None:
|
||||||
|
"""Run once before init_db; subsequent calls preserve progress."""
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
existing_install = conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
|
||||||
|
).fetchone() is not None
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS installation_setup (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
occurred_at REAL NOT NULL
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
|
||||||
|
VALUES (1, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
int(existing_install),
|
||||||
|
"review" if existing_install else "administrator",
|
||||||
|
datetime.now(timezone.utc).isoformat() if existing_install else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_setup_state() -> dict:
|
||||||
|
with db._connect() as conn:
|
||||||
|
# Old databases and isolated callers without startup initialization are
|
||||||
|
# already installed. A missing marker must never open public bootstrap.
|
||||||
|
table = conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
|
||||||
|
).fetchone()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
|
||||||
|
).fetchone() if table else None
|
||||||
|
if row is None:
|
||||||
|
return {"completed": True, "step": "review", "completed_at": None}
|
||||||
|
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
def is_setup_required() -> bool:
|
||||||
|
return not get_setup_state()["completed"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_setup_status() -> dict:
|
||||||
|
required = is_setup_required()
|
||||||
|
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_token_configured() -> bool:
|
||||||
|
"""Reject missing values and obvious examples, without claiming to measure entropy."""
|
||||||
|
token = str(getattr(settings, "setup_token", "") or "").strip()
|
||||||
|
placeholder = token.casefold().replace("_", "-")
|
||||||
|
return (
|
||||||
|
len(token) >= 32
|
||||||
|
and len(set(token)) > 1
|
||||||
|
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_bootstrap_attempt(client_ip: str) -> int | None:
|
||||||
|
"""Atomically reserve one attempt; return Retry-After when limited.
|
||||||
|
|
||||||
|
The IP is keyed using the existing HMAC helper, never stored in clear text.
|
||||||
|
A shared cap limits distributed attempts and expensive password hashing.
|
||||||
|
"""
|
||||||
|
now = time()
|
||||||
|
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
|
||||||
|
limits = (
|
||||||
|
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
|
||||||
|
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
|
||||||
|
)
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
retry_after = 0
|
||||||
|
for scope, key, maximum in limits:
|
||||||
|
count, oldest = conn.execute(
|
||||||
|
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
|
||||||
|
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
|
||||||
|
(scope, key, cutoff),
|
||||||
|
).fetchone()
|
||||||
|
if count >= maximum:
|
||||||
|
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
|
||||||
|
if retry_after:
|
||||||
|
return retry_after
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
|
||||||
|
[(scope, key, now) for scope, key, _ in limits],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_administrator(setup_token: str, username: str, password: str) -> None:
|
||||||
|
"""Claim fresh setup exactly once using the deployment's setup token."""
|
||||||
|
expected = str(getattr(settings, "setup_token", "") or "")
|
||||||
|
if not setup_token_configured() or not hmac.compare_digest(
|
||||||
|
setup_token.encode("utf-8"), expected.encode("utf-8")
|
||||||
|
):
|
||||||
|
raise InvalidSetupTokenError("Invalid setup token.")
|
||||||
|
username = username.strip()
|
||||||
|
if not username or len(username) > 100 or any(
|
||||||
|
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
|
||||||
|
):
|
||||||
|
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
|
||||||
|
if len(password) > 1024:
|
||||||
|
raise ValueError("Password must contain no more than 1024 characters.")
|
||||||
|
password = validate_password_policy(password)
|
||||||
|
if not is_setup_required() or db.has_admin_user():
|
||||||
|
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
||||||
|
|
||||||
|
password_hash = hash_password(password)
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
|
||||||
|
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
|
||||||
|
if setup is None or setup[0] or admin:
|
||||||
|
raise SetupUnavailableError("Initial administrator setup is no longer available.")
|
||||||
|
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
|
||||||
|
raise SetupUnavailableError("That username already exists.")
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
|
||||||
|
VALUES (?, ?, 'admin', 'local', ?)""",
|
||||||
|
(username, password_hash, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
|
||||||
|
|
||||||
|
|
||||||
|
def update_setup_step(step: SetupStep) -> dict:
|
||||||
|
if step not in SETUP_STEPS:
|
||||||
|
raise ValueError("Invalid setup step.")
|
||||||
|
if not is_setup_required():
|
||||||
|
return get_setup_state()
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
|
||||||
|
)
|
||||||
|
return get_setup_state()
|
||||||
|
|
||||||
|
|
||||||
|
def complete_setup() -> dict:
|
||||||
|
if not is_setup_required():
|
||||||
|
return get_setup_state()
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
|
||||||
|
raise SetupUnavailableError("Create an administrator before completing setup.")
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
|
||||||
|
WHERE id = 1 AND completed = 0""",
|
||||||
|
(datetime.now(timezone.utc).isoformat(),),
|
||||||
|
)
|
||||||
|
return get_setup_state()
|
||||||
@@ -32,6 +32,7 @@ from ..models import ActionOption, NormalizedState, RequestType, Snapshot, Timel
|
|||||||
from .collector_search import read_search_status
|
from .collector_search import read_search_status
|
||||||
from .media_repair import current_cycle_torrents, evaluate_media_repair
|
from .media_repair import current_cycle_torrents, evaluate_media_repair
|
||||||
from .download_labels import label_episode_downloads
|
from .download_labels import label_episode_downloads
|
||||||
|
from .arr import RootFolderNotFoundError, resolve_root_folder_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,12 +138,10 @@ def jellyfin_item_matches_request(
|
|||||||
request_provider_ids = extract_request_provider_ids(request_payload or {})
|
request_provider_ids = extract_request_provider_ids(request_payload or {})
|
||||||
item_provider_ids = extract_request_provider_ids(item)
|
item_provider_ids = extract_request_provider_ids(item)
|
||||||
|
|
||||||
provider_priority = ("tmdb", "tvdb", "imdb")
|
shared = set(request_provider_ids) & set(item_provider_ids)
|
||||||
for key in provider_priority:
|
if shared:
|
||||||
request_id = request_provider_ids.get(key)
|
# Conflicting metadata must never fall through to title matching.
|
||||||
item_id = item_provider_ids.get(key)
|
return all(request_provider_ids[key] == item_provider_ids[key] for key in shared)
|
||||||
if request_id and item_id and request_id == item_id:
|
|
||||||
return True
|
|
||||||
|
|
||||||
request_title = _normalize_media_title(title)
|
request_title = _normalize_media_title(title)
|
||||||
if not request_title:
|
if not request_title:
|
||||||
@@ -169,11 +168,6 @@ def jellyfin_item_matches_request(
|
|||||||
if request_title in item_titles:
|
if request_title in item_titles:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if request_type == RequestType.tv:
|
|
||||||
for candidate in item_titles:
|
|
||||||
if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -376,6 +370,38 @@ def _episode_availability(episodes: Any) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]:
|
||||||
|
"""Describe regular Sonarr seasons that can be added to an existing request."""
|
||||||
|
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
|
||||||
|
return []
|
||||||
|
episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else []
|
||||||
|
options: List[Dict[str, int]] = []
|
||||||
|
for season in series["seasons"]:
|
||||||
|
if not isinstance(season, dict) or season.get("monitored") is not False:
|
||||||
|
continue
|
||||||
|
season_number = season.get("seasonNumber")
|
||||||
|
if not isinstance(season_number, int) or season_number <= 0:
|
||||||
|
continue
|
||||||
|
matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number]
|
||||||
|
statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {}
|
||||||
|
episode_count = statistics.get("totalEpisodeCount")
|
||||||
|
if not isinstance(episode_count, int):
|
||||||
|
episode_count = statistics.get("episodeCount")
|
||||||
|
if not isinstance(episode_count, int):
|
||||||
|
episode_count = len(matching)
|
||||||
|
available = statistics.get("episodeFileCount")
|
||||||
|
if not isinstance(available, int):
|
||||||
|
available = sum(1 for episode in matching if episode.get("hasFile") is True)
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"seasonNumber": season_number,
|
||||||
|
"episodeCount": max(0, episode_count),
|
||||||
|
"available": max(0, available),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(options, key=lambda item: item["seasonNumber"])
|
||||||
|
|
||||||
|
|
||||||
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
if not torrents:
|
if not torrents:
|
||||||
return {"state": "idle", "message": "0 active downloads."}
|
return {"state": "idle", "message": "0 active downloads."}
|
||||||
@@ -946,6 +972,7 @@ def _build_presentation(
|
|||||||
"missing": missing,
|
"missing": missing,
|
||||||
"total": total,
|
"total": total,
|
||||||
"seasons": availability.get("seasons") or [],
|
"seasons": availability.get("seasons") or [],
|
||||||
|
"unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [],
|
||||||
"missingEpisodes": arr_details.get("missingEpisodes") or {},
|
"missingEpisodes": arr_details.get("missingEpisodes") or {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1208,11 +1235,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
arr_item = None
|
arr_item = None
|
||||||
arr_queue = None
|
arr_queue = None
|
||||||
episodes = None
|
episodes = None
|
||||||
media_status = jelly_request.get("media", {}).get("status")
|
|
||||||
try:
|
|
||||||
media_status_code = int(media_status) if media_status is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
media_status_code = None
|
|
||||||
if snapshot.request_type == RequestType.tv:
|
if snapshot.request_type == RequestType.tv:
|
||||||
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
|
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
|
||||||
if tvdb_id:
|
if tvdb_id:
|
||||||
@@ -1247,6 +1269,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
|
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
|
||||||
}
|
}
|
||||||
arr_details["availability"] = _episode_availability(episodes)
|
arr_details["availability"] = _episode_availability(episodes)
|
||||||
|
arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, episodes)
|
||||||
counts = arr_details["availability"]
|
counts = arr_details["availability"]
|
||||||
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
|
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
|
||||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||||
@@ -1363,11 +1386,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
|
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
|
||||||
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
if radarr_client.configured():
|
if radarr_client.configured():
|
||||||
root_folder = await _resolve_root_folder_path(
|
try:
|
||||||
|
root_folder = await resolve_root_folder_path(
|
||||||
radarr_client, runtime.radarr_root_folder, "Radarr"
|
radarr_client, runtime.radarr_root_folder, "Radarr"
|
||||||
)
|
)
|
||||||
|
except RootFolderNotFoundError as exc:
|
||||||
|
logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc)
|
||||||
|
root_folder = ""
|
||||||
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
||||||
if tmdb_id:
|
if tmdb_id and root_folder:
|
||||||
try:
|
try:
|
||||||
await radarr_client.add_movie(
|
await radarr_client.add_movie(
|
||||||
int(tmdb_id),
|
int(tmdb_id),
|
||||||
@@ -1382,11 +1409,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
|
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
|
||||||
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||||
if sonarr_client.configured():
|
if sonarr_client.configured():
|
||||||
root_folder = await _resolve_root_folder_path(
|
try:
|
||||||
|
root_folder = await resolve_root_folder_path(
|
||||||
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
|
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
|
||||||
)
|
)
|
||||||
|
except RootFolderNotFoundError as exc:
|
||||||
|
logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc)
|
||||||
|
root_folder = ""
|
||||||
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
|
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
|
||||||
if tvdb_id:
|
if tvdb_id and root_folder:
|
||||||
try:
|
try:
|
||||||
await sonarr_client.add_series(
|
await sonarr_client.add_series(
|
||||||
int(tvdb_id),
|
int(tvdb_id),
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
coverage==7.16.1
|
||||||
|
pip-audit==2.10.1
|
||||||
|
ruff==0.16.8
|
||||||
@@ -5,6 +5,8 @@ pydantic==2.12.5
|
|||||||
pydantic-settings==2.14.2
|
pydantic-settings==2.14.2
|
||||||
PyJWT==2.13.0
|
PyJWT==2.13.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
|
argon2-cffi==25.1.0
|
||||||
|
cryptography==50.0.1
|
||||||
python-multipart==0.0.31
|
python-multipart==0.0.31
|
||||||
Pillow==12.3.0
|
Pillow==12.3.0
|
||||||
prometheus-client==0.22.1
|
prometheus-client==0.22.1
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from backend.app.api_models import PasswordResetRequest, SignupRequest
|
||||||
|
|
||||||
|
|
||||||
|
class ApiRequestModelTests(unittest.TestCase):
|
||||||
|
def test_signup_rejects_unknown_fields(self) -> None:
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
SignupRequest(
|
||||||
|
invite_code="invite",
|
||||||
|
username="viewer",
|
||||||
|
password="strong password",
|
||||||
|
unexpected="value",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
|
||||||
|
request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
|
||||||
|
self.assertEqual(request.new_password, " leading and trailing ")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
|
||||||
|
|
||||||
|
|
||||||
|
class _ArrClient:
|
||||||
|
async def get_root_folders(self):
|
||||||
|
return [{"id": 7, "path": "/media/tv"}]
|
||||||
|
|
||||||
|
|
||||||
|
class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_resolves_numeric_root_folder_id(self) -> None:
|
||||||
|
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
|
||||||
|
|
||||||
|
async def test_preserves_configured_path(self) -> None:
|
||||||
|
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
|
||||||
|
|
||||||
|
async def test_rejects_missing_root_folder_id(self) -> None:
|
||||||
|
with self.assertRaises(RootFolderNotFoundError):
|
||||||
|
await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,27 +2,29 @@ import os
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, call, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from passlib.context import CryptContext
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from backend.app import db
|
from backend.app import db
|
||||||
from backend.app.clients.base import _operation_error_message, _operation_result_message
|
from backend.app.clients.base import _operation_error_message, _operation_result_message
|
||||||
from backend.app.clients.jellyfin import _availability_message
|
from backend.app.clients.jellyfin import _availability_message
|
||||||
from backend.app.clients.qbittorrent import _torrent_result_message
|
from backend.app.clients.qbittorrent import _torrent_result_message
|
||||||
from backend.app.auth import require_admin
|
from backend.app.auth import _load_current_user_from_token, require_admin
|
||||||
from backend.app.config import settings
|
from backend.app.config import settings
|
||||||
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
||||||
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||||
from backend.app.routers import auth as auth_router
|
from backend.app.routers import auth as auth_router
|
||||||
from backend.app.routers import admin as admin_router
|
from backend.app.routers import admin as admin_router
|
||||||
|
from backend.app.routers import branding as branding_router
|
||||||
from backend.app.routers import portal as portal_router
|
from backend.app.routers import portal as portal_router
|
||||||
from backend.app.routers import requests as requests_router
|
from backend.app.routers import requests as requests_router
|
||||||
from backend.app.routers import site as site_router
|
from backend.app.routers import site as site_router
|
||||||
from backend.app.routers import status as status_router
|
from backend.app.routers import status as status_router
|
||||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
from backend.app.security import PASSWORD_POLICY_MESSAGE, create_access_token, validate_password_policy
|
||||||
from backend.app.services import password_reset
|
from backend.app.services import password_reset
|
||||||
from backend.app.services import issue_resolution
|
from backend.app.services import issue_resolution
|
||||||
from backend.app.services.operation_progress import (
|
from backend.app.services.operation_progress import (
|
||||||
@@ -39,6 +41,7 @@ from backend.app.services.snapshot import (
|
|||||||
_build_repair_activity,
|
_build_repair_activity,
|
||||||
_episode_availability,
|
_episode_availability,
|
||||||
_torrent_progress,
|
_torrent_progress,
|
||||||
|
_unmonitored_season_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,21 +73,16 @@ class TempDatabaseMixin:
|
|||||||
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
self._original_sqlite_path = settings.sqlite_path
|
self._original_sqlite_path = settings.sqlite_path
|
||||||
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
|
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
|
||||||
|
self._original_settings_encryption_key = settings.settings_encryption_key
|
||||||
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
|
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
|
||||||
settings.sqlite_journal_mode = "DELETE"
|
settings.sqlite_journal_mode = "DELETE"
|
||||||
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
|
settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
|
||||||
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
|
|
||||||
auth_router._RESET_ATTEMPTS_BY_IP.clear()
|
|
||||||
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
|
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
settings.sqlite_path = self._original_sqlite_path
|
settings.sqlite_path = self._original_sqlite_path
|
||||||
settings.sqlite_journal_mode = self._original_journal_mode
|
settings.sqlite_journal_mode = self._original_journal_mode
|
||||||
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
|
settings.settings_encryption_key = self._original_settings_encryption_key
|
||||||
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
|
|
||||||
auth_router._RESET_ATTEMPTS_BY_IP.clear()
|
|
||||||
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
|
|
||||||
self._tempdir.cleanup()
|
self._tempdir.cleanup()
|
||||||
super_method = getattr(super(), "tearDown", None)
|
super_method = getattr(super(), "tearDown", None)
|
||||||
if callable(super_method):
|
if callable(super_method):
|
||||||
@@ -97,7 +95,204 @@ class PasswordPolicyTests(unittest.TestCase):
|
|||||||
validate_password_policy("short")
|
validate_password_policy("short")
|
||||||
|
|
||||||
def test_validate_password_policy_trims_whitespace(self) -> None:
|
def test_validate_password_policy_trims_whitespace(self) -> None:
|
||||||
self.assertEqual(validate_password_policy(" password123 "), "password123")
|
self.assertEqual(validate_password_policy(" password1234 "), "password1234")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self._jwt_secret = patch.object(
|
||||||
|
settings, "jwt_secret", "security-hardening-tests-secret-123456789"
|
||||||
|
)
|
||||||
|
self._jwt_secret.start()
|
||||||
|
self.addCleanup(self._jwt_secret.stop)
|
||||||
|
|
||||||
|
def test_sensitive_settings_are_encrypted_at_rest(self) -> None:
|
||||||
|
db.set_setting("jellyfin_api_key", "private-api-key")
|
||||||
|
|
||||||
|
with db._connect() as conn:
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
self.assertTrue(stored.startswith("enc:v1:"))
|
||||||
|
self.assertNotIn("private-api-key", stored)
|
||||||
|
self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key")
|
||||||
|
|
||||||
|
def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None:
|
||||||
|
created = db.create_signup_invite(code="TopSecretInvite42")
|
||||||
|
invite_id = int(created["id"])
|
||||||
|
|
||||||
|
with db._connect() as conn:
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT code FROM signup_invites WHERE id = ?", (invite_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
self.assertTrue(stored.startswith("sha256:"))
|
||||||
|
self.assertNotIn("TOPSECRETINVITE42", stored.upper())
|
||||||
|
self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"])
|
||||||
|
self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42"))
|
||||||
|
|
||||||
|
rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99")
|
||||||
|
self.assertTrue(rotated["code_available"])
|
||||||
|
self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42"))
|
||||||
|
self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99"))
|
||||||
|
|
||||||
|
def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None:
|
||||||
|
created = db.create_signup_invite(code="TemporaryInvite77")
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?",
|
||||||
|
("Legacy-Code-77", int(created["id"])),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
|
||||||
|
("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
migrated = db.get_signup_invite_by_code("Legacy-Code-77")
|
||||||
|
self.assertEqual(migrated["id"], created["id"])
|
||||||
|
self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key")
|
||||||
|
with db._connect() as conn:
|
||||||
|
invite_code = conn.execute(
|
||||||
|
"SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),)
|
||||||
|
).fetchone()[0]
|
||||||
|
stored_setting = conn.execute(
|
||||||
|
"SELECT value FROM settings WHERE key = 'radarr_api_key'"
|
||||||
|
).fetchone()[0]
|
||||||
|
self.assertTrue(invite_code.startswith("sha256:"))
|
||||||
|
self.assertTrue(stored_setting.startswith("enc:v1:"))
|
||||||
|
|
||||||
|
def test_legacy_password_hash_is_replaced_with_argon2(self) -> None:
|
||||||
|
password = "Example-password123!"
|
||||||
|
db.create_user("legacy", password)
|
||||||
|
legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password)
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ? WHERE username = ?",
|
||||||
|
(legacy_hash, "legacy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(db.verify_user_password("legacy", password))
|
||||||
|
self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2"))
|
||||||
|
|
||||||
|
def test_auth_version_revokes_existing_token(self) -> None:
|
||||||
|
db.create_user("viewer", "Example-password123!")
|
||||||
|
user = db.get_user_by_username("viewer")
|
||||||
|
token = create_access_token(
|
||||||
|
"viewer", "user", auth_version=int(user["auth_version"])
|
||||||
|
)
|
||||||
|
self.assertEqual(_load_current_user_from_token(token)["username"], "viewer")
|
||||||
|
|
||||||
|
db.increment_user_auth_version("viewer")
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
_load_current_user_from_token(token)
|
||||||
|
self.assertEqual(context.exception.status_code, 401)
|
||||||
|
|
||||||
|
async def test_request_mutations_require_owner_or_admin(self) -> None:
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret"
|
||||||
|
)
|
||||||
|
client = SimpleNamespace(
|
||||||
|
configured=lambda: True,
|
||||||
|
get_request=AsyncMock(
|
||||||
|
return_value={"id": 42, "requestedBy": {"username": "owner"}}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(requests_router, "JellyseerrClient", return_value=client):
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
await requests_router._ensure_request_mutation_access(
|
||||||
|
runtime, 42, {"username": "someone-else", "role": "user"}
|
||||||
|
)
|
||||||
|
self.assertEqual(context.exception.status_code, 403)
|
||||||
|
owned = await requests_router._ensure_request_mutation_access(
|
||||||
|
runtime, 42, {"username": "owner", "role": "user"}
|
||||||
|
)
|
||||||
|
self.assertEqual(owned["id"], 42)
|
||||||
|
|
||||||
|
self.assertIsNone(
|
||||||
|
await requests_router._ensure_request_mutation_access(
|
||||||
|
SimpleNamespace(), 42, {"username": "admin", "role": "admin"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None:
|
||||||
|
db.create_user(
|
||||||
|
"viewer", "Example-password123!", email="viewer@example.test"
|
||||||
|
)
|
||||||
|
user = db.get_user_by_username("viewer")
|
||||||
|
now = "2026-09-17T00:00:00+00:00"
|
||||||
|
db.upsert_request_cache(
|
||||||
|
42,
|
||||||
|
99,
|
||||||
|
"movie",
|
||||||
|
2,
|
||||||
|
"Example",
|
||||||
|
2026,
|
||||||
|
"viewer",
|
||||||
|
"viewer",
|
||||||
|
int(user["id"]),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
|
||||||
|
)
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
"42",
|
||||||
|
"available",
|
||||||
|
now,
|
||||||
|
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.save_action("42", "created", "Created", "ok", "Created by viewer")
|
||||||
|
item = db.create_portal_item(
|
||||||
|
kind="issue",
|
||||||
|
title="Example",
|
||||||
|
description="Example",
|
||||||
|
created_by_username="viewer",
|
||||||
|
created_by_id=int(user["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db.delete_user_data_by_username("viewer")
|
||||||
|
|
||||||
|
self.assertTrue(result["deleted"])
|
||||||
|
self.assertIsNone(db.get_user_by_username("viewer"))
|
||||||
|
with db._connect() as conn:
|
||||||
|
request_row = conn.execute(
|
||||||
|
"SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42"
|
||||||
|
).fetchone()
|
||||||
|
snapshot_json = conn.execute(
|
||||||
|
"SELECT payload_json FROM snapshots WHERE request_id = '42'"
|
||||||
|
).fetchone()[0]
|
||||||
|
action_message = conn.execute(
|
||||||
|
"SELECT message FROM actions WHERE request_id = '42'"
|
||||||
|
).fetchone()[0]
|
||||||
|
portal_owner = conn.execute(
|
||||||
|
"SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?",
|
||||||
|
(item["id"],),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(request_row[0], "Deleted user")
|
||||||
|
self.assertIsNone(request_row[1])
|
||||||
|
self.assertNotIn("viewer", request_row[2].lower())
|
||||||
|
self.assertNotIn("viewer", snapshot_json.lower())
|
||||||
|
self.assertNotIn("viewer", action_message.lower())
|
||||||
|
self.assertTrue(portal_owner[0].startswith("deleted-user-"))
|
||||||
|
self.assertIsNone(portal_owner[1])
|
||||||
|
|
||||||
|
async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None:
|
||||||
|
upload = SimpleNamespace(
|
||||||
|
filename="logo.png",
|
||||||
|
content_type="image/png",
|
||||||
|
read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)),
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
await branding_router.save_branding_image(upload)
|
||||||
|
self.assertEqual(context.exception.status_code, 413)
|
||||||
|
upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1)
|
||||||
|
|
||||||
|
|
||||||
class NetworkSecurityTests(unittest.TestCase):
|
class NetworkSecurityTests(unittest.TestCase):
|
||||||
@@ -298,6 +493,9 @@ class SiteInfoTests(unittest.TestCase):
|
|||||||
site_banner_enabled=False,
|
site_banner_enabled=False,
|
||||||
site_banner_message="",
|
site_banner_message="",
|
||||||
site_banner_tone="info",
|
site_banner_tone="info",
|
||||||
|
site_banner_background_color=None,
|
||||||
|
site_banner_border_color=None,
|
||||||
|
site_login_message="",
|
||||||
site_login_show_jellyfin_login=True,
|
site_login_show_jellyfin_login=True,
|
||||||
site_login_show_local_login=True,
|
site_login_show_local_login=True,
|
||||||
site_login_show_forgot_password=True,
|
site_login_show_forgot_password=True,
|
||||||
@@ -310,6 +508,49 @@ class SiteInfoTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(info["navigation"], {"showRequests": False})
|
self.assertEqual(info["navigation"], {"showRequests": False})
|
||||||
|
|
||||||
|
def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
|
||||||
|
runtime = settings.model_copy(update={
|
||||||
|
"site_banner_enabled": True,
|
||||||
|
"site_banner_message": "Planned maintenance",
|
||||||
|
"site_banner_tone": "warning",
|
||||||
|
"site_banner_background_color": "#123ABC",
|
||||||
|
"site_banner_border_color": "red",
|
||||||
|
"site_login_message": "Use your Grizzlyflix account to sign in.",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
|
||||||
|
info = site_router._build_site_info(False)
|
||||||
|
|
||||||
|
self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
|
||||||
|
self.assertIsNone(info["banner"]["borderColor"])
|
||||||
|
self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
|
||||||
|
|
||||||
|
|
||||||
|
class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_banner_colours_are_normalized_before_saving(self) -> None:
|
||||||
|
with patch.object(admin_router, "set_setting") as save:
|
||||||
|
result = await admin_router.update_settings({
|
||||||
|
"site_banner_background_color": "#A1B2C3",
|
||||||
|
"site_banner_border_color": "#010203",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(result, {"status": "ok", "updated": 2})
|
||||||
|
self.assertEqual(
|
||||||
|
save.call_args_list,
|
||||||
|
[
|
||||||
|
call("site_banner_background_color", "#a1b2c3"),
|
||||||
|
call("site_banner_border_color", "#010203"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_banner_colours_reject_unsafe_css_values(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
await admin_router.update_settings({
|
||||||
|
"site_banner_border_color": "red; background: url(example)",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
class RequestCacheTests(unittest.TestCase):
|
class RequestCacheTests(unittest.TestCase):
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
@@ -521,6 +762,34 @@ class RequestPresentationTests(unittest.TestCase):
|
|||||||
self.assertEqual(availability["missing"], 1)
|
self.assertEqual(availability["missing"], 1)
|
||||||
self.assertEqual(availability["total"], 2)
|
self.assertEqual(availability["total"], 2)
|
||||||
|
|
||||||
|
def test_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None:
|
||||||
|
series = {
|
||||||
|
"seasons": [
|
||||||
|
{"seasonNumber": 0, "monitored": False},
|
||||||
|
{"seasonNumber": 7, "monitored": True},
|
||||||
|
{
|
||||||
|
"seasonNumber": 8,
|
||||||
|
"monitored": False,
|
||||||
|
"statistics": {"episodeCount": 16, "episodeFileCount": 2},
|
||||||
|
},
|
||||||
|
{"seasonNumber": 9, "monitored": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
episodes = [
|
||||||
|
{"seasonNumber": 9, "episodeNumber": 1, "hasFile": True},
|
||||||
|
{"seasonNumber": 9, "episodeNumber": 2, "hasFile": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
options = _unmonitored_season_options(series, episodes)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
options,
|
||||||
|
[
|
||||||
|
{"seasonNumber": 8, "episodeCount": 16, "available": 2},
|
||||||
|
{"seasonNumber": 9, "episodeCount": 2, "available": 1},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_presentation_hides_download_without_download_evidence(self) -> None:
|
def test_presentation_hides_download_without_download_evidence(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
request_id="3909",
|
request_id="3909",
|
||||||
@@ -1128,6 +1397,25 @@ class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from backend.app.config import settings
|
||||||
|
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
|
||||||
|
secret.start()
|
||||||
|
self.addCleanup(secret.stop)
|
||||||
|
access = patch.object(
|
||||||
|
requests_router,
|
||||||
|
"_ensure_request_mutation_access",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
access.start()
|
||||||
|
self.addCleanup(access.stop)
|
||||||
|
|
||||||
|
def selection(self, payload, request_id, source):
|
||||||
|
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
|
||||||
|
{**payload, 'requiresOverride': False, 'rejections': []}, request_id,
|
||||||
|
{'username': 'viewer'}, source, None)
|
||||||
|
return payload
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _runtime() -> SimpleNamespace:
|
def _runtime() -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
@@ -1155,7 +1443,7 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
|
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
search_releases=AsyncMock(
|
search_episode_releases=AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1194,15 +1482,17 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"3909", user={"username": "viewer", "role": "user"}
|
"3909", user={"username": "viewer", "role": "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sonarr.search_releases.assert_any_await(42, 1)
|
sonarr.search_episode_releases.assert_any_await(101)
|
||||||
sonarr.search_releases.assert_any_await(42, 2)
|
sonarr.search_episode_releases.assert_any_await(201)
|
||||||
self.assertEqual(result["collector"], "Sonarr")
|
self.assertEqual(result["collector"], "Sonarr")
|
||||||
self.assertEqual(len(result["releases"]), 1)
|
self.assertEqual(len(result["releases"]), 2)
|
||||||
self.assertTrue(result["releases"][0]["fullSeason"])
|
self.assertTrue(result["releases"][0]["fullSeason"])
|
||||||
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
|
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
|
||||||
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
|
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
|
||||||
self.assertTrue(result["releases"][0]["bestPick"])
|
self.assertTrue(result["releases"][0]["bestPick"])
|
||||||
self.assertTrue(result["qualityFiltered"])
|
self.assertFalse(result["qualityFiltered"])
|
||||||
|
self.assertNotIn("selectionToken", result["releases"][1])
|
||||||
|
self.assertTrue(result["releases"][1]["requiresOverride"])
|
||||||
|
|
||||||
async def test_movie_manual_search_uses_radarr(self) -> None:
|
async def test_movie_manual_search_uses_radarr(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
@@ -1292,14 +1582,14 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
requests_router, "save_action"
|
requests_router, "save_action"
|
||||||
):
|
):
|
||||||
result = await requests_router.action_grab(
|
result = await requests_router.action_grab(
|
||||||
"3909", payload, user={"username": "viewer", "role": "user"}
|
"3909", self.selection(payload, "3909", self._runtime().sonarr_base_url), user={"username": "viewer", "role": "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
|
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
|
||||||
sonarr.push_release.assert_not_awaited()
|
sonarr.push_release.assert_not_awaited()
|
||||||
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
|
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
|
||||||
|
|
||||||
async def test_stale_movie_release_still_routes_through_radarr_push(self) -> None:
|
async def test_stale_movie_release_requires_fresh_search(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
request_id="4000",
|
request_id="4000",
|
||||||
title="Example Movie",
|
title="Example Movie",
|
||||||
@@ -1335,15 +1625,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
|
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
|
||||||
requests_router, "save_action"
|
requests_router, "save_action"
|
||||||
):
|
):
|
||||||
result = await requests_router.action_grab(
|
with self.assertRaises(HTTPException) as error:
|
||||||
"4000", payload, user={"username": "viewer", "role": "user"}
|
await requests_router.action_grab(
|
||||||
)
|
"4000", self.selection(payload, "4000", self._runtime().radarr_base_url), user={"username": "viewer", "role": "user"})
|
||||||
|
self.assertEqual(error.exception.status_code, 409)
|
||||||
|
radarr.push_release.assert_not_awaited()
|
||||||
|
|
||||||
radarr.push_release.assert_awaited_once()
|
|
||||||
pushed = radarr.push_release.await_args.args[0]
|
|
||||||
self.assertEqual(pushed["downloadUrl"], "http://prowlarr.test/download/1")
|
|
||||||
self.assertEqual(pushed["protocol"], "torrent")
|
|
||||||
self.assertEqual(result["response"], {"collector": "Radarr", "queued": True})
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
@@ -1542,6 +1829,16 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
access = patch.object(
|
||||||
|
requests_router,
|
||||||
|
"_ensure_request_mutation_access",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
access.start()
|
||||||
|
self.addCleanup(access.stop)
|
||||||
|
|
||||||
def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
|
def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
|
||||||
issue = {"id": 12, "status": "in_progress"}
|
issue = {"id": 12, "status": "in_progress"}
|
||||||
with (
|
with (
|
||||||
@@ -1835,6 +2132,103 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
|||||||
sonarr.search_episodes.assert_awaited_once_with([36899])
|
sonarr.search_episodes.assert_awaited_once_with([36899])
|
||||||
sonarr.search.assert_not_awaited()
|
sonarr.search.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None:
|
||||||
|
snapshot = Snapshot(
|
||||||
|
request_id="3580",
|
||||||
|
title="Suits",
|
||||||
|
request_type=RequestType.tv,
|
||||||
|
state=NormalizedState.available,
|
||||||
|
raw={"arr": {"item": {"id": 540}}},
|
||||||
|
)
|
||||||
|
refreshed = Snapshot(
|
||||||
|
request_id="3580",
|
||||||
|
title="Suits",
|
||||||
|
request_type=RequestType.tv,
|
||||||
|
state=NormalizedState.importing,
|
||||||
|
presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]},
|
||||||
|
)
|
||||||
|
original_series = {
|
||||||
|
"id": 540,
|
||||||
|
"monitored": True,
|
||||||
|
"qualityProfileId": 7,
|
||||||
|
"seasons": [
|
||||||
|
{"seasonNumber": 7, "monitored": True},
|
||||||
|
{"seasonNumber": 8, "monitored": False},
|
||||||
|
{"seasonNumber": 9, "monitored": False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
updated_series = {
|
||||||
|
**original_series,
|
||||||
|
"seasons": [
|
||||||
|
{"seasonNumber": 7, "monitored": True},
|
||||||
|
{"seasonNumber": 8, "monitored": True},
|
||||||
|
{"seasonNumber": 9, "monitored": True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
episodes = [
|
||||||
|
{
|
||||||
|
"id": 801,
|
||||||
|
"seasonNumber": 8,
|
||||||
|
"episodeNumber": 1,
|
||||||
|
"monitored": False,
|
||||||
|
"hasFile": False,
|
||||||
|
"airDateUtc": "2018-07-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 802,
|
||||||
|
"seasonNumber": 8,
|
||||||
|
"episodeNumber": 2,
|
||||||
|
"monitored": False,
|
||||||
|
"hasFile": True,
|
||||||
|
"episodeFileId": 88,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 901,
|
||||||
|
"seasonNumber": 9,
|
||||||
|
"episodeNumber": 1,
|
||||||
|
"monitored": False,
|
||||||
|
"hasFile": False,
|
||||||
|
"airDateUtc": "2019-07-17T00:00:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
verified_episodes = [{**episode, "monitored": True} for episode in episodes]
|
||||||
|
sonarr = SimpleNamespace(
|
||||||
|
configured=lambda: True,
|
||||||
|
get_series=AsyncMock(side_effect=[original_series, updated_series]),
|
||||||
|
update_series=AsyncMock(return_value=updated_series),
|
||||||
|
get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]),
|
||||||
|
monitor_episodes=AsyncMock(return_value={"monitored": True}),
|
||||||
|
search_episodes=AsyncMock(return_value={"id": 9001}),
|
||||||
|
)
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
jellyseerr_base_url=None,
|
||||||
|
jellyseerr_api_key=None,
|
||||||
|
sonarr_base_url="http://sonarr",
|
||||||
|
sonarr_api_key="secret",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
|
||||||
|
patch.object(
|
||||||
|
requests_router,
|
||||||
|
"build_snapshot",
|
||||||
|
new=AsyncMock(side_effect=[snapshot, refreshed]),
|
||||||
|
),
|
||||||
|
patch.object(requests_router, "SonarrClient", return_value=sonarr),
|
||||||
|
patch.object(requests_router, "save_action"),
|
||||||
|
):
|
||||||
|
result = await requests_router.action_add_seasons(
|
||||||
|
"3580",
|
||||||
|
{"season_numbers": [8, 9]},
|
||||||
|
{"username": "viewer", "role": "user", "auto_search_enabled": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["season_numbers"], [8, 9])
|
||||||
|
self.assertEqual(result["searched_episode_count"], 2)
|
||||||
|
self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"])
|
||||||
|
sonarr.update_series.assert_awaited_once_with(updated_series)
|
||||||
|
sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True)
|
||||||
|
sonarr.search_episodes.assert_awaited_once_with([801, 901])
|
||||||
|
|
||||||
async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
|
async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
request_id="3914",
|
request_id="3914",
|
||||||
@@ -1889,6 +2283,11 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
|||||||
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
|
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
|
||||||
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
|
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
|
||||||
patch.object(requests_router, "BazarrClient", return_value=bazarr),
|
patch.object(requests_router, "BazarrClient", return_value=bazarr),
|
||||||
|
patch.object(
|
||||||
|
requests_router,
|
||||||
|
"_ensure_request_mutation_access",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
patch.object(requests_router, "save_action"),
|
patch.object(requests_router, "save_action"),
|
||||||
patch.object(requests_router, "get_portal_item", return_value={
|
patch.object(requests_router, "get_portal_item", return_value={
|
||||||
"id": 12,
|
"id": 12,
|
||||||
@@ -2001,28 +2400,28 @@ class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTes
|
|||||||
|
|
||||||
async def test_invite_list_reports_automatic_operational_states(self) -> None:
|
async def test_invite_list_reports_automatic_operational_states(self) -> None:
|
||||||
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
|
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
|
||||||
db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
|
disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
|
||||||
used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
|
used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
|
||||||
db.increment_signup_invite_use(int(used["id"]))
|
db.increment_signup_invite_use(int(used["id"]))
|
||||||
db.create_signup_invite(
|
expired = db.create_signup_invite(
|
||||||
code="EXPIRED",
|
code="EXPIRED",
|
||||||
expires_at="2000-01-01T00:00:00+00:00",
|
expires_at="2000-01-01T00:00:00+00:00",
|
||||||
recipient_email="expired@example.com",
|
recipient_email="expired@example.com",
|
||||||
)
|
)
|
||||||
db.create_signup_invite(
|
no_profile = db.create_signup_invite(
|
||||||
code="NO-PROFILE",
|
code="NO-PROFILE",
|
||||||
profile_id=999,
|
profile_id=999,
|
||||||
recipient_email="profile@example.com",
|
recipient_email="profile@example.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = await admin_router.get_invites()
|
payload = await admin_router.get_invites()
|
||||||
states = {invite["code"]: invite["operational_state"] for invite in payload["invites"]}
|
states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]}
|
||||||
|
|
||||||
self.assertEqual(states[ready["code"]], "ready")
|
self.assertEqual(states[ready["id"]], "ready")
|
||||||
self.assertEqual(states["DISABLED"], "disabled")
|
self.assertEqual(states[disabled["id"]], "disabled")
|
||||||
self.assertEqual(states["USED"], "exhausted")
|
self.assertEqual(states[used["id"]], "exhausted")
|
||||||
self.assertEqual(states["EXPIRED"], "expired")
|
self.assertEqual(states[expired["id"]], "expired")
|
||||||
self.assertEqual(states["NO-PROFILE"], "profile_unavailable")
|
self.assertEqual(states[no_profile["id"]], "profile_unavailable")
|
||||||
self.assertEqual(payload["summary"]["total"], 5)
|
self.assertEqual(payload["summary"]["total"], 5)
|
||||||
self.assertEqual(payload["summary"]["ready"], 1)
|
self.assertEqual(payload["summary"]["ready"], 1)
|
||||||
self.assertEqual(payload["summary"]["attention"], 4)
|
self.assertEqual(payload["summary"]["attention"], 4)
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
from contextlib import closing
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.app import db
|
||||||
|
from backend.app.auth import get_current_user
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.routers import backups as backup_router
|
||||||
|
from backend.app.services import backups
|
||||||
|
|
||||||
|
|
||||||
|
PASSPHRASE = "test backup passphrase with spaces"
|
||||||
|
|
||||||
|
|
||||||
|
class BackupTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.database = self.root / "magent.db"
|
||||||
|
for key, value in {
|
||||||
|
"sqlite_path": str(self.database), "sqlite_journal_mode": "DELETE",
|
||||||
|
"settings_encryption_key": Fernet.generate_key().decode(),
|
||||||
|
"jwt_secret": "source-installation-signing-secret-for-backup-tests",
|
||||||
|
"admin_username": "backup-admin", "admin_password": "a secure initial password",
|
||||||
|
"jellyfin_api_key": "environment-integration-secret", "setup_token": "local-setup-token",
|
||||||
|
"discord_webhook_url": "https://discord.example.invalid/api/webhooks/legacy-private-token",
|
||||||
|
}.items():
|
||||||
|
context = patch.object(settings, key, value)
|
||||||
|
context.start()
|
||||||
|
self.addCleanup(context.stop)
|
||||||
|
context = patch.object(backups, "_assets_root", return_value=self.root / "assets")
|
||||||
|
context.start()
|
||||||
|
self.addCleanup(context.stop)
|
||||||
|
db.init_db()
|
||||||
|
db.set_setting("sonarr_api_key", "database-integration-secret")
|
||||||
|
db.set_setting("site_login_message", "Restored configuration")
|
||||||
|
db.set_setting("installation_setup", "complete")
|
||||||
|
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||||
|
conn.execute("INSERT INTO requests_cache(request_id,title,payload_json) VALUES (3580,'Suits','{}')")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO signup_invites(code,enabled,created_at,updated_at) VALUES ('sha256:existing-invite',1,'now','now')"
|
||||||
|
)
|
||||||
|
self.assets = self.root / "assets"
|
||||||
|
(self.assets / "branding").mkdir(parents=True)
|
||||||
|
(self.assets / "branding" / "logo.png").write_bytes(b"branding fixture")
|
||||||
|
(self.assets / "artwork" / "tmdb" / "w342").mkdir(parents=True)
|
||||||
|
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").write_bytes(b"cached fixture")
|
||||||
|
|
||||||
|
def export(self, include_cache=True):
|
||||||
|
content, filename = backups.create_backup(PASSPHRASE, include_cache)
|
||||||
|
self.assertTrue(filename.endswith(".magent-backup"))
|
||||||
|
return content
|
||||||
|
|
||||||
|
def rewrite_archive(self, content, change):
|
||||||
|
decrypted = backups._decrypt(content, PASSPHRASE)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(decrypted)) as archive:
|
||||||
|
files = {entry.filename: archive.read(entry) for entry in archive.infolist()}
|
||||||
|
change(files)
|
||||||
|
output = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(output, "w") as archive:
|
||||||
|
for name, value in files.items():
|
||||||
|
archive.writestr(name, value)
|
||||||
|
return backups._encrypt(output.getvalue(), PASSPHRASE)
|
||||||
|
|
||||||
|
def test_round_trip_reencrypts_secrets_preserves_invites_and_restores_cache_on_restart(self):
|
||||||
|
content = self.export()
|
||||||
|
self.assertNotIn(b"database-integration-secret", content)
|
||||||
|
self.assertNotIn(b"environment-integration-secret", content)
|
||||||
|
original_auth_version = db.get_user_by_username("backup-admin")["auth_version"]
|
||||||
|
db.set_setting("site_login_message", "Live data before restart")
|
||||||
|
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||||
|
settings.jwt_secret = "destination-installation-signing-secret-for-backup-tests"
|
||||||
|
# Simulate a different host with different env-backed integration settings.
|
||||||
|
settings.jellyfin_api_key = "destination-env-value"
|
||||||
|
metadata = backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
self.assertTrue(metadata["include_cache"])
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Live data before restart")
|
||||||
|
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||||
|
staged_bytes = (self.database.parent / "backups" / "pending" / "database.sqlite3").read_bytes()
|
||||||
|
self.assertNotIn(b"database-integration-secret", staged_bytes)
|
||||||
|
self.assertNotIn(b"environment-integration-secret", staged_bytes)
|
||||||
|
self.assertNotIn(b"legacy-private-token", staged_bytes)
|
||||||
|
(self.assets / "branding" / "logo.png").write_bytes(b"changed logo")
|
||||||
|
(self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").unlink()
|
||||||
|
self.assertTrue(backups.apply_pending_restore())
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||||
|
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||||
|
self.assertEqual(db.get_setting("jellyfin_api_key"), "environment-integration-secret")
|
||||||
|
self.assertEqual(db.get_setting("discord_webhook_url"), "https://discord.example.invalid/api/webhooks/legacy-private-token")
|
||||||
|
self.assertEqual(db.get_setting("installation_setup"), "complete")
|
||||||
|
self.assertIsNone(db.get_setting("setup_token"))
|
||||||
|
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"branding fixture")
|
||||||
|
self.assertEqual((self.assets / "artwork" / "tmdb" / "w342" / "poster.jpg").read_bytes(), b"cached fixture")
|
||||||
|
self.assertGreater(db.get_user_by_username("backup-admin")["auth_version"], original_auth_version)
|
||||||
|
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||||
|
self.assertEqual(conn.execute("SELECT title FROM requests_cache WHERE request_id=3580").fetchone(), ("Suits",))
|
||||||
|
self.assertEqual(conn.execute("SELECT code FROM signup_invites").fetchone(), ("sha256:existing-invite",))
|
||||||
|
self.assertTrue(conn.execute("SELECT value FROM settings WHERE key='sonarr_api_key'").fetchone()[0].startswith("enc:v1:"))
|
||||||
|
status = backups.backup_status()
|
||||||
|
self.assertIsNone(status["pending_restore"])
|
||||||
|
self.assertEqual(status["last_restore"]["status"], "restored")
|
||||||
|
self.assertTrue((self.database.parent / "backups" / status["last_restore"]["rollback_directory"] / "database.sqlite3").is_file())
|
||||||
|
self.assertFalse(backups.apply_pending_restore())
|
||||||
|
|
||||||
|
def test_wal_snapshot_contains_committed_uncheckpointed_rows(self):
|
||||||
|
with closing(sqlite3.connect(self.database)) as writer:
|
||||||
|
writer.execute("PRAGMA journal_mode=WAL")
|
||||||
|
writer.execute("PRAGMA wal_autocheckpoint=0")
|
||||||
|
writer.execute("UPDATE requests_cache SET title='Written in WAL' WHERE request_id=3580")
|
||||||
|
writer.commit()
|
||||||
|
self.assertTrue(Path(str(self.database) + "-wal").exists())
|
||||||
|
content = self.export()
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
self.assertTrue(backups.apply_pending_restore())
|
||||||
|
with closing(sqlite3.connect(self.database)) as restored:
|
||||||
|
self.assertEqual(restored.execute("SELECT title FROM requests_cache").fetchone()[0], "Written in WAL")
|
||||||
|
|
||||||
|
def test_process_interruption_is_recovered_on_next_startup(self):
|
||||||
|
class ProcessStopped(BaseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
content = self.export()
|
||||||
|
db.set_setting("site_login_message", "Value before interrupted restart")
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
with patch.object(backups, "_replace_assets", side_effect=ProcessStopped):
|
||||||
|
with self.assertRaises(ProcessStopped):
|
||||||
|
backups.apply_pending_restore()
|
||||||
|
self.assertTrue((self.database.parent / "backups" / "restore-journal.json").exists())
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Restored configuration")
|
||||||
|
self.assertFalse(backups.apply_pending_restore())
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Value before interrupted restart")
|
||||||
|
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||||
|
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||||
|
|
||||||
|
def test_crash_after_rollback_does_not_reapply_pending_restore(self):
|
||||||
|
class ProcessStopped(BaseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
content = self.export()
|
||||||
|
db.set_setting("site_login_message", "Value to retain")
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
replace_assets = backups._replace_assets
|
||||||
|
remove_tree = backups.shutil.rmtree
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fail_first_copy(source, target):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise OSError("failed apply")
|
||||||
|
return replace_assets(source, target)
|
||||||
|
|
||||||
|
def interrupt_cleanup(path, *args, **kwargs):
|
||||||
|
if Path(path).name == "pending":
|
||||||
|
raise ProcessStopped()
|
||||||
|
return remove_tree(path, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(backups, "_replace_assets", side_effect=fail_first_copy), \
|
||||||
|
patch.object(backups.shutil, "rmtree", side_effect=interrupt_cleanup):
|
||||||
|
with self.assertRaises(ProcessStopped):
|
||||||
|
backups.apply_pending_restore()
|
||||||
|
journal = json.loads((self.root / "backups" / "restore-journal.json").read_text())
|
||||||
|
self.assertEqual(journal["phase"], "rolled_back")
|
||||||
|
self.assertFalse(backups.apply_pending_restore())
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Value to retain")
|
||||||
|
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||||
|
|
||||||
|
def test_missing_runtime_column_is_rejected_even_with_current_migration_version(self):
|
||||||
|
directory = self.root / "schema-test"
|
||||||
|
directory.mkdir()
|
||||||
|
backups._extract_archive(backups._decrypt(self.export(), PASSPHRASE), directory)
|
||||||
|
source = directory / "database.sqlite3"
|
||||||
|
with closing(sqlite3.connect(source)) as conn, conn:
|
||||||
|
conn.execute("ALTER TABLE users DROP COLUMN auto_search_enabled")
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "missing database columns"):
|
||||||
|
backups._validate_database(source)
|
||||||
|
|
||||||
|
def test_changed_encryption_key_since_staging_leaves_live_database_untouched(self):
|
||||||
|
content = self.export()
|
||||||
|
db.set_setting("site_login_message", "Current data")
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
settings.settings_encryption_key = Fernet.generate_key().decode()
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "configuration is invalid"):
|
||||||
|
backups.apply_pending_restore()
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Current data")
|
||||||
|
self.assertIsNotNone(backups.backup_status()["pending_restore"])
|
||||||
|
|
||||||
|
def test_excluding_disk_cache_keeps_database_cache_and_branding(self):
|
||||||
|
with zipfile.ZipFile(io.BytesIO(backups._decrypt(self.export(False), PASSPHRASE))) as archive:
|
||||||
|
self.assertIn("database.sqlite3", archive.namelist())
|
||||||
|
self.assertIn("files/branding/logo.png", archive.namelist())
|
||||||
|
self.assertFalse(any("artwork" in name for name in archive.namelist()))
|
||||||
|
|
||||||
|
def test_wrong_password_and_tampering_never_stage_or_touch_live_database(self):
|
||||||
|
content = self.export()
|
||||||
|
for bad_content, password in ((content, "incorrect password value"), (content[:-1] + bytes([content[-1] ^ 1]), PASSPHRASE)):
|
||||||
|
with self.subTest(password=password):
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "Incorrect passphrase or damaged"):
|
||||||
|
backups.stage_restore(io.BytesIO(bad_content), password)
|
||||||
|
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||||
|
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||||
|
|
||||||
|
def test_path_traversal_unknown_files_and_checksum_failures_rejected(self):
|
||||||
|
content = self.export()
|
||||||
|
for name in ("../outside.txt", "/absolute.txt", "files/branding/../../../escape", "files/branding/script.py"):
|
||||||
|
with self.subTest(name=name):
|
||||||
|
malformed = self.rewrite_archive(content, lambda files: files.update({name: b"bad"}))
|
||||||
|
with self.assertRaises(backups.BackupError):
|
||||||
|
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||||
|
malformed = self.rewrite_archive(content, lambda files: files.update({"files/branding/logo.png": b"tampered"}))
|
||||||
|
with self.assertRaises(backups.BackupError):
|
||||||
|
backups.stage_restore(io.BytesIO(malformed), PASSPHRASE)
|
||||||
|
self.assertFalse((self.root / "outside.txt").exists())
|
||||||
|
|
||||||
|
def test_size_limit_and_unsupported_schema_rejected(self):
|
||||||
|
content = self.export()
|
||||||
|
with patch.object(backups, "MAX_UPLOAD_BYTES", 16):
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "upload limit"):
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
with patch.object(backups, "MAX_EXPANDED_BYTES", 16):
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "Expanded backup"):
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
with closing(sqlite3.connect(self.database)) as conn, conn:
|
||||||
|
conn.execute("CREATE TRIGGER unsafe AFTER INSERT ON settings BEGIN DELETE FROM users; END")
|
||||||
|
# Validate the original fixture to avoid executing the malicious trigger in export.
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "unsupported database schema"):
|
||||||
|
backups._validate_database(self.database)
|
||||||
|
|
||||||
|
def test_unsupported_compression_is_rejected_before_expansion(self):
|
||||||
|
content = self.export()
|
||||||
|
rewritten = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(backups._decrypt(content, PASSPHRASE))) as original:
|
||||||
|
with zipfile.ZipFile(rewritten, "w", compression=zipfile.ZIP_BZIP2) as target:
|
||||||
|
for entry in original.infolist():
|
||||||
|
target.writestr(entry.filename, original.read(entry))
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "unsafe archive entry"):
|
||||||
|
backups.stage_restore(io.BytesIO(backups._encrypt(rewritten.getvalue(), PASSPHRASE)), PASSPHRASE)
|
||||||
|
|
||||||
|
def test_cancel_is_idempotent_and_does_not_change_database(self):
|
||||||
|
content = self.export()
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
with self.assertRaisesRegex(backups.BackupError, "already staged"):
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
backups.cancel_restore()
|
||||||
|
backups.cancel_restore()
|
||||||
|
self.assertIsNone(backups.backup_status()["pending_restore"])
|
||||||
|
self.assertEqual(db.get_setting("sonarr_api_key"), "database-integration-secret")
|
||||||
|
|
||||||
|
def test_failure_after_database_replacement_rolls_back_both_database_and_files(self):
|
||||||
|
content = self.export()
|
||||||
|
db.set_setting("site_login_message", "Keep this current value")
|
||||||
|
(self.assets / "branding" / "logo.png").write_bytes(b"current logo")
|
||||||
|
backups.stage_restore(io.BytesIO(content), PASSPHRASE)
|
||||||
|
original = backups._replace_assets
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fail_once(source, target):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise OSError("simulated interrupted copy")
|
||||||
|
return original(source, target)
|
||||||
|
|
||||||
|
with patch.object(backups, "_replace_assets", side_effect=fail_once):
|
||||||
|
with self.assertRaisesRegex(OSError, "interrupted copy"):
|
||||||
|
backups.apply_pending_restore()
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Keep this current value")
|
||||||
|
self.assertEqual((self.assets / "branding" / "logo.png").read_bytes(), b"current logo")
|
||||||
|
self.assertEqual(backups.backup_status()["last_restore"]["status"], "rolled_back")
|
||||||
|
self.assertFalse(backups.apply_pending_restore())
|
||||||
|
|
||||||
|
def test_api_requires_admin_and_restore_confirmation(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(backup_router.router)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
self.assertEqual(client.get("/admin/backups").status_code, 401)
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: {"username": "member", "role": "user"}
|
||||||
|
self.assertEqual(client.get("/admin/backups").status_code, 403)
|
||||||
|
self.assertEqual(client.post("/admin/backups/export", json={"passphrase": PASSPHRASE}).status_code, 403)
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: {"username": "backup-admin", "role": "admin"}
|
||||||
|
status = client.get("/admin/backups")
|
||||||
|
self.assertEqual(status.status_code, 200)
|
||||||
|
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||||
|
self.assertEqual(status.json()["max_expanded_bytes"], backups.MAX_EXPANDED_BYTES)
|
||||||
|
response = client.post("/admin/backups/export", json={"passphrase": PASSPHRASE})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
rejected = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||||
|
data={"passphrase": PASSPHRASE, "confirmation": "wrong"})
|
||||||
|
self.assertEqual(rejected.status_code, 422)
|
||||||
|
restored = client.post("/admin/backups/restore", files={"file": ("test.magent-backup", response.content)},
|
||||||
|
data={"passphrase": PASSPHRASE, "confirmation": "RESTORE"})
|
||||||
|
self.assertEqual(restored.status_code, 202)
|
||||||
|
self.assertTrue(restored.json()["restart_required"])
|
||||||
|
self.assertEqual(client.delete("/admin/backups/restore").status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -99,6 +99,7 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
|||||||
with db._connect() as conn:
|
with db._connect() as conn:
|
||||||
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
||||||
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||||
|
self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
|
||||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||||
|
|
||||||
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
||||||
@@ -166,3 +167,16 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
for path in ('check', 'confirm'):
|
for path in ('check', 'confirm'):
|
||||||
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
|
||||||
|
db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
|
||||||
|
preview = await duplicates.repair_duplicates(self.keep)
|
||||||
|
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||||
|
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||||
|
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||||
|
with db._connect() as conn:
|
||||||
|
self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
|
||||||
|
self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from backend.app.config import settings
|
from backend.app.config import settings
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -16,6 +16,13 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
|
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
|
||||||
secret.start()
|
secret.start()
|
||||||
self.addCleanup(secret.stop)
|
self.addCleanup(secret.stop)
|
||||||
|
access = patch.object(
|
||||||
|
requests,
|
||||||
|
"_ensure_request_mutation_access",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
access.start()
|
||||||
|
self.addCleanup(access.stop)
|
||||||
db.create_user('feature-viewer', 'Example-password123!', role='user')
|
db.create_user('feature-viewer', 'Example-password123!', role='user')
|
||||||
db.create_user('feature-admin', 'Example-password123!', role='admin')
|
db.create_user('feature-admin', 'Example-password123!', role='admin')
|
||||||
self.user = db.get_user_by_username('feature-viewer')
|
self.user = db.get_user_by_username('feature-viewer')
|
||||||
@@ -26,7 +33,7 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
||||||
|
|
||||||
def test_defaults_persist_and_invites_share_existing_setting(self):
|
def test_defaults_persist_and_invites_share_existing_setting(self):
|
||||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
|
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
||||||
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
||||||
db.init_db()
|
db.init_db()
|
||||||
fresh = db.get_user_by_username(self.user['username'])
|
fresh = db.get_user_by_username(self.user['username'])
|
||||||
@@ -118,3 +125,22 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
|||||||
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
||||||
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
||||||
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
||||||
|
|
||||||
|
def test_manual_override_permission_is_checked_again_at_download(self):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from backend.app.models import Snapshot, RequestType
|
||||||
|
from backend.app.services import manual_releases
|
||||||
|
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||||
|
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
||||||
|
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
||||||
|
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
||||||
|
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
||||||
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
||||||
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
||||||
|
collector.grab_release.assert_not_awaited()
|
||||||
|
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
||||||
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
||||||
|
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
||||||
|
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
||||||
|
collector.grab_release.assert_awaited_once()
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from backend.app import db
|
||||||
|
from backend.app.services import jellyfin_sync
|
||||||
|
from backend.app.services.jellyfin_identity import link_user, user_for_identity
|
||||||
|
from backend.app.routers import admin
|
||||||
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||||
|
|
||||||
|
|
||||||
|
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
|
||||||
|
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
|
||||||
|
auto_search_enabled=False, email='kept@example.test')
|
||||||
|
original = db.get_user_by_username('old@example.test')
|
||||||
|
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
|
||||||
|
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
|
||||||
|
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
|
||||||
|
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
|
||||||
|
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
|
||||||
|
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
|
||||||
|
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||||
|
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||||
|
kept = user_for_identity('a' * 32, 'http://jf')
|
||||||
|
self.assertEqual(kept['id'], original['id'])
|
||||||
|
self.assertFalse(kept['auto_search_enabled'])
|
||||||
|
self.assertEqual(kept['email'], 'kept@example.test')
|
||||||
|
self.assertIsNone(db.get_user_by_username('NewName'))
|
||||||
|
self.assertEqual(kept['auth_provider'], 'jellyfin')
|
||||||
|
|
||||||
|
async def test_resync_no_longer_deletes_accounts(self):
|
||||||
|
db.create_user('Keep', 'Password-123456!')
|
||||||
|
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
|
||||||
|
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
|
||||||
|
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
|
||||||
|
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
|
||||||
|
patch.object(admin, 'delete_non_admin_users') as delete:
|
||||||
|
result = await admin.jellyseerr_users_resync()
|
||||||
|
self.assertEqual(result['cleared'], 0)
|
||||||
|
delete.assert_not_called()
|
||||||
|
self.assertIsNotNone(db.get_user_by_username('Keep'))
|
||||||
|
|
||||||
|
def test_jellyfin_lookup_is_scoped_to_server(self):
|
||||||
|
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
|
||||||
|
link_user('Viewer', 'a' * 32, 'http://jf')
|
||||||
|
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
|
||||||
|
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Real application HTTP checks for installation, cookies, and backup controls.
|
||||||
|
|
||||||
|
All persistence and artwork paths are isolated in temporary directories; workers,
|
||||||
|
logging file handlers, and the metrics listener are disabled for these tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.app import db, main
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.services import backups
|
||||||
|
|
||||||
|
|
||||||
|
OPERATOR_TOKEN = "installation-http-operator-token-test-123456789"
|
||||||
|
OWNER_PASSWORD = "installation-http-owner-password-123456789"
|
||||||
|
BACKUP_PASSPHRASE = "installation-http-backup-passphrase-123456789"
|
||||||
|
|
||||||
|
|
||||||
|
class InstallationHttpTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
for key, value in {
|
||||||
|
"sqlite_path": str(self.root / "magent.db"),
|
||||||
|
"sqlite_journal_mode": "DELETE",
|
||||||
|
"jwt_secret": "installation-http-test-jwt-secret-1234567890",
|
||||||
|
"settings_encryption_key": None,
|
||||||
|
"admin_username": "unused-environment-admin",
|
||||||
|
"admin_password": "",
|
||||||
|
"setup_token": OPERATOR_TOKEN,
|
||||||
|
"auth_cookie_secure": True,
|
||||||
|
"auth_cookie_domain": None,
|
||||||
|
"auth_cookie_samesite": "strict",
|
||||||
|
}.items():
|
||||||
|
context = patch.object(settings, key, value)
|
||||||
|
context.start()
|
||||||
|
self.addCleanup(context.stop)
|
||||||
|
for context in (
|
||||||
|
patch.object(main, "configure_logging"),
|
||||||
|
patch.object(main, "start_metrics"),
|
||||||
|
patch.object(main, "_background_tasks", []),
|
||||||
|
patch.object(main, "_background_started", False),
|
||||||
|
patch.object(backups, "_assets_root", return_value=self.root / "assets"),
|
||||||
|
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}),
|
||||||
|
):
|
||||||
|
context.start()
|
||||||
|
self.addCleanup(context.stop)
|
||||||
|
self.origin = str(settings.cors_allow_origin).rstrip("/")
|
||||||
|
self.client = self.enterContext(TestClient(main.app, base_url="https://magent.test"))
|
||||||
|
self.client.headers["Origin"] = self.origin
|
||||||
|
|
||||||
|
def create_owner(self):
|
||||||
|
response = self.client.post("/setup/bootstrap", json={
|
||||||
|
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 201, response.text)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def sign_in(self):
|
||||||
|
response = self.client.post("/auth/login", data={"username": "owner", "password": OWNER_PASSWORD})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||||
|
auth_cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "="))
|
||||||
|
self.assertIn("HttpOnly", auth_cookie)
|
||||||
|
self.assertIn("Secure", auth_cookie)
|
||||||
|
self.assertIn("SameSite=strict", auth_cookie)
|
||||||
|
self.assertNotIn("Authorization", self.client.headers)
|
||||||
|
|
||||||
|
def test_fresh_setup_cookie_settings_completion_and_backup_round_trip(self):
|
||||||
|
status = self.client.get("/setup/status")
|
||||||
|
self.assertEqual(status.json(), {"setup_required": True, "needs_admin": True})
|
||||||
|
self.assertEqual(status.headers["cache-control"], "no-store")
|
||||||
|
self.assertIn("default-src 'none'", status.headers["content-security-policy"])
|
||||||
|
self.assertEqual(self.client.get("/setup/state").status_code, 401)
|
||||||
|
self.assertEqual(self.client.get("/admin/backups").status_code, 401)
|
||||||
|
|
||||||
|
self.create_owner()
|
||||||
|
self.sign_in()
|
||||||
|
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
|
||||||
|
response = self.client.put("/admin/settings", json={
|
||||||
|
"jellyfin_base_url": "http://jellyfin.test:8096",
|
||||||
|
"jellyfin_api_key": "test-integration-key-for-setup",
|
||||||
|
"site_login_message": "Welcome to this installation",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertEqual(response.json()["updated"], 3)
|
||||||
|
values = {row["key"]: row for row in self.client.get("/admin/settings").json()["settings"]}
|
||||||
|
self.assertEqual(values["jellyfin_base_url"]["value"], "http://jellyfin.test:8096")
|
||||||
|
self.assertIsNone(values["jellyfin_api_key"]["value"])
|
||||||
|
self.assertTrue(values["jellyfin_api_key"]["isSet"])
|
||||||
|
response = self.client.put("/setup/state", json={"step": "review"})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
completed = self.client.post("/setup/complete")
|
||||||
|
self.assertEqual(completed.status_code, 200, completed.text)
|
||||||
|
self.assertTrue(completed.json()["completed"])
|
||||||
|
self.assertEqual(self.client.get("/setup/status").json(), {"setup_required": False, "needs_admin": False})
|
||||||
|
self.assertEqual(main._background_tasks, [])
|
||||||
|
|
||||||
|
exported = self.client.post("/admin/backups/export", json={
|
||||||
|
"passphrase": BACKUP_PASSPHRASE, "include_cache": False,
|
||||||
|
})
|
||||||
|
self.assertEqual(exported.status_code, 200, exported.text[:100])
|
||||||
|
self.assertTrue(exported.content.startswith(backups.MAGIC))
|
||||||
|
self.assertEqual(exported.headers["cache-control"], "no-store")
|
||||||
|
self.assertNotIn(b"test-integration-key-for-setup", exported.content)
|
||||||
|
restored = self.client.post("/admin/backups/restore", files={
|
||||||
|
"file": ("restore.magent-backup", io.BytesIO(exported.content), "application/octet-stream"),
|
||||||
|
}, data={"passphrase": BACKUP_PASSPHRASE, "confirmation": "RESTORE"})
|
||||||
|
self.assertEqual(restored.status_code, 202, restored.text)
|
||||||
|
self.assertTrue(restored.json()["restart_required"])
|
||||||
|
self.assertEqual(db.get_setting("site_login_message"), "Welcome to this installation")
|
||||||
|
self.assertIsNotNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||||
|
cancelled = self.client.delete("/admin/backups/restore")
|
||||||
|
self.assertEqual(cancelled.status_code, 200, cancelled.text)
|
||||||
|
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||||
|
|
||||||
|
def test_cross_origin_bootstrap_and_authenticated_changes_are_rejected(self):
|
||||||
|
response = self.client.post("/setup/bootstrap", headers={"Origin": "https://unrelated.invalid"}, json={
|
||||||
|
"setup_token": OPERATOR_TOKEN, "username": "owner", "password": OWNER_PASSWORD,
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
self.create_owner()
|
||||||
|
self.sign_in()
|
||||||
|
response = self.client.put("/setup/state", headers={"Origin": "https://unrelated.invalid"}, json={"step": "review"})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
response = self.client.post("/admin/backups/export", headers={"Origin": "https://unrelated.invalid"}, json={"passphrase": BACKUP_PASSPHRASE})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(self.client.get("/setup/state").json()["step"], "apps")
|
||||||
|
|
||||||
|
def test_setup_validation_errors_do_not_echo_password_or_token(self):
|
||||||
|
secret_password = "private-password-marker-" + "p" * 1024
|
||||||
|
secret_token = "private-token-marker-" + "t" * 1024
|
||||||
|
for payload, secret in (
|
||||||
|
({"setup_token": OPERATOR_TOKEN, "username": "owner", "password": secret_password}, secret_password),
|
||||||
|
({"setup_token": secret_token, "username": "owner", "password": OWNER_PASSWORD}, secret_token),
|
||||||
|
({"setup_token": OPERATOR_TOKEN, "password": OWNER_PASSWORD}, OWNER_PASSWORD),
|
||||||
|
):
|
||||||
|
with self.subTest(secret=secret[:22]):
|
||||||
|
response = self.client.post("/setup/bootstrap", json=payload)
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
self.assertNotIn(secret, response.text)
|
||||||
|
self.assertNotIn(OPERATOR_TOKEN, response.text)
|
||||||
|
for error in response.json()["detail"]:
|
||||||
|
self.assertNotIn("input", error)
|
||||||
|
|
||||||
|
def test_backup_validation_errors_do_not_echo_passphrases(self):
|
||||||
|
self.create_owner()
|
||||||
|
self.sign_in()
|
||||||
|
passphrase = "private-backup-passphrase-marker-" + "p" * 1024
|
||||||
|
response = self.client.post("/admin/backups/export", json={"passphrase": passphrase})
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertNotIn(passphrase, response.text)
|
||||||
|
response = self.client.post("/admin/backups/restore", files={"file": ("archive", b"data")}, data={
|
||||||
|
"passphrase": passphrase, "confirmation": "RESTORE",
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertNotIn(passphrase, response.text)
|
||||||
|
self.assertIsNone(self.client.get("/admin/backups").json()["pending_restore"])
|
||||||
|
|
||||||
|
def test_real_middleware_rejects_oversized_bootstrap_before_creation(self):
|
||||||
|
response = self.client.post("/setup/bootstrap", content=b"x" * (17 * 1024), headers={"Content-Type": "application/json"})
|
||||||
|
self.assertEqual(response.status_code, 413, response.text)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, File, Request, UploadFile
|
||||||
|
|
||||||
|
from backend.app import db, main
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.request_limits import InstallationBodyLimitMiddleware
|
||||||
|
from backend.app.services import setup
|
||||||
|
|
||||||
|
|
||||||
|
class InstallationLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
patches = [
|
||||||
|
patch.object(settings, "sqlite_path", str(Path(temporary.name) / "magent.db")),
|
||||||
|
patch.object(settings, "jwt_secret", "installation-lifecycle-secret-1234567890"),
|
||||||
|
patch.object(settings, "settings_encryption_key", None),
|
||||||
|
patch.object(settings, "admin_password", ""),
|
||||||
|
patch.object(settings, "setup_token", "operator-setup-token-at-least-32-characters"),
|
||||||
|
patch.object(main, "_background_started", False),
|
||||||
|
patch.object(main, "_background_tasks", []),
|
||||||
|
patch.object(main, "start_metrics"),
|
||||||
|
patch.object(main, "configure_logging"),
|
||||||
|
patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "true"}),
|
||||||
|
]
|
||||||
|
for item in patches:
|
||||||
|
item.start()
|
||||||
|
self.addCleanup(item.stop)
|
||||||
|
|
||||||
|
async def test_fresh_start_waits_for_admin_and_completion_then_starts_workers_once(self):
|
||||||
|
with patch.object(main, "_launch_background_task") as launch:
|
||||||
|
await main.startup()
|
||||||
|
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": True})
|
||||||
|
launch.assert_not_called()
|
||||||
|
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
|
||||||
|
await main._start_background_tasks()
|
||||||
|
launch.assert_not_called()
|
||||||
|
setup.complete_setup()
|
||||||
|
await main.app.state.on_setup_complete()
|
||||||
|
await main.app.state.on_setup_complete()
|
||||||
|
self.assertEqual(launch.call_count, 9)
|
||||||
|
|
||||||
|
async def test_upgraded_install_starts_normally_without_setup_token(self):
|
||||||
|
db.init_db()
|
||||||
|
db.create_user("owner", "existing-password-12345", role="admin")
|
||||||
|
settings.setup_token = ""
|
||||||
|
with patch.object(main, "_launch_background_task") as launch:
|
||||||
|
await main.startup()
|
||||||
|
self.assertFalse(setup.is_setup_required())
|
||||||
|
self.assertEqual(launch.call_count, 9)
|
||||||
|
|
||||||
|
async def test_disabled_workers_stay_disabled_after_setup(self):
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
db.init_db()
|
||||||
|
setup.bootstrap_administrator(settings.setup_token, "owner", "new-password-12345")
|
||||||
|
setup.complete_setup()
|
||||||
|
with patch.dict("os.environ", {"BACKGROUND_TASKS_ENABLED": "false"}), patch.object(main, "_launch_background_task") as launch:
|
||||||
|
await main._start_background_tasks()
|
||||||
|
launch.assert_not_called()
|
||||||
|
|
||||||
|
async def test_bad_secret_stops_before_restore_or_database_initialization(self):
|
||||||
|
settings.jwt_secret = "short"
|
||||||
|
with patch.object(main, "apply_pending_restore") as restore, patch.object(main, "init_db") as initialize:
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "JWT_SECRET"):
|
||||||
|
await main.startup()
|
||||||
|
restore.assert_not_called()
|
||||||
|
initialize.assert_not_called()
|
||||||
|
|
||||||
|
async def test_restore_failure_stops_before_initialization_and_workers(self):
|
||||||
|
with patch.object(main, "apply_pending_restore", side_effect=RuntimeError("restore failed")), patch.object(main, "init_db") as initialize, patch.object(main, "_launch_background_task") as launch:
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "restore failed"):
|
||||||
|
await main.startup()
|
||||||
|
initialize.assert_not_called()
|
||||||
|
launch.assert_not_called()
|
||||||
|
|
||||||
|
async def test_startup_order_is_restore_then_setup_marker_then_schema(self):
|
||||||
|
calls = Mock()
|
||||||
|
calls.attach_mock(Mock(wraps=main.apply_pending_restore), "restore")
|
||||||
|
calls.attach_mock(Mock(wraps=main.initialize_setup_state), "setup")
|
||||||
|
calls.attach_mock(Mock(wraps=main.init_db), "schema")
|
||||||
|
with patch.object(main, "apply_pending_restore", calls.restore), patch.object(main, "initialize_setup_state", calls.setup), patch.object(main, "init_db", calls.schema):
|
||||||
|
await main.startup()
|
||||||
|
self.assertEqual([call[0] for call in calls.mock_calls], ["restore", "setup", "schema"])
|
||||||
|
|
||||||
|
def test_missing_token_does_not_allow_fresh_bootstrap(self):
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
db.init_db()
|
||||||
|
settings.setup_token = ""
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "SETUP_TOKEN"):
|
||||||
|
main._enforce_secure_startup_configuration()
|
||||||
|
|
||||||
|
def test_destination_environment_does_not_add_an_admin_to_restored_accounts(self):
|
||||||
|
db.init_db()
|
||||||
|
db.create_user("restored-owner", "existing-password-12345", role="admin")
|
||||||
|
with patch.object(settings, "admin_username", "host-bootstrap"), patch.object(settings, "admin_password", "new-host-password-12345"):
|
||||||
|
db.init_db()
|
||||||
|
self.assertIsNone(db.get_user_by_username("host-bootstrap"))
|
||||||
|
|
||||||
|
async def test_shutdown_cancels_workers_and_allows_next_start(self):
|
||||||
|
task = asyncio.create_task(asyncio.Event().wait())
|
||||||
|
main._background_tasks.append(task)
|
||||||
|
main._background_started = True
|
||||||
|
await main.shutdown()
|
||||||
|
self.assertTrue(task.cancelled())
|
||||||
|
self.assertEqual(main._background_tasks, [])
|
||||||
|
self.assertFalse(main._background_started)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallationRequestLimitsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_rejects_oversized_declared_body_before_parser(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||||
|
|
||||||
|
@app.post("/setup/bootstrap")
|
||||||
|
async def bootstrap(request: Request):
|
||||||
|
self.fail("Body must be rejected before the endpoint")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.post("/setup/bootstrap", content=b"{}", headers={"Content-Length": "999999"})
|
||||||
|
self.assertEqual(response.status_code, 413)
|
||||||
|
|
||||||
|
async def test_counts_chunks_with_missing_or_forged_content_length(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||||
|
|
||||||
|
@app.post("/setup/bootstrap")
|
||||||
|
async def bootstrap(request: Request):
|
||||||
|
return await request.json()
|
||||||
|
|
||||||
|
async def chunks():
|
||||||
|
yield b'{"token":"'
|
||||||
|
yield b"a" * 17000
|
||||||
|
yield b'"}'
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
for headers in ({}, {"Content-Length": "1"}):
|
||||||
|
response = await client.post("/setup/bootstrap", content=chunks(), headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 413)
|
||||||
|
|
||||||
|
async def test_multipart_stream_limit_is_413_not_parser_500(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(InstallationBodyLimitMiddleware)
|
||||||
|
|
||||||
|
@app.post("/admin/backups/restore")
|
||||||
|
async def restore(file: UploadFile = File(...)):
|
||||||
|
return {"size": file.size}
|
||||||
|
|
||||||
|
async def chunks():
|
||||||
|
yield b'--boundary\r\nContent-Disposition: form-data; name="file"; filename="backup"\r\n\r\n'
|
||||||
|
yield b"a" * 2048
|
||||||
|
yield b"\r\n--boundary--\r\n"
|
||||||
|
|
||||||
|
with patch("backend.app.request_limits.RESTORE_BODY_LIMIT", 1024):
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.post("/admin/backups/restore", content=chunks(), headers={"Content-Type": "multipart/form-data; boundary=boundary"})
|
||||||
|
self.assertEqual(response.status_code, 413)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import httpx
|
||||||
|
from backend.app.clients.jellyfin import JellyfinClient
|
||||||
|
from backend.app.services.snapshot import jellyfin_item_matches_request
|
||||||
|
from backend.app.models import RequestType
|
||||||
|
|
||||||
|
class JellyfinMatchingTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_search_includes_punctuation_variant_and_provider_metadata(self):
|
||||||
|
calls=[]
|
||||||
|
def handle(request):
|
||||||
|
calls.append(request)
|
||||||
|
items=[{'Id':'animated','Name':'Avatar: The Last Airbender','ProductionYear':2005,'ProviderIds':{'Tmdb':'246'}}] if ':' in request.url.params['SearchTerm'] else [{'Id':'live','Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'82452'}}]
|
||||||
|
return httpx.Response(200,json={'Items':items})
|
||||||
|
original=httpx.AsyncClient
|
||||||
|
with patch('backend.app.clients.jellyfin.httpx.AsyncClient',side_effect=lambda **kw:original(transport=httpx.MockTransport(handle),**kw)):
|
||||||
|
result=await JellyfinClient('http://jellyfin','test').search_items('Avatar: The Last Airbender',['Series'])
|
||||||
|
self.assertEqual({i['Id'] for i in result['Items']},{'live','animated'})
|
||||||
|
self.assertTrue(all('ProviderIds' in r.url.params['Fields'] for r in calls))
|
||||||
|
matches=[i for i in result['Items'] if jellyfin_item_matches_request(i,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload={'tmdbId':82452})]
|
||||||
|
self.assertEqual([i['Id'] for i in matches],['live'])
|
||||||
|
|
||||||
|
def test_fallback_rejects_remakes_prefixes_and_conflicting_ids(self):
|
||||||
|
def match(item,payload=None):
|
||||||
|
return jellyfin_item_matches_request(item,title='Avatar: The Last Airbender',year=2024,request_type=RequestType.tv,request_payload=payload)
|
||||||
|
self.assertTrue(match({'Name':'Avatar the Last Airbender','ProductionYear':2024}))
|
||||||
|
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2005}))
|
||||||
|
self.assertFalse(match({'Name':'Avatar','ProductionYear':2024}))
|
||||||
|
self.assertFalse(match({'Name':'Avatar the Last Airbender','ProductionYear':2024,'ProviderIds':{'Tmdb':'246'}},{'tmdbId':82452}))
|
||||||
|
self.assertTrue(match({'Name':'Localized title','ProviderIds':{'Tmdb':'82452'}},{'tmdbId':82452}))
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from backend.app.logging_config import JsonLogFormatter, RequestContextFilter, bind_request_id, reset_request_id
|
||||||
|
|
||||||
|
|
||||||
|
class JsonLoggingTests(unittest.TestCase):
|
||||||
|
def test_json_formatter_includes_request_context(self) -> None:
|
||||||
|
token = bind_request_id("request-123")
|
||||||
|
try:
|
||||||
|
record = logging.LogRecord("magent.test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||||
|
RequestContextFilter().filter(record)
|
||||||
|
payload = json.loads(JsonLogFormatter().format(record))
|
||||||
|
finally:
|
||||||
|
reset_request_id(token)
|
||||||
|
|
||||||
|
self.assertEqual(payload["level"], "INFO")
|
||||||
|
self.assertEqual(payload["logger"], "magent.test")
|
||||||
|
self.assertEqual(payload["request_id"], "request-123")
|
||||||
|
self.assertEqual(payload["message"], "hello world")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.services import manual_releases as manual
|
||||||
|
from backend.app.routers import requests
|
||||||
|
from backend.app.models import Snapshot, RequestType
|
||||||
|
from backend.app.feature_access import permissions, update_permissions
|
||||||
|
from backend.app import db
|
||||||
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ManualSelectionTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
secret = patch.object(settings, 'jwt_secret', 'manual-selection-test-secret-123456789')
|
||||||
|
secret.start(); self.addCleanup(secret.stop)
|
||||||
|
self.user = {'username': 'viewer', 'role': 'user', 'features': {'ignore_profile_limits': True}}
|
||||||
|
self.release = {'guid': 'release', 'indexerId': 7, 'title': 'Example', 'requiresOverride': True,
|
||||||
|
'rejections': ['WEBDL-2160p is not wanted in profile']}
|
||||||
|
self.payload = {**self.release, 'ignoreProfileLimits': True,
|
||||||
|
'selectionToken': manual.issue_selection(self.release, '42', self.user, 'http://sonarr', 55)}
|
||||||
|
|
||||||
|
def test_profile_only_rejections_are_overridable(self):
|
||||||
|
for reason in ['WEBDL-2160p is not wanted in profile', 'Custom format score below minimum', 'File is larger than maximum size', 'Language is not wanted']:
|
||||||
|
self.assertTrue(manual.decision({'approved': False, 'rejections': [reason]})[1])
|
||||||
|
for reason in ['Unknown series', 'Release is blocklisted', 'No download client available', 'Already in queue']:
|
||||||
|
self.assertFalse(manual.decision({'rejections': [self.release['rejections'][0], reason]})[1])
|
||||||
|
self.assertFalse(manual.decision({'approved': True, 'downloadAllowed': False})[0])
|
||||||
|
|
||||||
|
def test_receipt_binds_request_user_source_item_and_release(self):
|
||||||
|
self.assertTrue(manual.verify_selection(self.payload, '42', self.user, 'http://sonarr', 55)['override'])
|
||||||
|
attempts = [({**self.payload, 'guid': 'other'}, '42', self.user, 'http://sonarr', 55),
|
||||||
|
(self.payload, '43', self.user, 'http://sonarr', 55),
|
||||||
|
(self.payload, '42', {**self.user, 'username': 'other'}, 'http://sonarr', 55),
|
||||||
|
(self.payload, '42', self.user, 'http://other', 55),
|
||||||
|
(self.payload, '42', self.user, 'http://sonarr', 56),
|
||||||
|
({**self.payload, 'selectionToken': 'forged'}, '42', self.user, 'http://sonarr', 55)]
|
||||||
|
for args in attempts:
|
||||||
|
with self.assertRaises(HTTPException): manual.verify_selection(*args)
|
||||||
|
|
||||||
|
def test_permission_revocation_and_literal_confirmation_enforced(self):
|
||||||
|
for payload, user, code in [(self.payload, {**self.user, 'features': {}}, 403),
|
||||||
|
({**self.payload, 'ignoreProfileLimits': 'true'}, self.user, 400)]:
|
||||||
|
with self.assertRaises(HTTPException) as error:
|
||||||
|
manual.verify_selection(payload, '42', user, 'http://sonarr', 55)
|
||||||
|
self.assertEqual(error.exception.status_code, code)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
|
def test_default_off_individual_and_bulk(self):
|
||||||
|
for name in ('one', 'two'): db.create_user(name, 'Password123!', role='user')
|
||||||
|
one, two = [db.get_user_by_username(n) for n in ('one', 'two')]
|
||||||
|
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||||
|
update_permissions({'ignore_profile_limits': True}, 'one')
|
||||||
|
self.assertTrue(permissions(one)['ignore_profile_limits'])
|
||||||
|
self.assertFalse(permissions(two)['ignore_profile_limits'])
|
||||||
|
update_permissions({'ignore_profile_limits': False})
|
||||||
|
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||||
|
|
||||||
|
|
||||||
|
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
access = patch.object(
|
||||||
|
requests,
|
||||||
|
'_ensure_request_mutation_access',
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
|
access.start()
|
||||||
|
self.addCleanup(access.stop)
|
||||||
|
|
||||||
|
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||||
|
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||||
|
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||||
|
active = peak = 0
|
||||||
|
async def search(identity):
|
||||||
|
nonlocal active, peak
|
||||||
|
active += 1; peak = max(peak, active)
|
||||||
|
await asyncio.sleep(0.001); active -= 1
|
||||||
|
return []
|
||||||
|
sonarr = SimpleNamespace(configured=lambda: True, get_episodes=AsyncMock(return_value=episodes), search_episode_releases=AsyncMock(side_effect=search))
|
||||||
|
runtime = SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||||
|
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||||
|
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
|
||||||
|
first = await requests.action_search('42', {'username':'viewer','role':'user'})
|
||||||
|
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=24)
|
||||||
|
self.assertEqual(first['nextOffset'],3); self.assertIsNone(second['nextOffset'])
|
||||||
|
self.assertEqual(sonarr.search_episode_releases.await_count,4)
|
||||||
|
self.assertLessEqual(peak,3)
|
||||||
|
self.assertEqual(first['totalMissingEpisodes'],25)
|
||||||
|
|
||||||
|
async def test_auto_search_preserves_current_profile(self):
|
||||||
|
for kind, service in [(RequestType.tv,'SonarrClient'),(RequestType.movie,'RadarrClient')]:
|
||||||
|
client=SimpleNamespace(configured=lambda:True, update_series=AsyncMock(), update_movie=AsyncMock(),
|
||||||
|
get_episodes=AsyncMock(return_value=[{'id':1,'seasonNumber':1,'monitored':True,'hasFile':False}]),
|
||||||
|
search_episodes=AsyncMock(return_value={'id':1}), search=AsyncMock(return_value={'id':1}))
|
||||||
|
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test',radarr_base_url='http://radarr',radarr_api_key='test',sonarr_quality_profile_id=6,radarr_quality_profile_id=6)
|
||||||
|
snapshot=Snapshot(request_id='42',request_type=kind,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||||
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,service,return_value=client),patch.object(requests,'save_action'),patch.object(requests,'series_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})),patch.object(requests,'movie_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})):
|
||||||
|
await requests.action_search_auto('42',{'username':'admin','role':'admin'})
|
||||||
|
client.update_series.assert_not_awaited(); client.update_movie.assert_not_awaited()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from backend.app.services import public_urls, newsletter_store, recap_store, newsletters, newsletter_catalog
|
||||||
|
from backend.tests.test_newsletters import NewsletterFixture
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUrlTests(unittest.TestCase):
|
||||||
|
def resolve(self, application=None, proxy=None, enabled=False, legacy='https://legacy.test'):
|
||||||
|
with patch.object(public_urls,'get_runtime_settings',return_value=SimpleNamespace(
|
||||||
|
magent_application_url=application,magent_proxy_base_url=proxy,magent_proxy_enabled=enabled)):
|
||||||
|
return public_urls.magent_public_url(legacy)
|
||||||
|
|
||||||
|
def test_hosting_is_authoritative_with_proxy_and_path_support(self):
|
||||||
|
self.assertEqual(self.resolve('https://prod.test/'),'https://prod.test')
|
||||||
|
self.assertEqual(self.resolve('http://internal:3000','https://public.test/magent/',True),'https://public.test/magent')
|
||||||
|
self.assertEqual(self.resolve('https://prod.test','https://old-proxy.test',False),'https://prod.test')
|
||||||
|
self.assertEqual(self.resolve(),'https://legacy.test')
|
||||||
|
|
||||||
|
def test_invalid_configured_address_does_not_use_stale_legacy(self):
|
||||||
|
for value in ['javascript:alert(1)','https://user:password@host.test','https://host.test?key=secret','https://host.test/#fragment','https://host.test:99999','https://host.test/ bad']:
|
||||||
|
self.assertEqual(self.resolve(value),'')
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterHostingTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_existing_draft_previews_using_hosting_without_duplicate_url(self):
|
||||||
|
draft=self.draft()
|
||||||
|
with newsletter_store.transaction() as c:
|
||||||
|
c.execute("UPDATE newsletter_settings SET public_url=''")
|
||||||
|
self.runtime.magent_application_url='https://prod.example.test'
|
||||||
|
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime),patch.object(newsletter_catalog,'posters',new=AsyncMock(return_value={})):
|
||||||
|
rendered=await newsletters.preview(draft['id'],draft['revision'])
|
||||||
|
self.assertIn('https://prod.example.test/profile#newsletters',rendered['body_html'])
|
||||||
|
self.assertIn('https://watch.example.test',rendered['body_html'])
|
||||||
|
self.assertNotIn('https://beta.example.test',rendered['body_html'])
|
||||||
|
self.assertEqual(recap_store.settings()['public_url'],'https://prod.example.test')
|
||||||
|
|
||||||
|
def test_scheduled_delivery_uses_current_hosting_address(self):
|
||||||
|
self.subscribe(when=100)
|
||||||
|
draft=self.draft()
|
||||||
|
newsletter_store.publish(draft['id'],draft['revision'],200,150)
|
||||||
|
self.runtime.magent_application_url='https://prod.example.test'
|
||||||
|
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime):
|
||||||
|
newsletter_store.enqueue_due(201)
|
||||||
|
delivery=newsletter_store.read_one('SELECT * FROM newsletter_deliveries WHERE edition_id=?',(draft['id'],))
|
||||||
|
self.assertEqual(delivery['public_url'],'https://prod.example.test')
|
||||||
|
self.runtime.magent_application_url='https://new.example.test'
|
||||||
|
self.assertEqual(newsletter_store.settings()['public_url'],'https://new.example.test')
|
||||||
|
|
||||||
|
def test_saving_schedule_uses_hosting_instead_of_client_address(self):
|
||||||
|
self.runtime.magent_application_url='https://prod.example.test'
|
||||||
|
with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime):
|
||||||
|
result=newsletter_store.save_settings({**self.config,'public_url':'https://stale.example.test'},datetime.now(timezone.utc))
|
||||||
|
self.assertEqual(result['public_url'],'https://prod.example.test')
|
||||||
|
result=recap_store.save_settings({'enabled':False,'day':2,'hour':9,'public_url':''},datetime.now(timezone.utc))
|
||||||
|
self.assertEqual(result['public_url'],'https://prod.example.test')
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, AsyncMock, patch
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from backend.app.routers import requests
|
||||||
|
|
||||||
|
|
||||||
|
class RecentStageTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_displayed_stage_controls_filter_and_pagination(self):
|
||||||
|
runtime = SimpleNamespace(jellyseerr_base_url='', jellyseerr_api_key='',
|
||||||
|
requests_data_source='prefer_cache', artwork_cache_mode='remote',
|
||||||
|
jellyfin_base_url='', jellyfin_api_key='')
|
||||||
|
rows = [dict(request_id=i, title=str(i), status=status, media_type='movie',
|
||||||
|
requested_by_id=10) for i, status in [(1,5),(2,5),(3,4),(4,6),(5,5),(6,2),(7,1),(8,3)]]
|
||||||
|
async def available(client, title, *args): return title in {'1','3','4','5'}
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for name, value in [('get_runtime_settings', runtime), ('get_request_stage_cache', {1:{'ready':True},3:{'ready':True},4:{'ready':True},5:{'ready':True}}), ('_recent_cache_stale', False),
|
||||||
|
('active_repair_request_ids', {'5'}), ('get_request_cache_payload', None)]:
|
||||||
|
stack.enter_context(patch.object(requests, name, return_value=value))
|
||||||
|
stack.enter_context(patch.dict(requests._recent_cache, {'items':rows}))
|
||||||
|
stack.enter_context(patch.object(requests, '_request_is_available_in_jellyfin', new=AsyncMock(side_effect=AssertionError('Recent requests must not call Jellyfin'))))
|
||||||
|
user={'role':'user','username':'viewer','jellyseerr_user_id':10}
|
||||||
|
expected={'working':[2,5], 'ready':[1,3], 'partial':[4], 'approved':[6],
|
||||||
|
'pending':[7], 'declined':[8], 'in_progress':[2,4,5,6]}
|
||||||
|
for stage, ids in expected.items():
|
||||||
|
result=await requests.recent_requests(take=20,skip=0,days=0,stage=stage,user=user)
|
||||||
|
self.assertEqual([r['id'] for r in result['results']],ids,stage)
|
||||||
|
result=await requests.recent_requests(take=1,skip=1,days=0,stage='working',user=user)
|
||||||
|
self.assertEqual([r['id'] for r in result['results']],[5])
|
||||||
|
result=await requests.recent_requests(take=1,skip=0,days=0,stage='ready',user=user)
|
||||||
|
self.assertEqual(result['results'][0]['status'],4)
|
||||||
|
result=await requests.recent_requests(take=20,skip=0,days=0,stage='all',user={**user,'jellyseerr_user_id':99})
|
||||||
|
self.assertEqual(result['results'],[])
|
||||||
|
|
||||||
|
async def test_background_refresh_skips_fresh_rows_and_preserves_failures(self):
|
||||||
|
import time
|
||||||
|
rows=[{'request_id':i,'status':5,'title':str(i),'updated_at':'v1'} for i in [1,2,3]]
|
||||||
|
runtime=SimpleNamespace(jellyfin_base_url='http://jellyfin',jellyfin_api_key='test',requests_stage_refresh_minutes=15)
|
||||||
|
with ExitStack() as stack:
|
||||||
|
stack.enter_context(patch.object(requests,'get_runtime_settings',return_value=runtime))
|
||||||
|
stack.enter_context(patch.object(requests,'get_cached_requests_since',return_value=rows))
|
||||||
|
stack.enter_context(patch.object(requests,'get_request_stage_cache',return_value={1:{'source_updated':'v1','checked_at':time.time()}}))
|
||||||
|
stack.enter_context(patch.object(requests,'get_request_cache_payload',return_value={}))
|
||||||
|
check=stack.enter_context(patch.object(requests,'_request_is_available_in_jellyfin',new=AsyncMock(side_effect=[True,RuntimeError('offline')])) )
|
||||||
|
save=stack.enter_context(patch.object(requests,'save_request_stage_cache'))
|
||||||
|
await requests.refresh_local_request_stages()
|
||||||
|
self.assertEqual(check.await_count,2)
|
||||||
|
written=save.call_args.args[0]
|
||||||
|
self.assertEqual(len(written),1)
|
||||||
|
self.assertEqual(written[0][:3],(2,'v1',1))
|
||||||
|
|
||||||
|
|
||||||
|
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||||
|
from backend.app import db
|
||||||
|
|
||||||
|
class StagePersistenceTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
|
def test_saved_stages_survive_initialization_and_actions_mark_due(self):
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("INSERT INTO requests_cache (request_id, payload_json) VALUES (42, '{}')")
|
||||||
|
db.save_request_stage_cache([(42,'v1',1,12345)])
|
||||||
|
db.init_db()
|
||||||
|
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
|
||||||
|
db.save_action('42','search_releases','Search','ok')
|
||||||
|
self.assertEqual(db.get_request_stage_cache()[42]['checked_at'],0)
|
||||||
|
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from backend.app.routers import requests
|
||||||
|
from backend.app.models import Snapshot, RequestType
|
||||||
|
|
||||||
|
class RecheckMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_series_restores_only_requested_seasons_and_episodes(self):
|
||||||
|
original={'id':10,'monitored':False,'qualityProfileId':7,'seasons':[{'seasonNumber':1,'monitored':False},{'seasonNumber':2,'monitored':False}]}
|
||||||
|
restored={**original,'monitored':True,'seasons':[{'seasonNumber':1,'monitored':True},{'seasonNumber':2,'monitored':False}]}
|
||||||
|
client=SimpleNamespace(get_series=AsyncMock(side_effect=[original,restored]),update_series=AsyncMock(),get_episodes=AsyncMock(side_effect=[[{'id':1,'seasonNumber':1,'monitored':False},{'id':2,'seasonNumber':2,'monitored':False}],[{'id':1,'seasonNumber':1,'monitored':True},{'id':2,'seasonNumber':2,'monitored':False}]]),monitor_episodes=AsyncMock())
|
||||||
|
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.tv,raw={'arr':{'item':{'id':10}}})
|
||||||
|
runtime=SimpleNamespace(sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||||
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'SonarrClient',return_value=client):
|
||||||
|
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2,'seasons':[{'seasonNumber':1}]}))
|
||||||
|
client.update_series.assert_awaited_once_with(restored)
|
||||||
|
client.monitor_episodes.assert_awaited_once_with([1],True)
|
||||||
|
|
||||||
|
async def test_movie_monitoring_preserves_profile_and_pending_is_noop(self):
|
||||||
|
movie={'id':10,'monitored':False,'qualityProfileId':7}
|
||||||
|
client=SimpleNamespace(get_movie=AsyncMock(side_effect=[movie,{**movie,'monitored':True}]),update_movie=AsyncMock())
|
||||||
|
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.movie,raw={'arr':{'item':{'id':10}}})
|
||||||
|
runtime=SimpleNamespace(radarr_base_url='http://radarr',radarr_api_key='test')
|
||||||
|
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'RadarrClient',return_value=client):
|
||||||
|
self.assertFalse(await requests._restore_request_monitoring(snapshot,{'status':1}))
|
||||||
|
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2}))
|
||||||
|
client.update_movie.assert_awaited_once_with({**movie,'monitored':True})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from backend.tests.test_insights import play, NOW, LIBRARIES
|
||||||
|
from backend.tests.test_email_recaps import fixture_report
|
||||||
|
from backend.app.services.insights import summarize
|
||||||
|
from backend.app.services.email_recaps import illustrated_recap
|
||||||
|
|
||||||
|
class ReportGraphicsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_patterns_deduplicate_and_handle_empty_history(self):
|
||||||
|
first = play()
|
||||||
|
second = play("second", PlaybackDuration=1800, EpisodeId="episode", ActivityDateInserted=(NOW-timedelta(days=1)).isoformat())
|
||||||
|
report = summarize([first, first, second], LIBRARIES, NOW-timedelta(days=7), NOW)
|
||||||
|
self.assertEqual(report["patterns"]["average_play_minutes"], 45)
|
||||||
|
self.assertEqual(report["patterns"]["longest_play_minutes"], 60)
|
||||||
|
self.assertEqual(report["patterns"]["weekend_percent"], 33.3)
|
||||||
|
self.assertEqual(sum(r["minutes"] for r in report["patterns"]["media"]), 90)
|
||||||
|
empty = summarize([], [], NOW-timedelta(days=7), NOW)
|
||||||
|
self.assertEqual(empty["patterns"]["average_play_minutes"], 0)
|
||||||
|
|
||||||
|
async def test_artwork_embedded_without_private_links_and_optional_on_failure(self):
|
||||||
|
report = fixture_report()
|
||||||
|
report["top_titles"][0]["artwork_url"] = "/insights/artwork/" + "a"*32 + "?token=123." + "b"*64
|
||||||
|
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(return_value=(b"picture", "image/webp"))):
|
||||||
|
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
|
||||||
|
self.assertIn("cid:recap-title-0@magent", rendered["body_html"])
|
||||||
|
self.assertNotIn("?token=", rendered["body_html"])
|
||||||
|
self.assertEqual(rendered["inline_images"][0]["subtype"], "webp")
|
||||||
|
preview = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe", preview=True)
|
||||||
|
self.assertIn("data:image/webp;base64,", preview["body_html"])
|
||||||
|
self.assertNotIn("inline_images", preview)
|
||||||
|
with patch("backend.app.services.email_recaps.get_runtime_settings"), patch("backend.app.services.insights_artwork.get_artwork", new=AsyncMock(side_effect=RuntimeError())):
|
||||||
|
rendered = await illustrated_recap(report, {"username":"viewer"}, "https://example.test", "https://example.test/unsubscribe")
|
||||||
|
self.assertEqual(rendered["inline_images"], [])
|
||||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from backend.app.services.request_language import language_info, original_profile, is_original_profile
|
from backend.app.services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome
|
||||||
from backend.app.routers import requests
|
from backend.app.routers import requests
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
with patch.object(requests, 'get_runtime_settings', return_value=runtime), \
|
||||||
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
|
patch.object(requests, 'JellyseerrClient', return_value=seerr), \
|
||||||
patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
|
patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \
|
||||||
|
patch.object(requests, 'apply_original_to_movie', new=AsyncMock(return_value=None)), \
|
||||||
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
|
patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone:
|
||||||
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
|
payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]}
|
||||||
if expected is None:
|
if expected is None:
|
||||||
@@ -65,3 +66,71 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await requests.create_request(payload, {'username': 'viewer'})
|
await requests.create_request(payload, {'username': 'viewer'})
|
||||||
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
|
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected)
|
||||||
self.assertEqual(clone.await_count, int(expected == 20))
|
self.assertEqual(clone.await_count, int(expected == 20))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_existing_radarr_movie_is_updated_and_read_back(self):
|
||||||
|
movie = {'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9, 'monitored': True}
|
||||||
|
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[movie]),
|
||||||
|
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={**movie, 'qualityProfileId': 20}))
|
||||||
|
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
|
||||||
|
self.assertEqual(await apply_original_to_movie(client, 613), 20)
|
||||||
|
self.assertEqual(client.update_movie.await_args.args[0]['qualityProfileId'], 20)
|
||||||
|
self.assertTrue(client.update_movie.await_args.args[0]['monitored'])
|
||||||
|
|
||||||
|
async def test_failed_profile_verification_does_not_claim_success(self):
|
||||||
|
client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[{'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9}]),
|
||||||
|
update_movie=AsyncMock(), get_movie=AsyncMock(return_value={'qualityProfileId': 9}))
|
||||||
|
with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)):
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
await apply_original_to_movie(client, 613)
|
||||||
|
|
||||||
|
async def test_search_reports_real_outcomes(self):
|
||||||
|
for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'),
|
||||||
|
('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]:
|
||||||
|
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
|
||||||
|
get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False}))
|
||||||
|
result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0)
|
||||||
|
self.assertEqual(result['status'], expected)
|
||||||
|
|
||||||
|
async def test_language_endpoint_checks_consent_identity_and_access(self):
|
||||||
|
for payload in ({}, {'acceptOriginalLanguage': 'true'}, {'acceptOriginalLanguage': True, 'languageCode': 'es'}):
|
||||||
|
with patch.object(requests, '_request_language_context', new=AsyncMock(return_value=(SimpleNamespace(), 613, {'code': 'de'}))), \
|
||||||
|
patch.object(requests, 'apply_original_to_movie', new=AsyncMock()) as apply:
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
await requests.accept_request_language('3976', payload, {'role': 'admin'})
|
||||||
|
apply.assert_not_awaited()
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
await requests.accept_request_language('3976', {'acceptOriginalLanguage': True}, {'role': 'user', 'auto_search_enabled': False})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_radarr_queue_filters_before_pagination(self):
|
||||||
|
from backend.app.clients.radarr import RadarrClient
|
||||||
|
client = RadarrClient('http://radarr.test', 'test')
|
||||||
|
with patch.object(client, 'get', new=AsyncMock(return_value={'records': []})) as get:
|
||||||
|
await client.get_queue(6940)
|
||||||
|
get.assert_awaited_once_with('/api/v3/queue', params={'movieIds': 6940, 'pageSize': 1000})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tv_search_distinguishes_no_download_and_queue(self):
|
||||||
|
from backend.app.services.request_language import series_search_outcome
|
||||||
|
client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []}))
|
||||||
|
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'pending')
|
||||||
|
client.get_queue.return_value = {'records': [{'seriesId': 50}]}
|
||||||
|
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading')
|
||||||
|
|
||||||
|
|
||||||
|
class SearchHandoffTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_radarr_completed_before_queue_refresh(self):
|
||||||
|
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
|
||||||
|
get_queue=AsyncMock(side_effect=[{'records':[]}, {'records':[]}, {'records':[{'movieId':2206}]}]),
|
||||||
|
get_movie=AsyncMock(return_value={'hasFile':False}))
|
||||||
|
result = await movie_search_outcome(client, 2206, {'id':1}, attempts=3, delay=0)
|
||||||
|
self.assertEqual(result['status'], 'downloading')
|
||||||
|
self.assertEqual(client.get_queue.await_count, 3)
|
||||||
|
|
||||||
|
async def test_sonarr_completed_before_queue_refresh(self):
|
||||||
|
from backend.app.services.request_language import series_search_outcome
|
||||||
|
client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}),
|
||||||
|
get_queue=AsyncMock(side_effect=[{'records':[{'seriesId':999}]}, {'records':[{'seriesId':50}]}]))
|
||||||
|
result = await series_search_outcome(client, 50, [{'id':1}], attempts=2, delay=0)
|
||||||
|
self.assertEqual(result['status'], 'downloading')
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Origin checks use operator configuration, never caller-controlled routing headers."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.app import db, main
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.routers import auth as auth_router
|
||||||
|
from backend.app.services import public_urls
|
||||||
|
from backend.app.services.request_origins import is_allowed_request_origin
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_ORIGIN = "https://watch.example.test"
|
||||||
|
LOCAL_ORIGIN = "http://localhost:3000"
|
||||||
|
|
||||||
|
|
||||||
|
class RequestOriginTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.runtime = SimpleNamespace(
|
||||||
|
magent_proxy_enabled=False,
|
||||||
|
magent_proxy_base_url=None,
|
||||||
|
magent_application_url=PUBLIC_ORIGIN,
|
||||||
|
)
|
||||||
|
self.enterContext(patch.object(settings, "cors_allow_origin", LOCAL_ORIGIN))
|
||||||
|
self.enterContext(patch.object(public_urls, "get_runtime_settings", return_value=self.runtime))
|
||||||
|
|
||||||
|
def test_explicit_cors_and_configured_public_url_are_both_allowed(self):
|
||||||
|
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||||
|
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||||
|
self.assertFalse(is_allowed_request_origin("https://unrelated.example.test"))
|
||||||
|
|
||||||
|
def test_scheme_hostname_case_and_default_ports_are_canonicalized(self):
|
||||||
|
for origin in (PUBLIC_ORIGIN, "HTTPS://WATCH.EXAMPLE.TEST", "https://watch.example.test:443"):
|
||||||
|
with self.subTest(origin=origin):
|
||||||
|
self.assertTrue(is_allowed_request_origin(origin))
|
||||||
|
self.runtime.magent_application_url = "http://watch.example.test:80"
|
||||||
|
self.assertTrue(is_allowed_request_origin("http://WATCH.example.test"))
|
||||||
|
self.assertFalse(is_allowed_request_origin("https://watch.example.test"))
|
||||||
|
self.assertFalse(is_allowed_request_origin("http://watch.example.test:8080"))
|
||||||
|
|
||||||
|
def test_nondefault_ports_must_match(self):
|
||||||
|
self.runtime.magent_application_url = "https://watch.example.test:8443/magent"
|
||||||
|
self.assertTrue(is_allowed_request_origin("https://watch.example.test:8443"))
|
||||||
|
self.assertFalse(is_allowed_request_origin("https://watch.example.test"))
|
||||||
|
self.assertFalse(is_allowed_request_origin("https://watch.example.test:443"))
|
||||||
|
|
||||||
|
def test_configured_subpath_does_not_become_part_of_origin(self):
|
||||||
|
self.runtime.magent_application_url = PUBLIC_ORIGIN + "/magent/"
|
||||||
|
self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||||
|
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN + "/magent"))
|
||||||
|
|
||||||
|
def test_enabled_proxy_uses_configured_proxy_public_url(self):
|
||||||
|
self.runtime.magent_proxy_enabled = True
|
||||||
|
self.runtime.magent_proxy_base_url = "https://proxy.example.test/magent"
|
||||||
|
self.assertTrue(is_allowed_request_origin("https://proxy.example.test"))
|
||||||
|
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||||
|
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||||
|
|
||||||
|
def test_unconfigured_public_url_only_allows_explicit_cors(self):
|
||||||
|
self.runtime.magent_application_url = None
|
||||||
|
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||||
|
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||||
|
|
||||||
|
def test_invalid_or_non_origin_inputs_are_rejected(self):
|
||||||
|
for origin in (
|
||||||
|
"", "null", "*", "watch.example.test", "//watch.example.test",
|
||||||
|
"ftp://watch.example.test", "javascript:alert(1)",
|
||||||
|
PUBLIC_ORIGIN + "/", PUBLIC_ORIGIN + "/path",
|
||||||
|
PUBLIC_ORIGIN + "?query=true", PUBLIC_ORIGIN + "#fragment",
|
||||||
|
PUBLIC_ORIGIN + "?", PUBLIC_ORIGIN + "#",
|
||||||
|
"https://user@watch.example.test", "https://user:password@watch.example.test",
|
||||||
|
"https://watch.example.test@evil.example.test", "https://watch.example.test.evil.example.test",
|
||||||
|
"https://watch.example.test:0", "https://watch.example.test:65536",
|
||||||
|
"https://watch.example.test:invalid", "https://[invalid",
|
||||||
|
PUBLIC_ORIGIN + " https://evil.example.test", PUBLIC_ORIGIN + ",https://evil.example.test",
|
||||||
|
"https://watch.example.test\\@evil.example.test", PUBLIC_ORIGIN + "\n",
|
||||||
|
):
|
||||||
|
with self.subTest(origin=repr(origin)):
|
||||||
|
self.assertFalse(is_allowed_request_origin(origin))
|
||||||
|
|
||||||
|
def test_invalid_configured_public_url_does_not_authorize_an_origin(self):
|
||||||
|
for configured in (
|
||||||
|
"https://user:password@watch.example.test", PUBLIC_ORIGIN + "?token=private",
|
||||||
|
PUBLIC_ORIGIN + "#fragment", "javascript:alert(1)", "https://watch.example.test:65536",
|
||||||
|
):
|
||||||
|
with self.subTest(configured=configured):
|
||||||
|
self.runtime.magent_application_url = configured
|
||||||
|
self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN))
|
||||||
|
self.assertTrue(is_allowed_request_origin(LOCAL_ORIGIN))
|
||||||
|
|
||||||
|
|
||||||
|
class RequestOriginHttpTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.runtime = SimpleNamespace(
|
||||||
|
magent_proxy_enabled=False,
|
||||||
|
magent_proxy_base_url=None,
|
||||||
|
magent_application_url=PUBLIC_ORIGIN,
|
||||||
|
)
|
||||||
|
for name, value in {
|
||||||
|
"sqlite_path": str(Path(temporary.name) / "origin-tests.db"),
|
||||||
|
"sqlite_journal_mode": "DELETE",
|
||||||
|
"jwt_secret": "request-origin-tests-jwt-secret-at-least-32-characters",
|
||||||
|
"settings_encryption_key": None,
|
||||||
|
"admin_username": "unused-environment-admin",
|
||||||
|
"admin_password": "",
|
||||||
|
"cors_allow_origin": LOCAL_ORIGIN,
|
||||||
|
"auth_cookie_domain": None,
|
||||||
|
"auth_cookie_secure": True,
|
||||||
|
}.items():
|
||||||
|
self.enterContext(patch.object(settings, name, value))
|
||||||
|
self.enterContext(patch.object(public_urls, "get_runtime_settings", return_value=self.runtime))
|
||||||
|
# Constructing without a context deliberately skips production startup:
|
||||||
|
# no migrations/workers/listeners/log files outside this temporary DB.
|
||||||
|
db.init_db()
|
||||||
|
self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN)
|
||||||
|
self.addCleanup(self.client.close)
|
||||||
|
|
||||||
|
def test_public_origin_reaches_both_auth_handlers_with_localhost_cors_default(self):
|
||||||
|
for path in ("/auth/login", "/auth/jellyfin/login"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.post(path, data={}, headers={"Origin": PUBLIC_ORIGIN})
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
self.assertNotEqual(response.json().get("detail"), "Cross-origin state change rejected")
|
||||||
|
|
||||||
|
def test_explicit_cors_origin_remains_allowed(self):
|
||||||
|
response = self.client.post("/auth/login", data={}, headers={"Origin": LOCAL_ORIGIN})
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
|
||||||
|
def test_no_origin_keeps_existing_nonbrowser_behavior(self):
|
||||||
|
response = self.client.post("/auth/login", data={})
|
||||||
|
self.assertEqual(response.status_code, 422, response.text)
|
||||||
|
|
||||||
|
def test_caller_controlled_host_forwarding_and_fetch_headers_cannot_authorize_evil_origin(self):
|
||||||
|
for path in ("/auth/login", "/auth/jellyfin/login"):
|
||||||
|
for routing_headers in (
|
||||||
|
{},
|
||||||
|
{"Host": "evil.example.test"},
|
||||||
|
{"X-Forwarded-Host": "evil.example.test", "X-Forwarded-Proto": "https"},
|
||||||
|
{"Host": "evil.example.test", "X-Forwarded-Host": "evil.example.test", "Sec-Fetch-Site": "same-origin"},
|
||||||
|
{"Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test", "Sec-Fetch-Site": "same-origin"},
|
||||||
|
):
|
||||||
|
with self.subTest(path=path, routing_headers=routing_headers):
|
||||||
|
response = self.client.post(path, data={}, headers={"Origin": "https://evil.example.test", **routing_headers})
|
||||||
|
self.assertEqual(response.status_code, 403, response.text)
|
||||||
|
self.assertEqual(response.json()["detail"], "Cross-origin state change rejected")
|
||||||
|
|
||||||
|
def test_null_path_query_and_userinfo_origins_are_rejected_before_login(self):
|
||||||
|
for origin in ("null", PUBLIC_ORIGIN + "/", PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?query=1", "https://user@watch.example.test"):
|
||||||
|
with self.subTest(origin=origin):
|
||||||
|
response = self.client.post("/auth/login", data={}, headers={"Origin": origin})
|
||||||
|
self.assertEqual(response.status_code, 403, response.text)
|
||||||
|
|
||||||
|
def test_valid_local_login_works_from_configured_public_origin(self):
|
||||||
|
password = "origin-tests-valid-local-password"
|
||||||
|
db.create_user("origin-owner", password, role="admin")
|
||||||
|
response = self.client.post("/auth/login", data={"username": "origin-owner", "password": password}, headers={"Origin": PUBLIC_ORIGIN})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||||
|
profile = self.client.get("/auth/profile")
|
||||||
|
self.assertEqual(profile.status_code, 200, profile.text)
|
||||||
|
self.assertEqual(profile.json()["user"]["username"], "origin-owner")
|
||||||
|
|
||||||
|
def test_valid_mocked_jellyfin_login_works_from_configured_public_origin(self):
|
||||||
|
jellyfin_runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin.test:8096", jellyfin_api_key="test-api-key")
|
||||||
|
upstream = SimpleNamespace(
|
||||||
|
configured=lambda: True,
|
||||||
|
authenticate_by_name=AsyncMock(return_value={"User": {"Id": "test-jellyfin-id", "Name": "origin-viewer"}}),
|
||||||
|
get_users=AsyncMock(return_value=[]),
|
||||||
|
_extract_user_id=lambda _response: "test-jellyfin-id",
|
||||||
|
)
|
||||||
|
with patch.object(auth_router, "get_runtime_settings", return_value=jellyfin_runtime), patch.object(auth_router, "JellyfinClient", return_value=upstream), patch.object(auth_router, "get_cached_jellyseerr_users", return_value=[]):
|
||||||
|
response = self.client.post("/auth/jellyfin/login", data={"username": "origin-viewer", "password": "origin-tests-jellyfin-password"}, headers={"Origin": PUBLIC_ORIGIN})
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
upstream.authenticate_by_name.assert_awaited_once_with("origin-viewer", "origin-tests-jellyfin-password")
|
||||||
|
self.assertIn(settings.auth_cookie_name, self.client.cookies)
|
||||||
|
self.assertEqual(db.get_user_by_username("origin-viewer")["auth_provider"], "jellyfin")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from backend.app.schema_migrations import run_schema_migrations
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaMigrationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.conn = sqlite3.connect(":memory:")
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at TEXT)"
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE signup_invites (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at TEXT, updated_at TEXT)"
|
||||||
|
)
|
||||||
|
self.conn.execute("CREATE TABLE portal_items (id INTEGER PRIMARY KEY, kind TEXT, updated_at TEXT)")
|
||||||
|
self.conn.execute("CREATE TABLE requests_cache (request_id INTEGER PRIMARY KEY, created_at TEXT)")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_migrations_are_versioned_and_idempotent(self) -> None:
|
||||||
|
self.assertEqual(run_schema_migrations(self.conn), [1])
|
||||||
|
self.assertEqual(run_schema_migrations(self.conn), [])
|
||||||
|
|
||||||
|
user_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(users)")}
|
||||||
|
self.assertIn("auth_version", user_columns)
|
||||||
|
self.assertIn("email", user_columns)
|
||||||
|
request_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(requests_cache)")}
|
||||||
|
self.assertIn("requested_by_id", request_columns)
|
||||||
|
applied = self.conn.execute("SELECT version, name FROM schema_migrations").fetchall()
|
||||||
|
self.assertEqual(applied, [(1, "legacy_columns_and_indexes")])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from threading import Barrier
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.app import db
|
||||||
|
from backend.app.config import settings
|
||||||
|
from backend.app.routers import setup as setup_router
|
||||||
|
from backend.app.security import create_access_token
|
||||||
|
from backend.app.services import setup
|
||||||
|
|
||||||
|
|
||||||
|
SETUP_TOKEN = "operator-setup-token-for-tests-only-1234567890"
|
||||||
|
ADMIN_PASSWORD = "A-long-admin-password!123"
|
||||||
|
|
||||||
|
|
||||||
|
class SetupTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
for field, value in {
|
||||||
|
"sqlite_path": os.path.join(self.temp.name, "test.db"),
|
||||||
|
"sqlite_journal_mode": "DELETE",
|
||||||
|
"admin_username": "environment-admin",
|
||||||
|
"admin_password": "",
|
||||||
|
"jwt_secret": "setup-test-jwt-secret-only-1234567890",
|
||||||
|
"settings_encryption_key": "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU=",
|
||||||
|
}.items():
|
||||||
|
context = patch.object(settings, field, value)
|
||||||
|
context.start()
|
||||||
|
self.addCleanup(context.stop)
|
||||||
|
context = patch.object(setup, "settings", SimpleNamespace(setup_token=SETUP_TOKEN))
|
||||||
|
context.start()
|
||||||
|
self.addCleanup(context.stop)
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
db.init_db()
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(setup_router.router)
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
self.addCleanup(self.client.close)
|
||||||
|
|
||||||
|
def bootstrap(self, **changes):
|
||||||
|
return self.client.post("/setup/bootstrap", json={
|
||||||
|
"setup_token": SETUP_TOKEN,
|
||||||
|
"username": "first-admin",
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
**changes,
|
||||||
|
})
|
||||||
|
|
||||||
|
def admin_headers(self):
|
||||||
|
return {"Authorization": f"Bearer {create_access_token('first-admin', 'admin')}"}
|
||||||
|
|
||||||
|
def test_fresh_install_requires_setup_and_exposes_no_configuration(self):
|
||||||
|
response = self.client.get("/setup/status")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"setup_required": True, "needs_admin": True})
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
self.assertEqual(self.client.get("/setup/state").status_code, 401)
|
||||||
|
|
||||||
|
def test_existing_install_migrates_as_completed_without_reopening_bootstrap(self):
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("DROP TABLE installation_setup")
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
self.assertEqual(setup.get_public_setup_status(), {"setup_required": False, "needs_admin": False})
|
||||||
|
self.assertIsNotNone(setup.get_setup_state()["completed_at"])
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 409)
|
||||||
|
|
||||||
|
def test_missing_marker_fails_closed(self):
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("DROP TABLE installation_setup")
|
||||||
|
self.assertFalse(setup.is_setup_required())
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 409)
|
||||||
|
|
||||||
|
def test_marker_survives_restart_before_schema_initialization(self):
|
||||||
|
new_path = os.path.join(self.temp.name, "interrupted.db")
|
||||||
|
with patch.object(settings, "sqlite_path", new_path):
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
db.init_db()
|
||||||
|
self.assertTrue(setup.is_setup_required())
|
||||||
|
|
||||||
|
def test_empty_precreated_database_is_a_fresh_install(self):
|
||||||
|
new_path = os.path.join(self.temp.name, "empty.db")
|
||||||
|
with open(new_path, "wb"):
|
||||||
|
pass
|
||||||
|
with patch.object(settings, "sqlite_path", new_path):
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
db.init_db()
|
||||||
|
self.assertTrue(setup.is_setup_required())
|
||||||
|
|
||||||
|
def test_environment_admin_uses_wizard_without_public_bootstrap(self):
|
||||||
|
with patch.object(settings, "admin_password", ADMIN_PASSWORD):
|
||||||
|
db.ensure_admin_user()
|
||||||
|
self.assertEqual(setup.get_public_setup_status(), {"setup_required": True, "needs_admin": False})
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 409)
|
||||||
|
|
||||||
|
def test_valid_token_creates_local_admin_once_and_uses_password_hash(self):
|
||||||
|
response = self.bootstrap()
|
||||||
|
self.assertEqual(response.status_code, 201, response.text)
|
||||||
|
self.assertEqual(response.json(), {"status": "created", "username": "first-admin"})
|
||||||
|
user = db.verify_user_password("first-admin", ADMIN_PASSWORD)
|
||||||
|
self.assertIsNotNone(user)
|
||||||
|
self.assertEqual(user["role"], "admin")
|
||||||
|
self.assertEqual(user["auth_provider"], "local")
|
||||||
|
self.assertNotEqual(user["password_hash"], ADMIN_PASSWORD)
|
||||||
|
self.assertEqual(setup.get_setup_state()["step"], "apps")
|
||||||
|
self.assertEqual(self.bootstrap(username="second-admin").status_code, 409)
|
||||||
|
self.assertEqual(len(db.get_all_users()), 1)
|
||||||
|
|
||||||
|
def test_invalid_and_missing_operator_tokens_never_create_admin(self):
|
||||||
|
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
|
||||||
|
with patch.object(setup.settings, "setup_token", ""):
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 403)
|
||||||
|
with patch.object(setup.settings, "setup_token", "too-short"):
|
||||||
|
self.assertEqual(self.bootstrap(setup_token="too-short").status_code, 403)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
|
||||||
|
def test_non_ascii_token_fails_cleanly(self):
|
||||||
|
self.assertEqual(self.bootstrap(setup_token="invalid-\N{SNOWMAN}").status_code, 403)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
|
||||||
|
def test_example_and_repeated_character_setup_tokens_are_rejected(self):
|
||||||
|
for token in (
|
||||||
|
"replace-with-a-separate-random-setup-token",
|
||||||
|
"CHANGE_ME_before_starting_this_installation",
|
||||||
|
"your-setup-token-goes-here-at-least-32-characters",
|
||||||
|
"a" * 64,
|
||||||
|
"0" * 64,
|
||||||
|
" " * 64,
|
||||||
|
):
|
||||||
|
with self.subTest(token=token), patch.object(setup.settings, "setup_token", token):
|
||||||
|
self.assertFalse(setup.setup_token_configured())
|
||||||
|
with self.assertRaises(setup.InvalidSetupTokenError):
|
||||||
|
setup.bootstrap_administrator(token, "owner", ADMIN_PASSWORD)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
self.assertTrue(setup.setup_token_configured())
|
||||||
|
|
||||||
|
def test_password_policy_and_username_validation(self):
|
||||||
|
for username in (" ", "admin user", "admin\x7f", "admin\nname"):
|
||||||
|
with self.subTest(username=repr(username)):
|
||||||
|
self.assertEqual(self.bootstrap(username=username).status_code, 400)
|
||||||
|
self.assertEqual(self.bootstrap(password="short").status_code, 400)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
|
||||||
|
def test_oversized_fields_and_unexpected_privileges_are_rejected(self):
|
||||||
|
self.assertEqual(self.bootstrap(password="x" * 1025).status_code, 422)
|
||||||
|
self.assertEqual(self.bootstrap(username="x" * 101).status_code, 422)
|
||||||
|
self.assertEqual(self.bootstrap(role="admin").status_code, 422)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
|
||||||
|
def test_existing_normalized_username_is_not_replaced(self):
|
||||||
|
db.create_user("Taken", ADMIN_PASSWORD)
|
||||||
|
self.assertEqual(self.bootstrap(username="taken").status_code, 409)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
self.assertEqual(len(db.get_all_users()), 1)
|
||||||
|
|
||||||
|
def test_bootstrap_attempts_are_persistently_limited(self):
|
||||||
|
for _ in range(setup.BOOTSTRAP_IP_ATTEMPTS):
|
||||||
|
self.assertEqual(self.bootstrap(setup_token="incorrect").status_code, 403)
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
response = self.bootstrap()
|
||||||
|
self.assertEqual(response.status_code, 429)
|
||||||
|
self.assertGreater(int(response.headers["retry-after"]), 0)
|
||||||
|
self.assertFalse(db.has_admin_user())
|
||||||
|
with db._connect() as conn:
|
||||||
|
keys = [row[0] for row in conn.execute("SELECT key_hash FROM installation_setup_attempts")]
|
||||||
|
self.assertNotIn("testclient", keys)
|
||||||
|
|
||||||
|
def test_rate_limit_global_cap_and_expiry(self):
|
||||||
|
with patch.object(setup, "time", return_value=1000):
|
||||||
|
for number in range(setup.BOOTSTRAP_GLOBAL_ATTEMPTS):
|
||||||
|
self.assertIsNone(setup.consume_bootstrap_attempt(f"192.0.2.{number}"))
|
||||||
|
self.assertEqual(setup.consume_bootstrap_attempt("198.51.100.1"), 900)
|
||||||
|
with patch.object(setup, "time", return_value=1901):
|
||||||
|
self.assertIsNone(setup.consume_bootstrap_attempt("198.51.100.1"))
|
||||||
|
|
||||||
|
def test_concurrent_attempts_cannot_bypass_rate_limit(self):
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
|
results = list(executor.map(lambda _: setup.consume_bootstrap_attempt("192.0.2.1"), range(12)))
|
||||||
|
self.assertEqual(results.count(None), setup.BOOTSTRAP_IP_ATTEMPTS)
|
||||||
|
|
||||||
|
def test_concurrent_bootstraps_create_only_one_admin(self):
|
||||||
|
barrier = Barrier(4)
|
||||||
|
|
||||||
|
def synchronized_hash(_):
|
||||||
|
barrier.wait(timeout=10)
|
||||||
|
return "test-only-precomputed-hash"
|
||||||
|
|
||||||
|
def create(number):
|
||||||
|
try:
|
||||||
|
setup.bootstrap_administrator(SETUP_TOKEN, f"admin-{number}", ADMIN_PASSWORD)
|
||||||
|
return True
|
||||||
|
except setup.SetupUnavailableError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with patch.object(setup, "hash_password", side_effect=synchronized_hash):
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
results = list(executor.map(create, range(4)))
|
||||||
|
self.assertEqual(results.count(True), 1)
|
||||||
|
self.assertEqual(len(db.get_all_users()), 1)
|
||||||
|
|
||||||
|
def test_state_mutations_require_admin_and_progress_resumes(self):
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 201)
|
||||||
|
db.create_user("viewer", ADMIN_PASSWORD)
|
||||||
|
user_headers = {"Authorization": f"Bearer {create_access_token('viewer', 'user')}"}
|
||||||
|
for path, method, kwargs in (
|
||||||
|
("/setup/state", "get", {}),
|
||||||
|
("/setup/state", "put", {"json": {"step": "review"}}),
|
||||||
|
("/setup/complete", "post", {}),
|
||||||
|
):
|
||||||
|
with self.subTest(path=path, method=method):
|
||||||
|
call = getattr(self.client, method)
|
||||||
|
self.assertEqual(call(path, **kwargs).status_code, 401)
|
||||||
|
self.assertEqual(call(path, headers=user_headers, **kwargs).status_code, 403)
|
||||||
|
response = self.client.put("/setup/state", json={"step": "preferences"}, headers=self.admin_headers())
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
setup.initialize_setup_state()
|
||||||
|
db.init_db()
|
||||||
|
self.assertEqual(setup.get_setup_state()["step"], "preferences")
|
||||||
|
self.assertTrue(setup.is_setup_required())
|
||||||
|
self.assertEqual(self.client.put(
|
||||||
|
"/setup/state", json={"step": "invalid"}, headers=self.admin_headers()
|
||||||
|
).status_code, 422)
|
||||||
|
|
||||||
|
def test_completion_invokes_worker_callback_and_cannot_reopen_bootstrap(self):
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 201)
|
||||||
|
callback = AsyncMock()
|
||||||
|
self.app.state.on_setup_complete = callback
|
||||||
|
response = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
self.assertTrue(response.json()["completed"])
|
||||||
|
self.assertIsNotNone(response.json()["completed_at"])
|
||||||
|
callback.assert_awaited_once()
|
||||||
|
self.assertFalse(setup.is_setup_required())
|
||||||
|
# A retry can restart an idempotent callback if the first response was
|
||||||
|
# interrupted, while keeping the original completion timestamp.
|
||||||
|
retry = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||||
|
self.assertEqual(retry.json(), response.json())
|
||||||
|
self.assertEqual(callback.await_count, 2)
|
||||||
|
self.client.put("/setup/state", json={"step": "administrator"}, headers=self.admin_headers())
|
||||||
|
with db._connect() as conn:
|
||||||
|
conn.execute("DELETE FROM users")
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 409)
|
||||||
|
self.assertEqual(setup.get_setup_state()["step"], "review")
|
||||||
|
|
||||||
|
def test_completion_requires_an_administrator(self):
|
||||||
|
with self.assertRaises(setup.SetupUnavailableError):
|
||||||
|
setup.complete_setup()
|
||||||
|
self.assertTrue(setup.is_setup_required())
|
||||||
|
|
||||||
|
def test_sync_callback_is_supported(self):
|
||||||
|
self.assertEqual(self.bootstrap().status_code, 201)
|
||||||
|
called = []
|
||||||
|
self.app.state.on_setup_complete = lambda: called.append(True)
|
||||||
|
response = self.client.post("/setup/complete", headers=self.admin_headers())
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(called, [True])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
AUTH_COOKIE_NAME: magent_beta_auth
|
AUTH_COOKIE_NAME: magent_beta_auth
|
||||||
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
||||||
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
||||||
|
AUTH_COOKIE_SECURE: "true"
|
||||||
|
AUTH_COOKIE_SAMESITE: strict
|
||||||
SQLITE_PATH: /app/data/magent.db
|
SQLITE_PATH: /app/data/magent.db
|
||||||
LOG_FILE: /app/data/magent.log
|
LOG_FILE: /app/data/magent.log
|
||||||
SITE_BANNER_ENABLED: "true"
|
SITE_BANNER_ENABLED: "true"
|
||||||
@@ -26,3 +28,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
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
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ services:
|
|||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
read_only: true
|
||||||
|
cap_drop: ["ALL"]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
init: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
|
||||||
|
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
|
||||||
|
|||||||
@@ -5,9 +5,19 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
environment:
|
||||||
|
AUTH_COOKIE_SECURE: "true"
|
||||||
|
AUTH_COOKIE_SAMESITE: strict
|
||||||
ports:
|
ports:
|
||||||
- "10.30.1.32:3200:3000"
|
- "10.30.1.32:3200:3000"
|
||||||
- "127.0.0.1:8200:8000"
|
- "127.0.0.1:8200:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
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
|
||||||
|
|||||||
+8
-1
@@ -7,6 +7,13 @@ services:
|
|||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./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
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Duplicate account repair
|
# Duplicate account repair
|
||||||
|
|
||||||
Open **Configuration → User management → Account links & repairs**, run **Check all user IDs**, then choose **Repair duplicate accounts** on a same-name conflict. An individual user's management overlay also links to this view with their username prefilled.
|
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.
|
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 account for the normalized name, one Seerr account mapped to that Jellyfin ID, and the same ID verified in Jellystat. Every member must be a non-admin Jellyfin 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.
|
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:
|
The transaction:
|
||||||
|
|
||||||
@@ -15,8 +15,10 @@ The transaction:
|
|||||||
- 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.
|
- 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.
|
- 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 automatic bulk merge or self-service undo. The archive supports administrative investigation; unrelated or renamed identities require separate review.
|
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.
|
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.
|
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,63 @@
|
|||||||
|
# Installation, backup and recovery
|
||||||
|
|
||||||
|
## Fresh installation
|
||||||
|
|
||||||
|
Start with `.env.example`. Generate independent random values for `JWT_SECRET` and `SETUP_TOKEN` (at least 32 characters each), plus a Fernet `SETTINGS_ENCRYPTION_KEY`. Never deploy the example placeholders. Keep the environment file private.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first two commands produce the JWT secret and setup token respectively. The third requires the backend dependencies. Alternatively generate the Fernet key using Python's standard library: `python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"`.
|
||||||
|
|
||||||
|
Set the correct browser-facing `CORS_ALLOW_ORIGIN`, `MAGENT_APPLICATION_URL`, cookie HTTPS settings and host paths before starting. These deployment settings are deliberately not editable through public setup. Changing the public application URL in the wizard does not change CORS or reverse-proxy configuration.
|
||||||
|
|
||||||
|
After `docker compose up -d --build`, visit the frontend. A new database redirects to `/setup`:
|
||||||
|
|
||||||
|
1. Enter `SETUP_TOKEN` and create a local administrator with a unique password of at least 12 characters. Alternatively, set `ADMIN_USERNAME` and `ADMIN_PASSWORD` in the environment before the first start, then sign in with that account.
|
||||||
|
2. Expand each app you use: Jellyfin, Seerr/Jellyseerr, Sonarr, Radarr, Prowlarr, qBittorrent, Bazarr and Jellystat. Enter its internal address and credentials, then **Save & test**. For Sonarr/Radarr, a successful check loads quality profiles and root folders.
|
||||||
|
3. Set site access, request refresh/retention and optional SMTP preferences. Invite signup remains invite-only.
|
||||||
|
4. Review and finish. Magent starts its configured background jobs, unless `BACKGROUND_TASKS_ENABLED=false`.
|
||||||
|
|
||||||
|
Use server-reachable addresses: `localhost` in a container refers to that container. Optional apps can be skipped. Each successful save persists; closing the tab leaves setup resumable. Unsaved form fields are not retained. Remove `SETUP_TOKEN` after finishing. Bootstrap is permanently disabled after setup, and cannot replace an existing administrator. Administrators can revisit the wizard from Settings without resetting the installation.
|
||||||
|
|
||||||
|
Upgrades with an existing users table are marked configured automatically. Setup status reveals only whether setup is needed and whether the first administrator is missing. Configuration and wizard progress require administrator authentication. First-admin creation uses a constant-time token comparison, persistent rate limits and a database transaction to prevent concurrent claims.
|
||||||
|
|
||||||
|
## Create a backup
|
||||||
|
|
||||||
|
Open **Settings → Advanced tools → Backup & restore** (`/admin/backups`). Choose a unique backup passphrase of 12–1024 characters, confirm it, optionally include the filesystem artwork cache, and download the `.magent-backup` file.
|
||||||
|
|
||||||
|
Every backup includes:
|
||||||
|
|
||||||
|
- A consistent SQLite snapshot: users, password hashes, invite records, requests, issues, settings, saved statistics, subscriptions and database-backed caches.
|
||||||
|
- Portable runtime configuration, including environment-provided app credentials. Secrets are decrypted only inside the private export staging area and encrypted archive; they are re-encrypted with the destination installation key when restoring.
|
||||||
|
- Custom branding (`data/branding/logo.png` and `favicon.ico`).
|
||||||
|
|
||||||
|
The optional cache adds supported TMDB artwork from `data/artwork/tmdb`. In-memory caches are rebuilt, not backed up. Media files, the connected apps' databases, log files, `.env`, TLS private keys, host paths, signing/encryption keys and deployment/network controls are not included. Keep a separate secure record of the deployment configuration and backup passphrase.
|
||||||
|
|
||||||
|
Backups use authenticated AES-256-GCM encryption with a per-backup salt and scrypt-derived key. The passphrase is never stored by Magent and cannot be recovered. Keep backups and their passphrases separately, off the Magent host. Treat backups as sensitive even though encrypted.
|
||||||
|
|
||||||
|
Current limits: **32 MiB encrypted archive**, **128 MiB expanded data**, and **20,000 entries**. These bound memory and disk use; including a large artwork cache can exceed them. Retry without artwork if necessary. For larger installations, use a separate operator-managed offline volume/database backup; this UI does not silently omit oversized data. Automatic scheduled backups and media-server backups are not part of this feature.
|
||||||
|
|
||||||
|
The frontend and backend accept up to 34 MiB for the whole multipart request, including the 32 MiB file. Configure any external reverse proxy's upload limit accordingly (for example `client_max_body_size 34m` in nginx); otherwise it may reject valid files before they reach Magent.
|
||||||
|
|
||||||
|
## Restore safely
|
||||||
|
|
||||||
|
1. Make a fresh backup of the destination. Stop external writes/other backend processes sharing its SQLite file. The supplied deployment uses one backend worker; do not run restore against a multi-worker/shared-database deployment.
|
||||||
|
2. Sign in as an administrator, select a `.magent-backup`, enter its passphrase and type `RESTORE`. A fresh replacement installation must first create its temporary administrator through `/setup`; then use the **Restore it here** link before connecting apps.
|
||||||
|
3. Upload and stage the restore. Magent checks authentication, encrypted integrity, archive paths and sizes, checksums, SQLite integrity, schema compatibility and an active restored administrator. Live data is unchanged at this point. A pending restore can be cancelled from the same page.
|
||||||
|
4. Restart the application using your normal deployment process, for example `docker compose restart magent`. Beta: `docker compose -p magent-beta -f docker-compose.beta.yml restart magent`. The UI never restarts a server automatically.
|
||||||
|
5. On startup, before schema initialization or workers, Magent creates a private rollback copy, replaces the database/selected assets and records the result. Failed or interrupted replacement is rolled back using a durable journal. Review the backend logs if startup stops.
|
||||||
|
6. Sign in with an account from the restored backup, verify Settings/service checks, requests, issues and invite policy, then create a new backup. Old sessions and password-reset tokens are invalidated. Existing invite records and links are retained, with their original expiry and usage state.
|
||||||
|
|
||||||
|
Restore **replaces** the destination database; it does not merge changes made after the backup. After staging, pause normal usage until the restart so new writes are not mistaken for restored data. Do not change the destination encryption key between staging and restart. Restoring earlier invite state can also restore its remaining uses: review active invitations after recovery.
|
||||||
|
|
||||||
|
Use the same Magent version for restore, then upgrade normally. Portable settings follow the backup, but destination host identity, JWT/encryption keys, local paths, TLS/cookie/proxy controls and ports remain destination-owned. Review public URLs and service addresses when moving hosts. Without the optional artwork cache, database artwork flags are reset and missing artwork can be fetched again; the existing destination artwork directory is left in place.
|
||||||
|
|
||||||
|
## Recovery files
|
||||||
|
|
||||||
|
The `backups/` directory beside the configured SQLite database contains private staging, lock/journal/status files and `rollback-<id>/` copies. It is not a library of exported encrypted downloads. Rollback copies contain the old database and assets; protect the data volume with host encryption and restrictive access. Magent does not automatically delete rollback copies after success. After validating the restored installation and saving a separate backup, an operator may archive or remove the specific old rollback directories during maintenance. Never remove an active `pending/` directory or `restore-journal.json` during a restore.
|
||||||
|
|
||||||
|
Insufficient disk space or invalid input stops the operation rather than partially accepting a backup. Allow room for the upload, extracted staging database/assets, live data and a rollback copy. The supplied Docker image's unprivileged user must have write access to the persistent data volume. Do not delete the data volume or replace `.env` to retry setup or recovery.
|
||||||
@@ -63,7 +63,7 @@ Open **My Stats → Monthly reports** (`/insights/reports`). The default is the
|
|||||||
|
|
||||||
New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription.
|
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 public Magent address, monthly schedule, personal preview, test emails and delivery history. 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.
|
**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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-1
@@ -9,7 +9,7 @@ The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects t
|
|||||||
## Setup and subscriptions
|
## 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.
|
- 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.
|
||||||
- Save this environment's public Magent address in Weekly schedule. On first migration it inherits the monthly recap address, if configured. No schedule or subscriptions are enabled by migration.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ Users can leave the normal request settings or explicitly accept original-langua
|
|||||||
|
|
||||||
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.
|
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 are not silently modified by selecting an already-requested search result.
|
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.
|
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).
|
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.
|
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.
|
||||||
|
|||||||
+203
-223
@@ -1,269 +1,229 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import PageHeading from './ui/PageHeading'
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from 'next/navigation'
|
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth";
|
||||||
import { useEffect, useState } from 'react'
|
import {
|
||||||
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
|
normalizeRecentResults,
|
||||||
|
normalizeSearchResults,
|
||||||
const normalizeRecentResults = (items: any[]) =>
|
type RecentRequest,
|
||||||
items
|
type RequestSearchResult,
|
||||||
.filter((item: any) => item?.id)
|
} from "./lib/request-results";
|
||||||
.map((item: any) => {
|
import { useEffectiveRole } from "./lib/viewMode";
|
||||||
const id = item.id
|
import PageHeading from "./ui/PageHeading";
|
||||||
const rawTitle = item.title
|
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
|
||||||
const placeholder =
|
|
||||||
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
|
||||||
year: item.year,
|
|
||||||
type: item.type,
|
|
||||||
statusLabel: item.statusLabel,
|
|
||||||
artwork: item.artwork,
|
|
||||||
createdAt: item.createdAt ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const REQUEST_STAGE_OPTIONS = [
|
|
||||||
{ value: 'all', label: 'All stages' },
|
|
||||||
{ value: 'pending', label: 'Waiting' },
|
|
||||||
{ value: 'approved', label: 'Approved' },
|
|
||||||
{ value: 'in_progress', label: 'In progress' },
|
|
||||||
{ value: 'working', label: 'Working' },
|
|
||||||
{ value: 'partial', label: 'Partial' },
|
|
||||||
{ value: 'ready', label: 'Ready' },
|
|
||||||
{ value: 'declined', label: 'Declined' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState("");
|
||||||
const [recent, setRecent] = useState<
|
const [recent, setRecent] = useState<RecentRequest[]>([]);
|
||||||
{
|
const [recentError, setRecentError] = useState<string | null>(null);
|
||||||
id: number
|
const [recentLoading, setRecentLoading] = useState(false);
|
||||||
title: string
|
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
|
||||||
year?: number
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
type?: string
|
const [role, setRole] = useState<string | null>(null);
|
||||||
statusLabel?: string
|
const effectiveRole = useEffectiveRole(role);
|
||||||
artwork?: { poster_url?: string; backdrop_url?: string }
|
const isAdmin = effectiveRole === "admin";
|
||||||
createdAt?: string | null
|
const [recentDays, setRecentDays] = useState(90);
|
||||||
}[]
|
const [recentStage, setRecentStage] = useState<RequestStage>("all");
|
||||||
>([])
|
const [authReady, setAuthReady] = useState(false);
|
||||||
const [recentError, setRecentError] = useState<string | null>(null)
|
|
||||||
const [recentLoading, setRecentLoading] = useState(false)
|
|
||||||
const [searchResults, setSearchResults] = useState<
|
|
||||||
{
|
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
type?: string
|
|
||||||
requestId?: number
|
|
||||||
statusLabel?: string
|
|
||||||
requestedBy?: string | null
|
|
||||||
accessible?: boolean
|
|
||||||
}[]
|
|
||||||
>([])
|
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
|
||||||
const [role, setRole] = useState<string | null>(null)
|
|
||||||
const [recentDays, setRecentDays] = useState(90)
|
|
||||||
const [recentStage, setRecentStage] = useState('all')
|
|
||||||
const [authReady, setAuthReady] = useState(false)
|
|
||||||
|
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return
|
if (!trimmed) return;
|
||||||
if (/^\d+$/.test(trimmed)) {
|
if (/^\d+$/.test(trimmed)) {
|
||||||
router.push(`/requests/${encodeURIComponent(trimmed)}`)
|
router.push(`/requests/${encodeURIComponent(trimmed)}`);
|
||||||
return
|
return;
|
||||||
}
|
|
||||||
void runSearch(trimmed)
|
|
||||||
}
|
}
|
||||||
|
void runSearch(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push("/login");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setRecentLoading(true)
|
setRecentLoading(true);
|
||||||
setRecentError(null)
|
setRecentError(null);
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase();
|
||||||
const meResponse = await authFetch(`${baseUrl}/auth/me`)
|
const meResponse = await authFetch(`${baseUrl}/auth/me`);
|
||||||
if (!meResponse.ok) {
|
if (!meResponse.ok) {
|
||||||
if (meResponse.status === 401) {
|
if (meResponse.status === 401) {
|
||||||
clearToken()
|
clearToken();
|
||||||
router.push('/login')
|
router.push("/login");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Auth failed: ${meResponse.status}`)
|
throw new Error(`Auth failed: ${meResponse.status}`);
|
||||||
}
|
}
|
||||||
const me = await meResponse.json()
|
const me = await meResponse.json();
|
||||||
const userRole = me?.role ?? null
|
if (cancelled) return;
|
||||||
setRole(userRole)
|
const userRole = me?.role ?? null;
|
||||||
setAuthReady(true)
|
setRole(userRole);
|
||||||
const take = userRole === 'admin' ? 50 : 6
|
setAuthReady(true);
|
||||||
|
const take = isAdmin ? 50 : 6;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
take: String(take),
|
take: String(take),
|
||||||
days: String(recentDays),
|
days: String(recentDays),
|
||||||
})
|
});
|
||||||
if (recentStage !== 'all') {
|
if (recentStage !== "all") {
|
||||||
params.set('stage', recentStage)
|
params.set("stage", recentStage);
|
||||||
}
|
}
|
||||||
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
|
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken();
|
||||||
router.push('/login')
|
router.push("/login");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Recent requests failed: ${response.status}`)
|
throw new Error(`Recent requests failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
|
if (cancelled) return;
|
||||||
if (Array.isArray(data?.results)) {
|
if (Array.isArray(data?.results)) {
|
||||||
setRecent(normalizeRecentResults(data.results))
|
setRecent(normalizeRecentResults(data.results));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error);
|
||||||
setRecentError('Recent requests are not available right now.')
|
if (!cancelled) setRecentError("Recent requests are not available right now.");
|
||||||
} finally {
|
} finally {
|
||||||
setRecentLoading(false)
|
if (!cancelled) setRecentLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
load()
|
void load();
|
||||||
}, [recentDays, recentStage])
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isAdmin, recentDays, recentStage, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authReady) {
|
if (!authReady) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase();
|
||||||
let closed = false
|
let closed = false;
|
||||||
let source: EventSource | null = null
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
try {
|
try {
|
||||||
const streamToken = await getEventStreamToken()
|
const streamToken = await getEventStreamToken();
|
||||||
if (closed) return
|
if (closed) return;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
stream_token: streamToken,
|
stream_token: streamToken,
|
||||||
recent_days: String(recentDays),
|
recent_days: String(recentDays),
|
||||||
})
|
});
|
||||||
if (recentStage !== 'all') {
|
if (recentStage !== "all") {
|
||||||
params.set('recent_stage', recentStage)
|
params.set("recent_stage", recentStage);
|
||||||
}
|
}
|
||||||
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
|
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`;
|
||||||
source = new EventSource(streamUrl)
|
source = new EventSource(streamUrl);
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
if (closed) return
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const payload = JSON.parse(event.data);
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== "object") {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_recent') {
|
if (payload.type === "home_recent") {
|
||||||
if (Array.isArray(payload.results)) {
|
if (Array.isArray(payload.results)) {
|
||||||
setRecent(normalizeRecentResults(payload.results))
|
setRecent(normalizeRecentResults(payload.results));
|
||||||
setRecentError(null)
|
setRecentError(null);
|
||||||
setRecentLoading(false)
|
setRecentLoading(false);
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
} else if (typeof payload.error === "string" && payload.error.trim()) {
|
||||||
setRecentError('Recent requests are not available right now.')
|
setRecentError("Recent requests are not available right now.");
|
||||||
setRecentLoading(false)
|
setRecentLoading(false);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (closed) return
|
if (closed) return;
|
||||||
console.error(error)
|
console.error(error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
void connect()
|
void connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true
|
closed = true;
|
||||||
source?.close()
|
source?.close();
|
||||||
}
|
};
|
||||||
}, [authReady, recentDays, recentStage])
|
}, [authReady, recentDays, recentStage]);
|
||||||
|
|
||||||
const runSearch = async (term: string) => {
|
const runSearch = async (term: string) => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase();
|
||||||
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
|
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken()
|
clearToken();
|
||||||
router.push('/login')
|
router.push("/login");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Search failed: ${response.status}`)
|
throw new Error(`Search failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
if (Array.isArray(data?.results)) {
|
if (Array.isArray(data?.results)) {
|
||||||
setSearchResults(
|
setSearchResults(normalizeSearchResults(data.results));
|
||||||
data.results.map((item: any) => ({
|
setSearchError(null);
|
||||||
title: item.title,
|
|
||||||
year: item.year,
|
|
||||||
type: item.type,
|
|
||||||
requestId: item.requestId,
|
|
||||||
statusLabel: item.statusLabel,
|
|
||||||
requestedBy: item.requestedBy ?? null,
|
|
||||||
accessible: Boolean(item.accessible),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
setSearchError(null)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error);
|
||||||
setSearchError('Search failed. Try a request ID instead.')
|
setSearchError("Search failed. Try a request ID instead.");
|
||||||
setSearchResults([])
|
setSearchResults([]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resolveArtworkUrl = (url?: string | null) => {
|
const resolveArtworkUrl = (url?: string | null) => {
|
||||||
if (!url) return null
|
if (!url) return null;
|
||||||
return url.startsWith('http') ? url : `${getApiBase()}${url}`
|
return url.startsWith("http") ? url : `${getApiBase()}${url}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatRequestTime = (value?: string | null) => {
|
const formatRequestTime = (value?: string | null) => {
|
||||||
if (!value) return null
|
if (!value) return null;
|
||||||
const date = new Date(value)
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.valueOf())) return value
|
if (Number.isNaN(date.valueOf())) return value;
|
||||||
return date.toLocaleString()
|
return date.toLocaleString();
|
||||||
}
|
};
|
||||||
|
|
||||||
const activeRecentCount = recent.filter((item) => {
|
const activeRecentCount = recent.filter((item) => {
|
||||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
const label = String(item.statusLabel ?? "").toLowerCase();
|
||||||
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
|
return !label.includes("ready") && !label.includes("available") && !label.includes("declined");
|
||||||
}).length
|
}).length;
|
||||||
const readyRecentCount = recent.filter((item) => {
|
const readyRecentCount = recent.filter((item) => {
|
||||||
const label = String(item.statusLabel ?? '').toLowerCase()
|
const label = String(item.statusLabel ?? "").toLowerCase();
|
||||||
return label.includes('ready') || label.includes('available')
|
return label.includes("ready") || label.includes("available");
|
||||||
}).length
|
}).length;
|
||||||
|
|
||||||
const requestCardState = (value?: string) => {
|
const requestCardState = (value?: string) => {
|
||||||
const label = String(value ?? '').toLowerCase()
|
const label = String(value ?? "").toLowerCase();
|
||||||
if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 }
|
if (label.includes("partial")) return { key: "attention", label: value || "Partially ready", progress: 65 };
|
||||||
if (!/not |unavailable|waiting/.test(label) && (label.includes('ready') || label.includes('available'))) return { key: 'ready', label: value || 'Ready', progress: 100 }
|
if (!/not |unavailable|waiting/.test(label) && (label.includes("ready") || label.includes("available")))
|
||||||
if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 }
|
return { key: "ready", label: value || "Ready", progress: 100 };
|
||||||
if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 }
|
if (label.includes("declined") || label.includes("failed") || label.includes("error"))
|
||||||
return { key: 'waiting', label: value || 'Waiting', progress: 4 }
|
return { key: "attention", label: value || "Needs attention", progress: 12 };
|
||||||
}
|
if (label.includes("working") || label.includes("progress") || label.includes("download"))
|
||||||
|
return { key: "processing", label: value || "In progress", progress: 58 };
|
||||||
|
return { key: "waiting", label: value || "Waiting", progress: 4 };
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card home-page">
|
<main className="card home-page">
|
||||||
<PageHeading title="My requests" description="Follow your requests from collection to ready to watch." actions={
|
<PageHeading
|
||||||
|
title="My requests"
|
||||||
|
description="Follow your requests from collection to ready to watch."
|
||||||
|
actions={
|
||||||
<form onSubmit={submit} className="home-search">
|
<form onSubmit={submit} className="home-search">
|
||||||
<label htmlFor="request-search">Title, year, or request number</label>
|
<label htmlFor="request-search">Title, year, or request number</label>
|
||||||
<div className="home-search-row">
|
<div className="home-search-row">
|
||||||
@@ -276,19 +236,28 @@ export default function HomePage() {
|
|||||||
<button type="submit">Find request</button>
|
<button type="submit">Find request</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
} />
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{(searchError || searchResults.length > 0) && (
|
{(searchError || searchResults.length > 0) && (
|
||||||
<section className="home-search-results" aria-live="polite">
|
<section className="home-search-results" aria-live="polite">
|
||||||
<div className="home-section-heading">
|
<div className="home-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-kicker">Search results</span>
|
<span className="section-kicker">Search results</span>
|
||||||
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
|
<h2>
|
||||||
|
{searchError
|
||||||
|
? "Search unavailable"
|
||||||
|
: `${searchResults.length} match${searchResults.length === 1 ? "" : "es"} found`}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={() => {
|
<button
|
||||||
setSearchResults([])
|
type="button"
|
||||||
setSearchError(null)
|
className="ghost-button"
|
||||||
}}>
|
onClick={() => {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,17 +267,20 @@ export default function HomePage() {
|
|||||||
<div className="home-result-grid">
|
<div className="home-result-grid">
|
||||||
{searchResults.map((item, index) => (
|
{searchResults.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
key={`${item.title || 'Untitled'}-${index}`}
|
key={`${item.title || "Untitled"}-${index}`}
|
||||||
type="button"
|
type="button"
|
||||||
className="home-result-card"
|
className="home-result-card"
|
||||||
disabled={!item.requestId}
|
disabled={!item.requestId}
|
||||||
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
|
<strong>
|
||||||
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
|
{item.title || "Untitled"}
|
||||||
|
{item.year ? ` (${item.year})` : ""}
|
||||||
|
</strong>
|
||||||
|
<small>{item.type?.toUpperCase() || "MEDIA"}</small>
|
||||||
</span>
|
</span>
|
||||||
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
|
<span>{!item.requestId ? "Not requested" : item.statusLabel || "Already requested"}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,16 +289,25 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="home-metric-strip" aria-label="Request summary">
|
<section className="home-metric-strip" aria-label="Request summary">
|
||||||
<div><span>In view</span><strong>{recent.length}</strong></div>
|
<div>
|
||||||
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
|
<span>In view</span>
|
||||||
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
|
<strong>{recent.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>In progress</span>
|
||||||
|
<strong>{activeRecentCount}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Ready</span>
|
||||||
|
<strong>{readyRecentCount}</strong>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="recent home-recent">
|
<section className="recent home-recent">
|
||||||
<div className="recent-header home-section-heading">
|
<div className="recent-header home-section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-kicker">Request activity</span>
|
<span className="section-kicker">Request activity</span>
|
||||||
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
|
<h2>{isAdmin ? "Recent requests" : "My recent requests"}</h2>
|
||||||
</div>
|
</div>
|
||||||
{authReady && (
|
{authReady && (
|
||||||
<div className="recent-filter-group">
|
<div className="recent-filter-group">
|
||||||
@@ -343,21 +324,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{authReady && (
|
{authReady && <RequestStageFilter value={recentStage} onChange={setRecentStage} />}
|
||||||
<div className="request-filter-chips" aria-label="Filter requests by stage">
|
|
||||||
{REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={option.value}
|
|
||||||
className={recentStage === option.value ? 'is-active' : undefined}
|
|
||||||
onClick={() => setRecentStage(option.value)}
|
|
||||||
>
|
|
||||||
{option.value === 'working' ? <i aria-hidden="true" /> : null}
|
|
||||||
{option.value === 'all' ? 'All' : option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="recent-grid home-recent-grid">
|
<div className="recent-grid home-recent-grid">
|
||||||
{recentLoading ? (
|
{recentLoading ? (
|
||||||
<div className="loading-center">
|
<div className="loading-center">
|
||||||
@@ -372,7 +339,7 @@ export default function HomePage() {
|
|||||||
<span>Try a wider period or a different stage.</span>
|
<span>Try a wider period or a different stage.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
recent.map((item) => (
|
(isAdmin ? recent : recent.slice(0, 6)).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -382,30 +349,43 @@ export default function HomePage() {
|
|||||||
{item.artwork?.poster_url ? (
|
{item.artwork?.poster_url ? (
|
||||||
<img
|
<img
|
||||||
className="recent-poster"
|
className="recent-poster"
|
||||||
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
src={resolveArtworkUrl(item.artwork.poster_url) ?? ""}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
|
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">
|
||||||
|
#{item.id}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="recent-info">
|
<span className="recent-info">
|
||||||
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
|
<span className="recent-title">
|
||||||
|
{item.title || "Untitled"}
|
||||||
|
{item.year ? ` (${item.year})` : ""}
|
||||||
|
</span>
|
||||||
<span className="recent-status-badge">
|
<span className="recent-status-badge">
|
||||||
<span aria-hidden="true">{({ ready: '✓', processing: '↻', attention: '!', waiting: '◷' })[requestCardState(item.statusLabel).key]}</span>
|
<span aria-hidden="true">
|
||||||
{item.statusLabel || 'Status not available yet'}
|
{
|
||||||
|
{ ready: "✓", processing: "↻", attention: "!", waiting: "◷" }[
|
||||||
|
requestCardState(item.statusLabel).key
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{item.statusLabel || "Status not available yet"}
|
||||||
</span>
|
</span>
|
||||||
<span className="recent-meta">
|
<span className="recent-meta">
|
||||||
Request {item.id}
|
Request {item.id}
|
||||||
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
|
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="recent-open-cue" aria-hidden="true">Open</span>
|
<span className="recent-open-cue" aria-hidden="true">
|
||||||
|
Open
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px sol
|
|||||||
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
|
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
|
||||||
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
|
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
|
||||||
|
.account-login-message { color: #ded8ed; border-color: #514a60; background: #26222d; white-space: pre-line; }
|
||||||
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
|
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
|
||||||
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
|
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
|
||||||
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
|
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
|
||||||
|
|||||||
@@ -1,72 +1,162 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
|
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean };
|
||||||
type Option = { value: string; label: string }
|
type Option = { value: string; label: string };
|
||||||
type Props = {
|
type Props = {
|
||||||
setting: AdminSetting
|
setting: AdminSetting;
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
help?: string
|
help?: string;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
boolean?: boolean
|
boolean?: boolean;
|
||||||
numeric?: boolean
|
numeric?: boolean;
|
||||||
multiline?: boolean
|
multiline?: boolean;
|
||||||
options?: Option[]
|
options?: Option[];
|
||||||
optionsUnavailable?: boolean
|
optionsUnavailable?: boolean;
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const SELECTS: Record<string, Option[]> = {
|
const SELECTS: Record<string, Option[]> = {
|
||||||
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })),
|
log_level: ["DEBUG", "INFO", "WARNING", "ERROR"].map((value) => ({ value, label: value })),
|
||||||
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({ value: String(index), label: index === 0 ? 'None — close when fixed' : String(index) })),
|
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({
|
||||||
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
value: String(index),
|
||||||
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }],
|
label: index === 0 ? "None — close when fixed" : String(index),
|
||||||
site_banner_tone: ['info', 'warning', 'error', 'maintenance'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
})),
|
||||||
magent_notify_push_provider: ['ntfy', 'gotify', 'pushover', 'webhook', 'telegram', 'discord'].map((value) => ({ value, label: value })),
|
issue_confirmation_interval_unit: ["days", "weeks", "months"].map((value) => ({
|
||||||
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
|
value,
|
||||||
}
|
label: value[0].toUpperCase() + value.slice(1),
|
||||||
|
})),
|
||||||
|
artwork_cache_mode: [
|
||||||
|
{ value: "remote", label: "Load from the internet" },
|
||||||
|
{ value: "cache", label: "Store locally" },
|
||||||
|
],
|
||||||
|
site_banner_tone: ["info", "warning", "error", "maintenance"].map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value[0].toUpperCase() + value.slice(1),
|
||||||
|
})),
|
||||||
|
magent_notify_push_provider: ["ntfy", "gotify", "pushover", "webhook", "telegram", "discord"].map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value,
|
||||||
|
})),
|
||||||
|
requests_data_source: [
|
||||||
|
{ value: "always_js", label: "Read directly from Seerr" },
|
||||||
|
{ value: "prefer_cache", label: "Use saved requests" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_DEFAULTS: Record<string, string> = {
|
||||||
|
site_banner_background_color: "#332814",
|
||||||
|
site_banner_border_color: "#a27b32",
|
||||||
|
};
|
||||||
|
|
||||||
export default function SettingField(props: Props) {
|
export default function SettingField(props: Props) {
|
||||||
const { setting, label, value, help, placeholder, onChange } = props
|
const { setting, label, value, help, placeholder, onChange } = props;
|
||||||
const id = `setting-${setting.key}`
|
const id = `setting-${setting.key}`;
|
||||||
const options = props.options ?? SELECTS[setting.key] ?? (setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ? SELECTS.log_level : undefined)
|
const options =
|
||||||
const selectedOptions = options && value && !options.some((option) => option.value === value)
|
props.options ??
|
||||||
? [{ value, label: `Current selection (${value})` }, ...options] : options
|
SELECTS[setting.key] ??
|
||||||
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time'
|
(setting.key === "log_http_client_level" || setting.key === "log_background_sync_level"
|
||||||
const zeroAllowed = setting.key === 'log_file_backup_count'
|
? SELECTS.log_level
|
||||||
const minimum = zeroAllowed ? 0 : 1
|
: undefined);
|
||||||
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
|
const selectedOptions =
|
||||||
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
|
options && value && !options.some((option) => option.value === value)
|
||||||
|
? [{ value, label: `Current selection (${value})` }, ...options]
|
||||||
|
: options;
|
||||||
|
const isTime = setting.key === "requests_full_sync_time" || setting.key === "requests_cleanup_time";
|
||||||
|
const zeroAllowed = setting.key === "log_file_backup_count";
|
||||||
|
const minimum = zeroAllowed ? 0 : 1;
|
||||||
|
const maximum =
|
||||||
|
setting.key === "issue_confirmation_interval_value" ? 365 : setting.key.endsWith("_port") ? 65535 : undefined;
|
||||||
|
const colorDefault = COLOR_DEFAULTS[setting.key];
|
||||||
|
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault;
|
||||||
|
const aria = { id, name: setting.key, "aria-describedby": help ? `${id}-help` : undefined };
|
||||||
|
|
||||||
if (props.boolean) {
|
if (props.boolean) {
|
||||||
return (
|
return (
|
||||||
<div className="setting-field setting-switch">
|
<div className="setting-field setting-switch">
|
||||||
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
|
<div>
|
||||||
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
|
<label htmlFor={id}>{label}</label>
|
||||||
|
{help && <p id={`${id}-help`}>{help}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
<input
|
||||||
|
{...aria}
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value.toLowerCase() === "true"}
|
||||||
|
checked={value.toLowerCase() === "true"}
|
||||||
|
onChange={(event) => onChange(String(event.target.checked))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}>
|
<div className={`setting-field ${props.multiline ? "field-span-full" : ""}`}>
|
||||||
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
|
<label htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
{setting.sensitive && setting.isSet && <small>Saved</small>}
|
||||||
|
</label>
|
||||||
{props.optionsUnavailable ? (
|
{props.optionsUnavailable ? (
|
||||||
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
|
<select {...aria} disabled value={value}>
|
||||||
|
<option value={value}>Save the connection, then reload available options</option>
|
||||||
|
</select>
|
||||||
|
) : colorDefault ? (
|
||||||
|
<div className="setting-color-control">
|
||||||
|
<input
|
||||||
|
id={`${id}-picker`}
|
||||||
|
type="color"
|
||||||
|
aria-label={`Choose ${label.toLowerCase()}`}
|
||||||
|
value={pickerValue}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
{...aria}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
pattern="#[0-9A-Fa-f]{6}"
|
||||||
|
placeholder={colorDefault}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<button type="button" className="ghost-button" onClick={() => onChange("")}>
|
||||||
|
Use tone default
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : selectedOptions ? (
|
) : selectedOptions ? (
|
||||||
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
|
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
{!value && <option value="">Choose an option</option>}
|
{!value && <option value="">Choose an option</option>}
|
||||||
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
{selectedOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : props.multiline ? (
|
) : props.multiline ? (
|
||||||
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
|
<textarea
|
||||||
|
{...aria}
|
||||||
|
rows={setting.key.includes("_pem") ? 6 : 3}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input {...aria} type={setting.sensitive ? 'password' : props.numeric ? 'number' : isTime ? 'time' : 'text'}
|
<input
|
||||||
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined}
|
{...aria}
|
||||||
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false}
|
type={setting.sensitive ? "password" : props.numeric ? "number" : isTime ? "time" : "text"}
|
||||||
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)} />
|
min={props.numeric ? minimum : undefined}
|
||||||
|
max={props.numeric ? maximum : undefined}
|
||||||
|
step={props.numeric ? 1 : undefined}
|
||||||
|
autoComplete={setting.sensitive ? "new-password" : "off"}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={setting.sensitive && setting.isSet ? "Leave blank to keep the saved value" : placeholder}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{help && <p id={`${id}-help`}>{help}</p>}
|
{help && <p id={`${id}-help`}>{help}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1371
-1301
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,38 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) {
|
export default function SettingsRegion({
|
||||||
const [open, setOpen] = useState(!collapsed)
|
title,
|
||||||
|
id,
|
||||||
|
collapsed,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(!collapsed);
|
||||||
return (
|
return (
|
||||||
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}>
|
<section id={id} className={`admin-section admin-zone config-subsection ${open ? "" : "is-collapsed"}`}>
|
||||||
{collapsed && <button type="button" className="config-region-toggle" aria-expanded={open} aria-controls={`${id}-content`} onClick={() => setOpen(!open)}><strong>{title}</strong><span>{open ? 'Hide' : 'Configure'} <b aria-hidden="true">{open ? '−' : '+'}</b></span></button>}
|
{collapsed && (
|
||||||
<div id={`${id}-content`} hidden={!open}>{children}</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="config-region-toggle"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={`${id}-content`}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>
|
||||||
|
{open ? "Hide" : "Configure"} <b aria-hidden="true">{open ? "−" : "+"}</b>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div id={`${id}-content`} hidden={!open}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from "next/navigation";
|
||||||
import SettingsPage from '../SettingsPage'
|
import SettingsPage from "../SettingsPage";
|
||||||
|
|
||||||
const ALLOWED_SECTIONS = new Set([
|
const ALLOWED_SECTIONS = new Set([
|
||||||
'seerr',
|
"seerr",
|
||||||
'jellyseerr',
|
"jellyseerr",
|
||||||
'jellyfin',
|
"jellyfin",
|
||||||
'jellystat',
|
"jellystat",
|
||||||
'artwork',
|
"artwork",
|
||||||
'sonarr',
|
"sonarr",
|
||||||
'radarr',
|
"radarr",
|
||||||
'bazarr',
|
"bazarr",
|
||||||
'prowlarr',
|
"prowlarr",
|
||||||
'qbittorrent',
|
"qbittorrent",
|
||||||
'requests',
|
"requests",
|
||||||
'issue-workflow',
|
"issue-workflow",
|
||||||
'cache',
|
"cache",
|
||||||
'logs',
|
"logs",
|
||||||
'maintenance',
|
"maintenance",
|
||||||
'magent',
|
"magent",
|
||||||
'general',
|
"general",
|
||||||
'notifications',
|
"notifications",
|
||||||
'site',
|
"site",
|
||||||
])
|
]);
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ section: string }>
|
params: Promise<{ section: string }>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default async function AdminSectionPage({ params }: PageProps) {
|
export default async function AdminSectionPage({ params }: PageProps) {
|
||||||
const { section } = await params
|
const { section } = await params;
|
||||||
if (!ALLOWED_SECTIONS.has(section)) {
|
if (!ALLOWED_SECTIONS.has(section)) {
|
||||||
notFound()
|
notFound();
|
||||||
}
|
}
|
||||||
return <SettingsPage section={section} />
|
return <SettingsPage section={section} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary,
|
||||||
|
.pending,
|
||||||
|
.panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary p,
|
||||||
|
.panel > p,
|
||||||
|
.muted,
|
||||||
|
.help {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-inline-start-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="file"] {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 44px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page input:focus-visible,
|
||||||
|
.page button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.columns {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.summary,
|
||||||
|
.pending,
|
||||||
|
.panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiUrl, requestJson } from "../../lib/api-client";
|
||||||
|
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
|
||||||
|
import AdminShell from "../../ui/AdminShell";
|
||||||
|
import styles from "./backups.module.css";
|
||||||
|
|
||||||
|
type BackupDetails = {
|
||||||
|
created_at: string;
|
||||||
|
build: string;
|
||||||
|
include_cache: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackupStatus = {
|
||||||
|
format_version: number;
|
||||||
|
max_upload_bytes: number;
|
||||||
|
max_expanded_bytes: number;
|
||||||
|
include_cache_default: boolean;
|
||||||
|
pending_restore: (BackupDetails & { staged_at: string }) | null;
|
||||||
|
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RestoreResult = {
|
||||||
|
status: "staged";
|
||||||
|
restart_required: true;
|
||||||
|
backup: BackupDetails;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateLabel = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<BackupStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
|
||||||
|
const [includeCache, setIncludeCache] = useState(false);
|
||||||
|
const [exportPassphrase, setExportPassphrase] = useState("");
|
||||||
|
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
||||||
|
const [restorePassphrase, setRestorePassphrase] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleError = useCallback(
|
||||||
|
(cause: unknown, fallback: string) => {
|
||||||
|
if (cause instanceof UnauthorizedError) {
|
||||||
|
router.replace("/login?next=%2Fadmin%2Fbackups");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cause instanceof ForbiddenError) {
|
||||||
|
router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(cause instanceof Error ? cause.message : fallback);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void revision;
|
||||||
|
const abort = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
|
||||||
|
.then((result) => {
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
setData(result);
|
||||||
|
setIncludeCache(result.include_cache_default);
|
||||||
|
})
|
||||||
|
.catch((cause: unknown) => {
|
||||||
|
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!abort.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [revision, handleError]);
|
||||||
|
|
||||||
|
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
|
||||||
|
setError("Choose a backup passphrase between 12 and 1,024 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exportPassphrase !== confirmPassphrase) {
|
||||||
|
setError("The backup passphrases do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("export");
|
||||||
|
try {
|
||||||
|
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
|
||||||
|
link.hidden = true;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
try {
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
|
||||||
|
}
|
||||||
|
setExportPassphrase("");
|
||||||
|
setConfirmPassphrase("");
|
||||||
|
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
|
||||||
|
} catch (cause) {
|
||||||
|
handleError(cause, "Could not create the backup.");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !data || data.pending_restore) return;
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
if (!file || file.size === 0) {
|
||||||
|
setError("Choose a Magent backup file to restore.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > data.max_upload_bytes) {
|
||||||
|
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
|
||||||
|
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmation !== "RESTORE") {
|
||||||
|
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("restore");
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("passphrase", restorePassphrase);
|
||||||
|
form.append("confirmation", confirmation);
|
||||||
|
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
|
||||||
|
setData((current) =>
|
||||||
|
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
|
||||||
|
);
|
||||||
|
setRestorePassphrase("");
|
||||||
|
setConfirmation("");
|
||||||
|
setFile(null);
|
||||||
|
if (fileInput.current) fileInput.current.value = "";
|
||||||
|
setNotice(
|
||||||
|
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
handleError(cause, "Could not prepare the restore.");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRestore = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy("cancel");
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
await requestJson("/admin/backups/restore", { method: "DELETE" });
|
||||||
|
setData((current) => (current ? { ...current, pending_restore: null } : current));
|
||||||
|
setNotice("Pending restore cancelled. Your current data is unchanged.");
|
||||||
|
} catch (cause) {
|
||||||
|
handleError(cause, "Could not cancel the restore.");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
|
||||||
|
<div className={styles.page}>
|
||||||
|
{error && (
|
||||||
|
<p className="error-banner" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p className={styles.notice} role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading && <p role="status">Loading backup settings...</p>}
|
||||||
|
{!loading && !data && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
setError("");
|
||||||
|
setRevision((current) => current + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<section className={styles.summary} aria-labelledby="backup-contents">
|
||||||
|
<h2 id="backup-contents">What is saved</h2>
|
||||||
|
<p>
|
||||||
|
Every backup includes Magent settings, app connection credentials, branding, and the complete database
|
||||||
|
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Connected apps and media files need their own backups. App credentials configured through the
|
||||||
|
environment are included, but the deployment environment file, host paths, and signing or encryption
|
||||||
|
keys are not.
|
||||||
|
</p>
|
||||||
|
{data.last_restore && (
|
||||||
|
<p className={styles.muted}>
|
||||||
|
{data.last_restore.status === "rolled_back"
|
||||||
|
? "Last restore was rolled back"
|
||||||
|
: "Last restore completed"}
|
||||||
|
: {dateLabel(data.last_restore.restored_at)}.
|
||||||
|
{data.last_restore.status === "rolled_back" &&
|
||||||
|
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data.pending_restore && (
|
||||||
|
<section className={styles.pending} aria-labelledby="pending-restore-title">
|
||||||
|
<h2 id="pending-restore-title">Restore ready — restart required</h2>
|
||||||
|
<p>
|
||||||
|
Backup from {dateLabel(data.pending_restore.created_at)}
|
||||||
|
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
|
||||||
|
{data.pending_restore.include_cache ? "included" : "not included"}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Restart the Magent container or service to apply this backup. Changes made since the backup was
|
||||||
|
created will be replaced. Afterwards, sign in again with an administrator account from the restored
|
||||||
|
backup.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
|
||||||
|
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<section className={styles.panel} aria-labelledby="create-backup-title">
|
||||||
|
<h2 id="create-backup-title">Create a backup</h2>
|
||||||
|
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
|
||||||
|
<p>
|
||||||
|
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
|
||||||
|
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
|
||||||
|
artwork caches unchecked.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
|
||||||
|
<fieldset className={styles.fields} disabled={!!busy}>
|
||||||
|
<legend className={styles.legend}>Backup options</legend>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeCache}
|
||||||
|
onChange={(event) => setIncludeCache(event.target.checked)}
|
||||||
|
aria-describedby="cache-help"
|
||||||
|
/>
|
||||||
|
Include artwork caches
|
||||||
|
</label>
|
||||||
|
<p id="cache-help" className={styles.help}>
|
||||||
|
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
|
||||||
|
again. Database caches are always included.
|
||||||
|
</p>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Backup passphrase
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={exportPassphrase}
|
||||||
|
onChange={(event) => setExportPassphrase(event.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
required
|
||||||
|
aria-describedby="backup-passphrase-help"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="backup-passphrase-help" className={styles.help}>
|
||||||
|
Use at least 12 characters. This passphrase is separate from your login password. A lost
|
||||||
|
passphrase cannot be recovered.
|
||||||
|
</p>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Confirm backup passphrase
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassphrase}
|
||||||
|
onChange={(event) => setConfirmPassphrase(event.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">
|
||||||
|
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.panel} aria-labelledby="restore-backup-title">
|
||||||
|
<h2 id="restore-backup-title">Restore a backup</h2>
|
||||||
|
<p>
|
||||||
|
Restoring replaces Magent's settings and database, including users and invites. Download a
|
||||||
|
current backup first if you want to keep these changes.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
|
||||||
|
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
|
||||||
|
<legend className={styles.legend}>Choose and confirm a backup</legend>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Backup file
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".magent-backup,application/octet-stream"
|
||||||
|
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||||
|
required
|
||||||
|
aria-describedby="backup-file-help"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="backup-file-help" className={styles.help}>
|
||||||
|
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
|
||||||
|
</p>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Backup passphrase
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={restorePassphrase}
|
||||||
|
onChange={(event) => setRestorePassphrase(event.target.value)}
|
||||||
|
minLength={12}
|
||||||
|
maxLength={1024}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Type RESTORE to confirm replacement
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
spellCheck={false}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(event) => setConfirmation(event.target.value)}
|
||||||
|
pattern="RESTORE"
|
||||||
|
required
|
||||||
|
aria-describedby="restore-restart-help"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id="restore-restart-help" className={styles.help}>
|
||||||
|
The backup is checked before being queued. It only takes effect when you restart Magent; you can
|
||||||
|
cancel before then. You will need to sign in using an account from the backup.
|
||||||
|
</p>
|
||||||
|
<button type="submit" className="danger-button">
|
||||||
|
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
|
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
|
||||||
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
|
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
|
||||||
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
|
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
|
||||||
|
.config-subsection .setting-color-control { display: grid; grid-template-columns: 52px minmax(140px, 1fr) auto; align-items: center; gap: 8px; }
|
||||||
|
.config-subsection .setting-color-control input[type=color] { width: 52px; min-width: 52px; padding: 4px; cursor: pointer; }
|
||||||
|
.config-subsection .setting-color-control .ghost-button { min-height: 42px; padding: 9px 12px; white-space: nowrap; }
|
||||||
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
|
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
|
||||||
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
|
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
|
||||||
.setting-switch > div { display: grid; gap: 6px; }
|
.setting-switch > div { display: grid; gap: 6px; }
|
||||||
@@ -92,6 +95,8 @@
|
|||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.admin-form .admin-grid { grid-template-columns: 1fr; }
|
.admin-form .admin-grid { grid-template-columns: 1fr; }
|
||||||
.admin-form .config-subsection { padding: 16px !important; }
|
.admin-form .config-subsection { padding: 16px !important; }
|
||||||
|
.config-subsection .setting-color-control { grid-template-columns: 52px minmax(0, 1fr); }
|
||||||
|
.config-subsection .setting-color-control .ghost-button { grid-column: 1 / -1; }
|
||||||
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
|
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
|
||||||
.config-link-copy { flex-basis: calc(100% - 62px); }
|
.config-link-copy { flex-basis: calc(100% - 62px); }
|
||||||
.config-directory-link .config-connection-badge { margin-left: 46px; }
|
.config-directory-link .config-connection-badge { margin-left: 46px; }
|
||||||
|
|||||||
@@ -1,37 +1,106 @@
|
|||||||
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
|
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string };
|
||||||
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
|
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] };
|
||||||
|
|
||||||
export const CONFIG_GROUPS: ConfigGroup[] = [
|
export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||||
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
|
{
|
||||||
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
|
title: "Media services",
|
||||||
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
|
description: "Connect the services that collect, repair and play your content.",
|
||||||
{ href: '/admin/jellystat', label: 'Jellystat', description: 'Personal viewing statistics', symbol: 'JS', service: 'Jellystat' },
|
items: [
|
||||||
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
|
{ href: "/admin/seerr", label: "Seerr", description: "Requests and approvals", symbol: "SE", service: "Seerr" },
|
||||||
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
|
{
|
||||||
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
|
href: "/admin/jellyfin",
|
||||||
{ href: '/admin/prowlarr', label: 'Prowlarr', description: 'Search sources', symbol: 'PR', service: 'Prowlarr' },
|
label: "Jellyfin",
|
||||||
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' },
|
description: "Playback and library availability",
|
||||||
]},
|
symbol: "JF",
|
||||||
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
service: "Jellyfin",
|
||||||
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options' },
|
},
|
||||||
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates' },
|
{
|
||||||
{ href: '/admin/recaps', label: 'Monthly email recaps', description: 'Personal viewing emails, schedule and delivery history' },
|
href: "/admin/jellystat",
|
||||||
{ href: '/admin/newsletters', label: 'Newsletters', description: 'New arrivals, featured picks and weekly editions' },
|
label: "Jellystat",
|
||||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure' },
|
description: "Personal viewing statistics",
|
||||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention' },
|
symbol: "JS",
|
||||||
{ href: '/users', label: 'User management', description: 'Accounts, permissions, identity checks and repairs' },
|
service: "Jellystat",
|
||||||
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites' },
|
},
|
||||||
]},
|
{
|
||||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
href: "/admin/sonarr",
|
||||||
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' },
|
label: "Sonarr",
|
||||||
{ href: '/admin/diagnostics', label: 'System health', description: 'Service checks and diagnostics' },
|
description: "TV collection and quality",
|
||||||
{ href: '/admin/logs', label: 'Logs', description: 'Recent activity and log settings' },
|
symbol: "SO",
|
||||||
{ href: '/admin/cache', label: 'Request cache', description: 'Inspect saved request records' },
|
service: "Sonarr",
|
||||||
{ href: '/admin/artwork', label: 'Artwork cache', description: 'Poster storage and missing artwork' },
|
},
|
||||||
{ href: '/admin/maintenance', label: 'Recovery & cleanup', description: 'Database repair and history cleanup' },
|
{
|
||||||
]},
|
href: "/admin/radarr",
|
||||||
]
|
label: "Radarr",
|
||||||
|
description: "Movie collection and quality",
|
||||||
|
symbol: "RA",
|
||||||
|
service: "Radarr",
|
||||||
|
},
|
||||||
|
{ href: "/admin/bazarr", label: "Bazarr", description: "Subtitle repairs", symbol: "BA", service: "Bazarr" },
|
||||||
|
{ href: "/admin/prowlarr", label: "Prowlarr", description: "Search sources", symbol: "PR", service: "Prowlarr" },
|
||||||
|
{
|
||||||
|
href: "/admin/qbittorrent",
|
||||||
|
label: "qBittorrent",
|
||||||
|
description: "Download progress and recovery",
|
||||||
|
symbol: "QB",
|
||||||
|
service: "qBittorrent",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Preferences & access",
|
||||||
|
description: "Set the experience for your users and how issues are followed up.",
|
||||||
|
items: [
|
||||||
|
{ href: "/admin/site", label: "Site & sign-in", description: "Announcements and login options" },
|
||||||
|
{
|
||||||
|
href: "/admin/notifications",
|
||||||
|
label: "Email & notifications",
|
||||||
|
description: "Invites, password resets and repair updates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/recaps",
|
||||||
|
label: "Monthly email recaps",
|
||||||
|
description: "Personal viewing emails, schedule and delivery history",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/newsletters",
|
||||||
|
label: "Newsletters",
|
||||||
|
description: "New arrivals, featured picks and weekly editions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/issue-workflow",
|
||||||
|
label: "Issue follow-up",
|
||||||
|
description: "Confirmation emails and automatic closure",
|
||||||
|
},
|
||||||
|
{ href: "/admin/requests", label: "Request updates", description: "Refresh schedule and history retention" },
|
||||||
|
{ href: "/users", label: "User management", description: "Accounts, permissions, identity checks and repairs" },
|
||||||
|
{ href: "/admin/invites", label: "Invite policy & access", description: "Defaults, profiles and issued invites" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Advanced tools",
|
||||||
|
description: "Hosting and troubleshooting.",
|
||||||
|
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" },
|
||||||
|
{ href: "/admin/artwork", label: "Artwork cache", description: "Poster storage and missing artwork" },
|
||||||
|
{ href: "/admin/maintenance", label: "Recovery & cleanup", description: "Database repair and history cleanup" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const serviceStatusLabel = (status?: string) => ({
|
export const serviceStatusLabel = (status?: string) =>
|
||||||
up: 'Connected', down: 'Unavailable', degraded: 'Needs attention', not_configured: 'Not set up',
|
({
|
||||||
}[status ?? ''] ?? 'Not checked')
|
up: "Connected",
|
||||||
|
down: "Unavailable",
|
||||||
|
degraded: "Needs attention",
|
||||||
|
not_configured: "Not set up",
|
||||||
|
})[status ?? ""] ?? "Not checked";
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import AdminShell from '../../ui/AdminShell'
|
import AdminShell from "../../ui/AdminShell";
|
||||||
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
|
import AdminDiagnosticsPanel from "../../ui/AdminDiagnosticsPanel";
|
||||||
|
|
||||||
export default function AdminDiagnosticsPage() {
|
export default function AdminDiagnosticsPage() {
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell title="Diagnostics" subtitle="Check connections and investigate service problems.">
|
||||||
title="Diagnostics"
|
|
||||||
subtitle="Check connections and investigate service problems."
|
|
||||||
>
|
|
||||||
<AdminDiagnosticsPanel />
|
<AdminDiagnosticsPanel />
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,232 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { authFetch, getApiBase } from '../../lib/auth'
|
import { authFetch, getApiBase } from "../../lib/auth";
|
||||||
import { FEATURES, type FeatureAccess } from '../../lib/features'
|
import { FEATURES, type FeatureAccess } from "../../lib/features";
|
||||||
import type { Row } from './IdentityReviewPanel'
|
import type { Row } from "./IdentityReviewPanel";
|
||||||
|
|
||||||
type Account = { id: number; username: string; email: string | null; profile_id: number | null; last_login_at: string | null }
|
type Account = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
profile_id: number | null;
|
||||||
|
last_login_at: string | null;
|
||||||
|
};
|
||||||
type Preview = {
|
type Preview = {
|
||||||
accounts: Account[]; keep_id: number; recommended_id: number; revision: string; can_confirm: boolean; issues: string[]
|
accounts: Account[];
|
||||||
proposed: Account & { jellyfin_user_id: string; seerr_user_id: number; features: FeatureAccess; expires_at: string | null; is_blocked: boolean; auto_search_enabled: boolean }
|
keep_id: number;
|
||||||
}
|
recommended_id: number;
|
||||||
|
revision: string;
|
||||||
|
can_confirm: boolean;
|
||||||
|
issues: string[];
|
||||||
|
proposed: Account & {
|
||||||
|
jellyfin_user_id: string;
|
||||||
|
seerr_user_id: number;
|
||||||
|
features: FeatureAccess;
|
||||||
|
expires_at: string | null;
|
||||||
|
is_blocked: boolean;
|
||||||
|
auto_search_enabled: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function DuplicateAccountRepair({ row, onClose, onSaved }: { row: Row; onClose: () => void; onSaved: () => void }) {
|
export default function DuplicateAccountRepair({
|
||||||
const dialog = useRef<HTMLDialogElement>(null)
|
row,
|
||||||
const controller = useRef<AbortController | null>(null)
|
onClose,
|
||||||
const [preview, setPreview] = useState<Preview | null>(null)
|
onSaved,
|
||||||
const [busy, setBusy] = useState(false)
|
}: {
|
||||||
const [saving, setSaving] = useState(false)
|
row: Row;
|
||||||
const [acknowledged, setAcknowledged] = useState(false)
|
onClose: () => void;
|
||||||
const [error, setError] = useState('')
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
const submit = async (confirm = false, keepId?: number) => {
|
const submit = async (confirm = false, keepId?: number) => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController();
|
||||||
controller.current?.abort(); controller.current = abort
|
controller.current?.abort();
|
||||||
setError(''); setAcknowledged(false)
|
controller.current = abort;
|
||||||
if (confirm) setSaving(true)
|
setError("");
|
||||||
else setBusy(true)
|
setAcknowledged(false);
|
||||||
|
if (confirm) setSaving(true);
|
||||||
|
else setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? 'confirm' : 'check'}`, {
|
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? "confirm" : "check"}`, {
|
||||||
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
|
method: "POST",
|
||||||
body: JSON.stringify({ user_id: row.user.id, ...(keepId ? { keep_id: keepId } : {}), ...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}) }),
|
signal: abort.signal,
|
||||||
})
|
headers: { "Content-Type": "application/json" },
|
||||||
const data = await response.json()
|
body: JSON.stringify({
|
||||||
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not review these accounts.')
|
user_id: row.user.id,
|
||||||
if (!abort.signal.aborted) { if (confirm) onSaved(); else setPreview(data) }
|
...(keepId ? { keep_id: keepId } : {}),
|
||||||
} catch (err) { if (!abort.signal.aborted) { setError(err instanceof Error ? err.message : 'Repair failed. Preview again.'); setPreview(null) } }
|
...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}),
|
||||||
finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(typeof data.detail === "string" ? data.detail : "Could not review these accounts.");
|
||||||
|
if (!abort.signal.aborted) {
|
||||||
|
if (confirm) onSaved();
|
||||||
|
else setPreview(data);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!abort.signal.aborted) {
|
||||||
|
setError(err instanceof Error ? err.message : "Repair failed. Preview again.");
|
||||||
|
setPreview(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!abort.signal.aborted) {
|
||||||
|
setBusy(false);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: The dialog preview runs once when this keyed modal mounts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = document.activeElement as HTMLElement | null
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
const overflow = document.body.style.overflow
|
const overflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden'; dialog.current?.showModal()
|
document.body.style.overflow = "hidden";
|
||||||
void submit()
|
dialog.current?.showModal();
|
||||||
return () => { controller.current?.abort(); document.body.style.overflow = overflow; previous?.focus() }
|
void submit();
|
||||||
}, [])
|
return () => {
|
||||||
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="duplicates-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
controller.current?.abort();
|
||||||
|
document.body.style.overflow = overflow;
|
||||||
|
previous?.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
className="identity-resolve-dialog"
|
||||||
|
aria-labelledby="duplicates-title"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!saving) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="identity-resolve-content">
|
<div className="identity-resolve-content">
|
||||||
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header>
|
<header>
|
||||||
<p>Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to the verified Jellyfin identity.</p>
|
<h2 id="duplicates-title">Repair duplicate accounts</h2>
|
||||||
|
<button type="button" className="ghost-button" disabled={saving} onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to
|
||||||
|
the verified Jellyfin identity.
|
||||||
|
</p>
|
||||||
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
|
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
{error && (
|
||||||
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>}
|
<p className="error-banner" role="alert">
|
||||||
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview">
|
{error}
|
||||||
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}>
|
</p>
|
||||||
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} — Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)}
|
)}
|
||||||
</select></label>
|
{!preview && !busy && (
|
||||||
|
<button type="button" disabled={saving} onClick={() => void submit()}>
|
||||||
|
Check again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{preview && (
|
||||||
|
<section className="identity-confirm-panel" aria-label="Duplicate repair preview">
|
||||||
|
<label>
|
||||||
|
Magent account to keep
|
||||||
|
<select
|
||||||
|
disabled={busy || saving}
|
||||||
|
value={preview.keep_id}
|
||||||
|
onChange={(event) => void submit(false, Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{preview.accounts.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.username} — Magent {account.id}
|
||||||
|
{account.id === preview.recommended_id ? " (recommended)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
|
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
|
||||||
<div className="identity-mapping identity-duplicate-accounts">{preview.accounts.map((account) => <div key={account.id}><strong>Magent {account.id}{account.id === preview.keep_id ? ' · Keep' : ' · Consolidate'}</strong><p>{account.username}</p><p>{account.email || 'No email'} · Profile {account.profile_id ?? 'None'}</p><p>Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : 'Never'}</p></div>)}</div>
|
<div className="identity-mapping identity-duplicate-accounts">
|
||||||
|
{preview.accounts.map((account) => (
|
||||||
|
<div key={account.id}>
|
||||||
|
<strong>
|
||||||
|
Magent {account.id}
|
||||||
|
{account.id === preview.keep_id ? " · Keep" : " · Consolidate"}
|
||||||
|
</strong>
|
||||||
|
<p>{account.username}</p>
|
||||||
|
<p>
|
||||||
|
{account.email || "No email"} · Profile {account.profile_id ?? "None"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : "Never"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<h3>Resulting account</h3>
|
<h3>Resulting account</h3>
|
||||||
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p>
|
<p>
|
||||||
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p>
|
<strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr{" "}
|
||||||
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p>
|
{preview.proposed.seerr_user_id ?? "Not verified"}
|
||||||
<p>Access: {preview.proposed.is_blocked ? 'Blocked' : 'Not blocked'} · Expiry: {preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : 'None'} · Automatic search: {preview.proposed.auto_search_enabled ? 'Enabled' : 'Disabled'}</p>
|
</p>
|
||||||
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul>
|
<p>
|
||||||
<p>Request, issue, invitation and login activity history is retained. The selected account keeps its email and profile. Any block, earlier expiry or disabled permission on either row is preserved.</p>
|
Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? "Not verified"}</code>
|
||||||
<p>Extra Magent rows are removed from the active directory after their details are archived. Their outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains its own subscriptions where still eligible. Password reset links must be requested again.</p>
|
</p>
|
||||||
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p>
|
<p>
|
||||||
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
Email: {preview.proposed.email || "None"} · Profile: {preview.proposed.profile_id ?? "None"}
|
||||||
<label className="identity-import-option"><span><input type="checkbox" checked={acknowledged} disabled={busy || saving || !preview.can_confirm} onChange={(event) => setAcknowledged(event.target.checked)} /> I confirm these rows belong to the same person and have reviewed the account to keep.</span></label>
|
</p>
|
||||||
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button>
|
<p>
|
||||||
</section>}
|
Access: {preview.proposed.is_blocked ? "Blocked" : "Not blocked"} · Expiry:{" "}
|
||||||
|
{preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : "None"} ·
|
||||||
|
Automatic search: {preview.proposed.auto_search_enabled ? "Enabled" : "Disabled"}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{FEATURES.map((feature) => (
|
||||||
|
<li key={feature.key}>
|
||||||
|
{feature.label}: {preview.proposed.features[feature.key] ? "Enabled" : "Disabled"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Request, issue, invitation and login activity history is retained. The selected account keeps its email
|
||||||
|
and profile. Any block, earlier expiry or disabled permission on either row is preserved.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Extra Magent rows are removed from the active directory after their details are archived. Their
|
||||||
|
outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains
|
||||||
|
its own subscriptions where still eligible. Password reset links must be requested again.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different
|
||||||
|
Jellyfin identities or delete upstream users.
|
||||||
|
</p>
|
||||||
|
{preview.issues.length > 0 && (
|
||||||
|
<ul className="identity-issues">
|
||||||
|
{preview.issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<label className="identity-import-option">
|
||||||
|
<span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledged}
|
||||||
|
disabled={busy || saving || !preview.can_confirm}
|
||||||
|
onChange={(event) => setAcknowledged(event.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
I confirm these rows belong to the same person and have reviewed the account to keep.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!preview.can_confirm || !acknowledged || busy || saving}
|
||||||
|
onClick={() => void submit(true)}
|
||||||
|
>
|
||||||
|
{saving ? "Rechecking and repairing..." : "Confirm duplicate repair"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,506 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from "next/navigation";
|
||||||
import { authFetch, getApiBase } from '../../lib/auth'
|
import { authFetch, getApiBase } from "../../lib/auth";
|
||||||
import './identities.css'
|
import "./identities.css";
|
||||||
import DuplicateAccountRepair from './DuplicateAccountRepair'
|
import DuplicateAccountRepair from "./DuplicateAccountRepair";
|
||||||
import ResolveIdentityLink from './ResolveIdentityLink'
|
import ResolveIdentityLink from "./ResolveIdentityLink";
|
||||||
|
|
||||||
type Identity = { id: string; name: string }
|
type Identity = { id: string; name: string };
|
||||||
export type Row = {
|
export type Row = {
|
||||||
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null }
|
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null };
|
||||||
jellyfin: Identity | null
|
jellyfin: Identity | null;
|
||||||
candidate_jellyfin_id: string | null
|
candidate_jellyfin_id: string | null;
|
||||||
stored_jellyfin_id: string | null
|
stored_jellyfin_id: string | null;
|
||||||
seerr: { id: number; name: string; jellyfin_id: string }[]
|
seerr: { id: number; name: string; jellyfin_id: string }[];
|
||||||
jellystat: { state: string; id?: string; name?: string }
|
jellystat: { state: string; id?: string; name?: string };
|
||||||
basis: string
|
basis: string;
|
||||||
issues: string[]
|
issues: string[];
|
||||||
state: string
|
state: string;
|
||||||
can_confirm: boolean
|
can_confirm: boolean;
|
||||||
confirmed_at: string | null
|
confirmed_at: string | null;
|
||||||
}
|
};
|
||||||
type Report = {
|
type Report = {
|
||||||
revision: string; checked_at: string; server_id: string | null
|
revision: string;
|
||||||
services: Record<string, string>
|
checked_at: string;
|
||||||
counts: Record<string, number>
|
server_id: string | null;
|
||||||
jellyfin_users: Identity[]
|
services: Record<string, string>;
|
||||||
rows: Row[]
|
counts: Record<string, number>;
|
||||||
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[]
|
jellyfin_users: Identity[];
|
||||||
}
|
rows: Row[];
|
||||||
const labels: Record<string, string> = { ready: 'Ready to review', confirmed: 'Confirmed', conflict: 'Conflict', unlinked: 'Missing link', unavailable: 'Check incomplete' }
|
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[];
|
||||||
const serviceLabels: Record<string, string> = { available: 'Checked', unavailable: 'Unavailable', not_configured: 'Not configured', not_checked: 'No IDs to check' }
|
};
|
||||||
const basisLabels: Record<string, string> = { confirmed_id: 'Confirmed Jellyfin ID', stored_jellyfin_id: 'Stored Jellyfin ID', stored_seerr_id: 'Seerr’s Jellyfin ID', suggested_username: 'Suggested from Jellyfin username — review before saving', none: 'No identity match' }
|
const labels: Record<string, string> = {
|
||||||
const statsLabels: Record<string, string> = { matched: 'ID matches', missing: 'ID not found', unavailable: 'Could not check', not_configured: 'Not configured', not_checked: 'No ID to check' }
|
ready: "Ready to review",
|
||||||
|
confirmed: "Confirmed",
|
||||||
|
conflict: "Conflict",
|
||||||
|
unlinked: "Missing link",
|
||||||
|
unavailable: "Check incomplete",
|
||||||
|
};
|
||||||
|
const serviceLabels: Record<string, string> = {
|
||||||
|
available: "Checked",
|
||||||
|
unavailable: "Unavailable",
|
||||||
|
not_configured: "Not configured",
|
||||||
|
not_checked: "No IDs to check",
|
||||||
|
};
|
||||||
|
const basisLabels: Record<string, string> = {
|
||||||
|
confirmed_id: "Confirmed Jellyfin ID",
|
||||||
|
stored_jellyfin_id: "Stored Jellyfin ID",
|
||||||
|
stored_seerr_id: "Seerr’s Jellyfin ID",
|
||||||
|
suggested_username: "Suggested from Jellyfin username — review before saving",
|
||||||
|
none: "No identity match",
|
||||||
|
};
|
||||||
|
const statsLabels: Record<string, string> = {
|
||||||
|
matched: "ID matches",
|
||||||
|
missing: "ID not found",
|
||||||
|
unavailable: "Could not check",
|
||||||
|
not_configured: "Not configured",
|
||||||
|
not_checked: "No ID to check",
|
||||||
|
};
|
||||||
|
|
||||||
export default function IdentityReviewPanel() {
|
export default function IdentityReviewPanel() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false);
|
||||||
const [report, setReport] = useState<Report | null>(null)
|
const [report, setReport] = useState<Report | null>(null);
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false);
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [notice, setNotice] = useState('')
|
const [notice, setNotice] = useState("");
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState("");
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState("all");
|
||||||
const [selected, setSelected] = useState<number[]>([])
|
const [selected, setSelected] = useState<number[]>([]);
|
||||||
const [duplicates, setDuplicates] = useState<Row | null>(null)
|
const [duplicates, setDuplicates] = useState<Row | null>(null);
|
||||||
const [resolving, setResolving] = useState<Row | null>(null)
|
const [resolving, setResolving] = useState<Row | null>(null);
|
||||||
const [reviewing, setReviewing] = useState(false)
|
const [reviewing, setReviewing] = useState(false);
|
||||||
const controller = useRef<AbortController | null>(null)
|
const controller = useRef<AbortController | null>(null);
|
||||||
const reviewPanel = useRef<HTMLElement | null>(null)
|
const reviewPanel = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuery(new URLSearchParams(window.location.search).get('user') ?? '')
|
setQuery(new URLSearchParams(window.location.search).get("user") ?? "");
|
||||||
const abort = new AbortController()
|
const abort = new AbortController();
|
||||||
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal }).then(async (response) => {
|
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal })
|
||||||
if (response.status === 401) { router.replace('/login'); return }
|
.then(async (response) => {
|
||||||
if (!response.ok) throw new Error('Could not check administrator access. Refresh to try again.')
|
if (response.status === 401) {
|
||||||
if ((await response.json()).role !== 'admin') { router.replace('/'); return }
|
router.replace("/login");
|
||||||
if (!abort.signal.aborted) setReady(true)
|
return;
|
||||||
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) })
|
}
|
||||||
return () => { abort.abort(); controller.current?.abort() }
|
if (!response.ok) throw new Error("Could not check administrator access. Refresh to try again.");
|
||||||
}, [router])
|
if ((await response.json()).role !== "admin") {
|
||||||
|
router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!abort.signal.aborted) setReady(true);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!abort.signal.aborted) setError(err.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
abort.abort();
|
||||||
|
controller.current?.abort();
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => { if (reviewing) reviewPanel.current?.focus() }, [reviewing])
|
useEffect(() => {
|
||||||
|
if (reviewing) reviewPanel.current?.focus();
|
||||||
|
}, [reviewing]);
|
||||||
|
|
||||||
const responseData = async (response: Response) => {
|
const responseData = async (response: Response) => {
|
||||||
if (response.status === 401) { router.replace('/login'); throw new Error('Your session has ended. Sign in again.') }
|
if (response.status === 401) {
|
||||||
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') }
|
router.replace("/login");
|
||||||
const data = await response.json().catch(() => ({}))
|
throw new Error("Your session has ended. Sign in again.");
|
||||||
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'The identity check could not complete. Try again.')
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
router.replace("/");
|
||||||
|
throw new Error("Administrator access is required.");
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
typeof data.detail === "string" ? data.detail : "The identity check could not complete. Try again.",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const runCheck = async () => {
|
const runCheck = async () => {
|
||||||
controller.current?.abort()
|
controller.current?.abort();
|
||||||
const abort = new AbortController()
|
const abort = new AbortController();
|
||||||
controller.current = abort
|
controller.current = abort;
|
||||||
setBusy(true); setError(''); setNotice(''); setSelected([]); setReviewing(false); setReport(null)
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
setSelected([]);
|
||||||
|
setReviewing(false);
|
||||||
|
setReport(null);
|
||||||
try {
|
try {
|
||||||
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }))
|
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }));
|
||||||
if (!abort.signal.aborted) setReport(data)
|
if (!abort.signal.aborted) setReport(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not check identities.')
|
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not check identities.");
|
||||||
} finally { if (!abort.signal.aborted) setBusy(false) }
|
} finally {
|
||||||
|
if (!abort.signal.aborted) setBusy(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!report || saving || !selected.length) return
|
if (!report || saving || !selected.length) return;
|
||||||
setSaving(true); setError(''); setNotice('')
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
try {
|
try {
|
||||||
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities/confirm`, {
|
const data = await responseData(
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
await authFetch(`${getApiBase()}/admin/identities/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
|
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
|
||||||
}))
|
}),
|
||||||
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`)
|
);
|
||||||
|
setNotice(
|
||||||
|
`${data.confirmed} account ${data.confirmed === 1 ? "link" : "links"} confirmed and saved. Run another check to see the updated mappings.`,
|
||||||
|
);
|
||||||
// The scan describes the previous database state and cannot be reused for another write.
|
// The scan describes the previous database state and cannot be reused for another write.
|
||||||
setReport(null); setSelected([]); setReviewing(false)
|
setReport(null);
|
||||||
|
setSelected([]);
|
||||||
|
setReviewing(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Could not save identity links.')
|
setError(err instanceof Error ? err.message : "Could not save identity links.");
|
||||||
setReport(null); setSelected([]); setReviewing(false)
|
setReport(null);
|
||||||
} finally { setSaving(false) }
|
setSelected([]);
|
||||||
|
setReviewing(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const needle = query.trim().toLowerCase()
|
const needle = query.trim().toLowerCase();
|
||||||
const filtered = report?.rows.filter((row) => (filter === 'all' || row.state === filter) &&
|
const filtered =
|
||||||
[row.user.username, row.user.id, row.candidate_jellyfin_id, row.user.jellyseerr_user_id, ...row.seerr.map((entry) => entry.id)].join(' ').toLowerCase().includes(needle)) ?? []
|
report?.rows.filter(
|
||||||
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? []
|
(row) =>
|
||||||
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id)
|
(filter === "all" || row.state === filter) &&
|
||||||
const toggle = (id: number) => { setReviewing(false); setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) }
|
[
|
||||||
|
row.user.username,
|
||||||
|
row.user.id,
|
||||||
|
row.candidate_jellyfin_id,
|
||||||
|
row.user.jellyseerr_user_id,
|
||||||
|
...row.seerr.map((entry) => entry.id),
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(needle),
|
||||||
|
) ?? [];
|
||||||
|
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? [];
|
||||||
|
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id);
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
setReviewing(false);
|
||||||
|
setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="identity-review">
|
<div className="identity-review">
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
{error && (
|
||||||
{notice && <p className="status-banner" role="status">{notice}</p>}
|
<p className="error-banner" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p className="status-banner" role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{!ready && !error && <p role="status">Checking administrator access…</p>}
|
{!ready && !error && <p role="status">Checking administrator access…</p>}
|
||||||
{ready && <>
|
{ready && (
|
||||||
|
<>
|
||||||
<section className="identity-intro admin-panel">
|
<section className="identity-intro admin-panel">
|
||||||
<div><h2>Confirm user IDs</h2><p>Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same user ID.</p><p>Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs. Duplicate ownership and upstream changes require individual review.</p></div>
|
<div>
|
||||||
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button>
|
<h2>Confirm user IDs</h2>
|
||||||
|
<p>
|
||||||
|
Jellyfin’s server and user IDs identify each account. Seerr and Jellystat are checked against that same
|
||||||
|
user ID.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs.
|
||||||
|
Duplicate ownership and upstream changes require individual review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={runCheck} disabled={busy || saving}>
|
||||||
|
{busy ? "Checking all accounts…" : report ? "Run check again" : "Check all user IDs"}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>}
|
{busy && (
|
||||||
{report && <>
|
<p role="status">
|
||||||
<div className="identity-service-strip">{Object.entries(report.services).map(([service, state]) => <span key={service}><strong>{service === 'seerr' ? 'Seerr' : service === 'jellyfin' ? 'Jellyfin' : 'Jellystat'}</strong> {serviceLabels[state] ?? state}</span>)}</div>
|
Reading the live user directories and checking Jellystat IDs. This can take up to a minute.
|
||||||
<p className="identity-meta">Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server <code>{report.server_id ?? 'Unavailable'}</code></p>
|
</p>
|
||||||
<div className="identity-counts">{['magent', 'ready', 'confirmed', 'conflict', 'unlinked', 'unavailable'].map((state) => <div key={state}><strong>{report.counts[state]}</strong><span>{state === 'magent' ? 'Magent accounts' : labels[state]}</span></div>)}</div>
|
)}
|
||||||
<p className="identity-meta">Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.</p>
|
{report && (
|
||||||
|
<>
|
||||||
|
<div className="identity-service-strip">
|
||||||
|
{Object.entries(report.services).map(([service, state]) => (
|
||||||
|
<span key={service}>
|
||||||
|
<strong>{service === "seerr" ? "Seerr" : service === "jellyfin" ? "Jellyfin" : "Jellystat"}</strong>{" "}
|
||||||
|
{serviceLabels[state] ?? state}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="identity-meta">
|
||||||
|
Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server{" "}
|
||||||
|
<code>{report.server_id ?? "Unavailable"}</code>
|
||||||
|
</p>
|
||||||
|
<div className="identity-counts">
|
||||||
|
{["magent", "ready", "confirmed", "conflict", "unlinked", "unavailable"].map((state) => (
|
||||||
|
<div key={state}>
|
||||||
|
<strong>{report.counts[state]}</strong>
|
||||||
|
<span>{state === "magent" ? "Magent accounts" : labels[state]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="identity-meta">
|
||||||
|
Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in
|
||||||
|
Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.
|
||||||
|
</p>
|
||||||
<div className="identity-filters">
|
<div className="identity-filters">
|
||||||
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label>
|
<label>
|
||||||
<label>Show<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}><option value="all">All accounts</option>{Object.entries(labels).map(([state, label]) => <option key={state} value={state}>{label}</option>)}</select></label>
|
Find an account
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Username or user ID"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Show
|
||||||
|
<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}>
|
||||||
|
<option value="all">All accounts</option>
|
||||||
|
{Object.entries(labels).map(([state, label]) => (
|
||||||
|
<option key={state} value={state}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="identity-selection">
|
<div className="identity-selection">
|
||||||
<span>{filtered.length} accounts shown · {selected.length} selected</span>
|
<span>
|
||||||
<button type="button" className="ghost-button" disabled={saving || !eligible.length} onClick={() => { setSelected((current) => [...new Set([...current, ...eligible])]); setReviewing(false) }}>Select ready accounts shown</button>
|
{filtered.length} accounts shown · {selected.length} selected
|
||||||
<button type="button" className="ghost-button" disabled={saving || !selected.length} onClick={() => { setSelected([]); setReviewing(false) }}>Clear selection</button>
|
</span>
|
||||||
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>Review selected links ({selected.length})</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={saving || !eligible.length}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected((current) => [...new Set([...current, ...eligible])]);
|
||||||
|
setReviewing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select ready accounts shown
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={saving || !selected.length}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected([]);
|
||||||
|
setReviewing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>
|
||||||
|
Review selected links ({selected.length})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
|
{reviewing && (
|
||||||
|
<section
|
||||||
|
className="identity-confirm-panel"
|
||||||
|
ref={reviewPanel}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Review links before saving"
|
||||||
|
>
|
||||||
<h2>Save these {selected.length} account links?</h2>
|
<h2>Save these {selected.length} account links?</h2>
|
||||||
<p>Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live IDs will be checked again before saving.</p>
|
<p>
|
||||||
<ul>{selectedRows.map((row) => <li key={row.user.id}><strong>{row.user.username}</strong> · Magent {row.user.id} → Jellyfin <code>{row.candidate_jellyfin_id}</code> → Seerr {row.seerr[0].id}</li>)}</ul>
|
Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live
|
||||||
<p>Saving links does not merge or delete accounts. Existing requests and playback history stay with their service IDs.</p>
|
IDs will be checked again before saving.
|
||||||
<div className="identity-confirm-actions"><button type="button" onClick={save} disabled={saving}>{saving ? 'Rechecking and saving…' : 'Confirm and save links'}</button><button type="button" className="ghost-button" disabled={saving} onClick={() => setReviewing(false)}>Back to review</button></div>
|
</p>
|
||||||
</section>}
|
<ul>
|
||||||
|
{selectedRows.map((row) => (
|
||||||
|
<li key={row.user.id}>
|
||||||
|
<strong>{row.user.username}</strong> · Magent {row.user.id} → Jellyfin{" "}
|
||||||
|
<code>{row.candidate_jellyfin_id}</code> → Seerr {row.seerr[0].id}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Saving links does not merge or delete accounts. Existing requests and playback history stay with
|
||||||
|
their service IDs.
|
||||||
|
</p>
|
||||||
|
<div className="identity-confirm-actions">
|
||||||
|
<button type="button" onClick={save} disabled={saving}>
|
||||||
|
{saving ? "Rechecking and saving…" : "Confirm and save links"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setReviewing(false)}
|
||||||
|
>
|
||||||
|
Back to review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<section className="identity-accounts" aria-label="Account identity results">
|
<section className="identity-accounts" aria-label="Account identity results">
|
||||||
{!filtered.length && <p>No accounts match these filters.</p>}
|
{!filtered.length && <p>No accounts match these filters.</p>}
|
||||||
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
|
{filtered.map((row) => (
|
||||||
<header><div className="identity-account-name">{row.can_confirm && <input type="checkbox" aria-label={`Select ${row.user.username} (Magent ${row.user.id})`} checked={selected.includes(row.user.id)} disabled={saving} onChange={() => toggle(row.user.id)} />}<div><h2>{row.user.username}</h2><span>Magent {row.user.id} · {row.user.auth_provider === 'jellyseerr' ? 'Seerr' : row.user.auth_provider} sign-in</span></div></div><span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span></header>
|
<article className="identity-account" key={row.user.id}>
|
||||||
<dl className="identity-mapping">
|
<header>
|
||||||
<div><dt>Jellyfin user ID</dt><dd><code>{row.candidate_jellyfin_id ?? 'No match'}</code>{row.jellyfin && <span>{row.jellyfin.name}</span>}<small>{basisLabels[row.basis]}</small>{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && <small>Stored: {row.stored_jellyfin_id}</small>}</dd></div>
|
<div className="identity-account-name">
|
||||||
<div><dt>Seerr user ID</dt><dd><strong>{row.seerr.length ? row.seerr.map((entry) => entry.id).join(', ') : 'No match'}</strong><span>{row.seerr.map((entry) => entry.name).join(', ')}</span><small>Stored in Magent: {row.user.jellyseerr_user_id ?? 'Not linked'}</small></dd></div>
|
{row.can_confirm && (
|
||||||
<div><dt>Jellystat user ID</dt><dd><code>{row.jellystat.id ?? 'Not verified'}</code><span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span></dd></div>
|
<input
|
||||||
</dl>
|
type="checkbox"
|
||||||
{row.issues.length > 0 && <ul className="identity-issues">{row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
aria-label={`Select ${row.user.username} (Magent ${row.user.id})`}
|
||||||
{(row.state === 'unlinked' || row.state === 'conflict') && <div className="identity-resolution-entry"><p className="identity-meta">Compare the correct Jellyfin identity with the stored links and review the smallest safe repair.</p><button type="button" className="ghost-button" disabled={saving || report.services.jellyfin !== 'available'} onClick={() => setResolving(row)}>Review repair</button>{row.issues.some((issue) => issue.includes("share this username")) && <button type="button" className="ghost-button" disabled={saving} onClick={() => setDuplicates(row)}>Repair duplicate accounts</button>}</div>}
|
checked={selected.includes(row.user.id)}
|
||||||
{row.state === 'unavailable' && <p className="identity-meta">A required service could not be checked. Check its connection and run this again.</p>}
|
disabled={saving}
|
||||||
{row.confirmed_at && <p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>}
|
onChange={() => toggle(row.user.id)}
|
||||||
</article>)}
|
/>
|
||||||
</section>
|
)}
|
||||||
{report.upstream.length > 0 && <details className="identity-upstream"><summary>{report.upstream.length} upstream accounts need review</summary><ul>{report.upstream.map((entry) => <li key={`${entry.platform}-${entry.id}`}><strong>{entry.platform}: {entry.name}</strong> · ID <code>{entry.id}</code>{entry.jellyfin_id && <span> · Jellyfin <code>{entry.jellyfin_id}</code></span>}<p>{entry.detail}</p></li>)}</ul></details>}
|
<div>
|
||||||
</>}
|
<h2>{row.user.username}</h2>
|
||||||
</>}
|
<span>
|
||||||
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />}
|
Magent {row.user.id} ·{" "}
|
||||||
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => {
|
{row.user.auth_provider === "jellyseerr" ? "Seerr" : row.user.auth_provider} sign-in
|
||||||
setResolving(null); setReport(null); setSelected([]); setReviewing(false)
|
</span>
|
||||||
setNotice('Account links repaired and saved. Run another check to see the updated mappings.')
|
|
||||||
}} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
<span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span>
|
||||||
|
</header>
|
||||||
|
<dl className="identity-mapping">
|
||||||
|
<div>
|
||||||
|
<dt>Jellyfin user ID</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{row.candidate_jellyfin_id ?? "No match"}</code>
|
||||||
|
{row.jellyfin && <span>{row.jellyfin.name}</span>}
|
||||||
|
<small>{basisLabels[row.basis]}</small>
|
||||||
|
{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && (
|
||||||
|
<small>Stored: {row.stored_jellyfin_id}</small>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Seerr user ID</dt>
|
||||||
|
<dd>
|
||||||
|
<strong>
|
||||||
|
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(", ") : "No match"}
|
||||||
|
</strong>
|
||||||
|
<span>{row.seerr.map((entry) => entry.name).join(", ")}</span>
|
||||||
|
<small>Stored in Magent: {row.user.jellyseerr_user_id ?? "Not linked"}</small>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Jellystat user ID</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{row.jellystat.id ?? "Not verified"}</code>
|
||||||
|
<span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{row.issues.length > 0 && (
|
||||||
|
<ul className="identity-issues">
|
||||||
|
{row.issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{(row.state === "unlinked" || row.state === "conflict") && (
|
||||||
|
<div className="identity-resolution-entry">
|
||||||
|
<p className="identity-meta">
|
||||||
|
Compare the correct Jellyfin identity with the stored links and review the smallest safe
|
||||||
|
repair.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={saving || report.services.jellyfin !== "available"}
|
||||||
|
onClick={() => setResolving(row)}
|
||||||
|
>
|
||||||
|
Review repair
|
||||||
|
</button>
|
||||||
|
{row.issues.some(
|
||||||
|
(issue) =>
|
||||||
|
issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username"),
|
||||||
|
) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setDuplicates(row)}
|
||||||
|
>
|
||||||
|
Repair duplicate accounts
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.state === "unavailable" && (
|
||||||
|
<p className="identity-meta">
|
||||||
|
A required service could not be checked. Check its connection and run this again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{row.confirmed_at && (
|
||||||
|
<p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
{report.upstream.length > 0 && (
|
||||||
|
<details className="identity-upstream">
|
||||||
|
<summary>{report.upstream.length} upstream accounts need review</summary>
|
||||||
|
<ul>
|
||||||
|
{report.upstream.map((entry) => (
|
||||||
|
<li key={`${entry.platform}-${entry.id}`}>
|
||||||
|
<strong>
|
||||||
|
{entry.platform}: {entry.name}
|
||||||
|
</strong>{" "}
|
||||||
|
· ID <code>{entry.id}</code>
|
||||||
|
{entry.jellyfin_id && (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
· Jellyfin <code>{entry.jellyfin_id}</code>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p>{entry.detail}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{duplicates && (
|
||||||
|
<DuplicateAccountRepair
|
||||||
|
row={duplicates}
|
||||||
|
onClose={() => setDuplicates(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setDuplicates(null);
|
||||||
|
void runCheck().then(() => setNotice("Duplicate accounts repaired. History retained and links rechecked."));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{resolving && report && (
|
||||||
|
<ResolveIdentityLink
|
||||||
|
row={resolving}
|
||||||
|
accounts={report.jellyfin_users}
|
||||||
|
onClose={() => setResolving(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setResolving(null);
|
||||||
|
setReport(null);
|
||||||
|
setSelected([]);
|
||||||
|
setReviewing(false);
|
||||||
|
setNotice("Account links repaired and saved. Run another check to see the updated mappings.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,290 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { authFetch, getApiBase } from '../../lib/auth'
|
import { authFetch, getApiBase } from "../../lib/auth";
|
||||||
import type { Row } from './IdentityReviewPanel'
|
import type { Row } from "./IdentityReviewPanel";
|
||||||
|
|
||||||
type Preview = {
|
type Preview = {
|
||||||
revision: string; server_id: string; row: Row
|
revision: string;
|
||||||
before: { jellyfin_user_id: string | null; seerr_user_id: number | null }
|
server_id: string;
|
||||||
seerr_users: { id: number; name: string; jellyfin_id: string | null }[]
|
row: Row;
|
||||||
scope: string
|
before: { jellyfin_user_id: string | null; seerr_user_id: number | null };
|
||||||
action: string
|
seerr_users: { id: number; name: string; jellyfin_id: string | null }[];
|
||||||
}
|
scope: string;
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ResolveIdentityLink({ row, accounts, onClose, onSaved }: {
|
export default function ResolveIdentityLink({
|
||||||
row: Row; accounts: { id: string; name: string }[]; onClose: () => void; onSaved: () => void
|
row,
|
||||||
|
accounts,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
row: Row;
|
||||||
|
accounts: { id: string; name: string }[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const dialog = useRef<HTMLDialogElement>(null)
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
const controller = useRef<AbortController | null>(null)
|
const controller = useRef<AbortController | null>(null);
|
||||||
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? '')
|
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? "");
|
||||||
const [inspectSeerr, setInspectSeerr] = useState('')
|
const [inspectSeerr, setInspectSeerr] = useState("");
|
||||||
const [createSeerr, setCreateSeerr] = useState(false)
|
const [createSeerr, setCreateSeerr] = useState(false);
|
||||||
const [preview, setPreview] = useState<Preview | null>(null)
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false);
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = document.activeElement as HTMLElement | null
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
const overflow = document.body.style.overflow
|
const overflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = "hidden";
|
||||||
dialog.current?.showModal()
|
dialog.current?.showModal();
|
||||||
return () => {
|
return () => {
|
||||||
controller.current?.abort()
|
controller.current?.abort();
|
||||||
document.body.style.overflow = overflow
|
document.body.style.overflow = overflow;
|
||||||
previous?.focus()
|
previous?.focus();
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const submit = async (confirm: boolean) => {
|
const submit = async (confirm: boolean) => {
|
||||||
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return
|
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return;
|
||||||
const abort = new AbortController()
|
const abort = new AbortController();
|
||||||
controller.current = abort
|
controller.current = abort;
|
||||||
setError('')
|
setError("");
|
||||||
if (confirm) setSaving(true)
|
if (confirm) setSaving(true);
|
||||||
else { setBusy(true); setPreview(null) }
|
else {
|
||||||
|
setBusy(true);
|
||||||
|
setPreview(null);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? 'confirm' : 'check'}`, {
|
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? "confirm" : "check"}`, {
|
||||||
method: 'POST', signal: abort.signal, headers: { 'Content-Type': 'application/json' },
|
method: "POST",
|
||||||
body: JSON.stringify({ user_id: row.user.id, jellyfin_user_id: chosen, create_seerr: createSeerr, ...(confirm ? { revision: preview?.revision } : {}) }),
|
signal: abort.signal,
|
||||||
})
|
headers: { "Content-Type": "application/json" },
|
||||||
const data = await response.json().catch(() => ({}))
|
body: JSON.stringify({
|
||||||
if (!response.ok) throw new Error(response.status === 401 ? 'Your session has ended. Sign in again.' : typeof data.detail === 'string' ? data.detail : 'Could not check the account links. Try again.')
|
user_id: row.user.id,
|
||||||
|
jellyfin_user_id: chosen,
|
||||||
|
create_seerr: createSeerr,
|
||||||
|
...(confirm ? { revision: preview?.revision } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
response.status === 401
|
||||||
|
? "Your session has ended. Sign in again."
|
||||||
|
: typeof data.detail === "string"
|
||||||
|
? data.detail
|
||||||
|
: "Could not check the account links. Try again.",
|
||||||
|
);
|
||||||
if (!abort.signal.aborted) {
|
if (!abort.signal.aborted) {
|
||||||
if (confirm) onSaved()
|
if (confirm) onSaved();
|
||||||
else setPreview(data)
|
else setPreview(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!abort.signal.aborted) {
|
if (!abort.signal.aborted) {
|
||||||
setError(err instanceof Error ? err.message : 'Could not resolve the link.')
|
setError(err instanceof Error ? err.message : "Could not resolve the link.");
|
||||||
setPreview(null)
|
setPreview(null);
|
||||||
}
|
}
|
||||||
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
|
} finally {
|
||||||
|
if (!abort.signal.aborted) {
|
||||||
|
setBusy(false);
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return <dialog ref={dialog} className="identity-resolve-dialog" aria-labelledby="resolve-title" onCancel={(event) => { event.preventDefault(); if (!saving) onClose() }}>
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
className="identity-resolve-dialog"
|
||||||
|
aria-labelledby="resolve-title"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!saving) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="identity-resolve-content">
|
<div className="identity-resolve-content">
|
||||||
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header>
|
<header>
|
||||||
<p>Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm that these identities belong to the same person before repairing Magent.</p>
|
<h2 id="resolve-title">Review account repair</h2>
|
||||||
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => {
|
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
|
||||||
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value)
|
Close
|
||||||
}}><option value="">Choose an account</option>{[...accounts].sort((a, b) => a.name.localeCompare(b.name)).map((account) => <option key={account.id} value={account.id}>{account.name} — {account.id}</option>)}</select></label>
|
</button>
|
||||||
<label className="identity-import-option"><span><input type="checkbox" checked={createSeerr} disabled={busy || saving} onChange={(event) => { setCreateSeerr(event.target.checked); setPreview(null) }} /> This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.</span></label>
|
</header>
|
||||||
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>{busy ? 'Checking all platform links…' : 'Preview repair'}</button>
|
<p>
|
||||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm
|
||||||
|
that these identities belong to the same person before repairing Magent.
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
Jellyfin account
|
||||||
|
<select
|
||||||
|
value={chosen}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => {
|
||||||
|
controller.current?.abort();
|
||||||
|
setBusy(false);
|
||||||
|
setPreview(null);
|
||||||
|
setError("");
|
||||||
|
setCreateSeerr(false);
|
||||||
|
setChosen(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Choose an account</option>
|
||||||
|
{[...accounts]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name} — {account.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="identity-import-option">
|
||||||
|
<span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createSeerr}
|
||||||
|
disabled={busy || saving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCreateSeerr(event.target.checked);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>{" "}
|
||||||
|
This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>
|
||||||
|
{busy ? "Checking all platform links…" : "Preview repair"}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="error-banner" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
|
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
|
||||||
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
|
{preview && (
|
||||||
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3>
|
<section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
|
||||||
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p>
|
<h3>{preview.row.can_confirm ? "Ready to repair" : "This link needs attention"}</h3>
|
||||||
|
<p className="identity-meta">
|
||||||
|
Jellyfin server <code>{preview.server_id ?? "Unavailable"}</code>
|
||||||
|
</p>
|
||||||
<div className="identity-mapping">
|
<div className="identity-mapping">
|
||||||
<div><strong>Current Magent links</strong><p>Jellyfin: <code>{preview.before.jellyfin_user_id ?? 'Not linked'}</code></p><p>Seerr: {preview.before.seerr_user_id ?? 'Not linked'}</p></div>
|
<div>
|
||||||
<div><strong>Proposed Magent links</strong><p>Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code></p><p>Seerr: {preview.row.seerr.length === 1 ? preview.row.seerr[0].id : preview.action === 'import_seerr' ? 'Assigned by Seerr during import' : 'Not verified'}</p></div>
|
<strong>Current Magent links</strong>
|
||||||
|
<p>
|
||||||
|
Jellyfin: <code>{preview.before.jellyfin_user_id ?? "Not linked"}</code>
|
||||||
|
</p>
|
||||||
|
<p>Seerr: {preview.before.seerr_user_id ?? "Not linked"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Proposed Magent links</strong>
|
||||||
|
<p>
|
||||||
|
Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Seerr:{" "}
|
||||||
|
{preview.row.seerr.length === 1
|
||||||
|
? preview.row.seerr[0].id
|
||||||
|
: preview.action === "import_seerr"
|
||||||
|
? "Assigned by Seerr during import"
|
||||||
|
: "Not verified"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className="identity-mapping">
|
<dl className="identity-mapping">
|
||||||
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div>
|
<div>
|
||||||
<div><dt>Seerr</dt><dd>{preview.row.seerr.length ? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(', ') : 'No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again.'}</dd></div>
|
<dt>Jellyfin</dt>
|
||||||
<div><dt>Jellystat</dt><dd><code>{preview.row.jellystat.id ?? 'Not verified'}</code>{preview.row.jellystat.state === 'matched' ? 'Same Jellyfin ID verified' : preview.row.jellystat.state === 'missing' ? 'This ID is missing from Jellystat. Check its Jellyfin sync, then check again.' : 'Could not verify this ID. Check the Jellystat connection and try again.'}</dd></div>
|
<dd>
|
||||||
|
{preview.row.jellyfin?.name ?? "Account not found"}
|
||||||
|
<code>{preview.row.candidate_jellyfin_id}</code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Seerr</dt>
|
||||||
|
<dd>
|
||||||
|
{preview.row.seerr.length
|
||||||
|
? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(", ")
|
||||||
|
: "No matching Jellyfin ID. Check this user’s Jellyfin account link in Seerr, then check again."}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Jellystat</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{preview.row.jellystat.id ?? "Not verified"}</code>
|
||||||
|
{preview.row.jellystat.state === "matched"
|
||||||
|
? "Same Jellyfin ID verified"
|
||||||
|
: preview.row.jellystat.state === "missing"
|
||||||
|
? "This ID is missing from Jellystat. Check its Jellyfin sync, then check again."
|
||||||
|
: "Could not verify this ID. Check the Jellystat connection and try again."}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>}
|
{preview.row.issues.length > 0 && (
|
||||||
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>}
|
<ul className="identity-issues">
|
||||||
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance">
|
{preview.row.issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{preview.row.state === "unavailable" && (
|
||||||
|
<p>A required service is unavailable. Restore its connection and check again.</p>
|
||||||
|
)}
|
||||||
|
{preview.row.seerr.length !== 1 && (
|
||||||
|
<div className="identity-upstream-guidance">
|
||||||
<h3>Check the existing Seerr account</h3>
|
<h3>Check the existing Seerr account</h3>
|
||||||
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
|
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
|
||||||
<label>Seerr account to inspect<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}><option value="">Choose an existing account</option>{preview.seerr_users.map((account) => <option key={account.id} value={account.id}>{account.name} (ID {account.id})</option>)}</select></label>
|
<label>
|
||||||
{preview.seerr_users.filter((account) => String(account.id) === inspectSeerr).map((account) => <p key={account.id}>Current Jellyfin ID: <code>{account.jellyfin_id ?? 'Not linked'}</code></p>)}
|
Seerr account to inspect
|
||||||
<p>If this is the same person, use Seerr's account settings to reconnect their existing account to Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the existing Seerr account to preserve its requests and settings.</p>
|
<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}>
|
||||||
<p>If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page, then preview again. Do not import a second account to work around an existing identity mismatch.</p>
|
<option value="">Choose an existing account</option>
|
||||||
</div>}
|
{preview.seerr_users.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name} (ID {account.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{preview.seerr_users
|
||||||
|
.filter((account) => String(account.id) === inspectSeerr)
|
||||||
|
.map((account) => (
|
||||||
|
<p key={account.id}>
|
||||||
|
Current Jellyfin ID: <code>{account.jellyfin_id ?? "Not linked"}</code>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<p>
|
||||||
|
If this is the same person, use Seerr's account settings to reconnect their existing account to
|
||||||
|
Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the
|
||||||
|
existing Seerr account to preserve its requests and settings.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page,
|
||||||
|
then preview again. Do not import a second account to work around an existing identity mismatch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p>{preview.scope}</p>
|
<p>{preview.scope}</p>
|
||||||
<p>Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate ownership are rechecked before the change is saved.</p>
|
<p>
|
||||||
{preview.before.jellyfin_user_id && preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && <p>Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to opt in again.</p>}
|
Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate
|
||||||
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and saving…' : preview.action === 'import_seerr' ? 'Import Seerr account and repair links' : 'Confirm repair'}</button>
|
ownership are rechecked before the change is saved.
|
||||||
</section>}
|
</p>
|
||||||
|
{preview.before.jellyfin_user_id &&
|
||||||
|
preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && (
|
||||||
|
<p>
|
||||||
|
Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to
|
||||||
|
opt in again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>
|
||||||
|
{saving
|
||||||
|
? "Rechecking and saving…"
|
||||||
|
: preview.action === "import_seerr"
|
||||||
|
? "Import Seerr account and repair links"
|
||||||
|
: "Confirm repair"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user