Compare commits

..
20 Commits
Author SHA1 Message Date
Assclaw 0f4071c970 fix(auth): accept configured public origin for sign-in
Magent CI/CD / verify (push) Successful in 4m15s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-19 12:26:09 +12:00
Assclaw a6a4a9aa24 fix: apply CSP nonce to Next scripts
Magent CI/CD / verify (push) Successful in 4m41s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:51:23 +12:00
Assclaw 91a950a3b0 fix: enforce isolated beta deployment path
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Successful in 1m40s
2026-09-17 22:41:34 +12:00
Assclaw 2525a9eb25 fix: preserve readable deployment source modes
Magent CI/CD / verify (push) Successful in 3m36s
Magent CI/CD / deploy-beta (push) Successful in 1m35s
2026-09-17 22:34:17 +12:00
Assclaw 8a6fe71446 fix: permit beta data ownership setup
Magent CI/CD / verify (push) Successful in 3m39s
Magent CI/CD / deploy-beta (push) Failing after 1m44s
2026-09-17 22:27:07 +12:00
Assclaw 6ab79efc35 fix: use Docker volume for CI smoke data
Magent CI/CD / verify (push) Successful in 3m4s
Magent CI/CD / deploy-beta (push) Failing after 1m53s
2026-09-17 22:11:28 +12:00
Assclaw f852e7c941 chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 20:03:47 +12:00
Assclaw 5639dbcb83 security: harden data auth and deployment 2026-09-17 18:31:35 +12:00
Assclaw a6d1c73837 chore: patch vulnerable dependencies
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-17 15:22:26 +12:00
Assclaw aed1bf9256 feat: add seasons from request details
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-16 17:15:40 +12:00
Assclaw 05a540ecbb fix: keep site banner off login page
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 56s
2026-09-16 14:37:00 +12:00
Assclaw d75f36c691 feat: customize site banners and login notices
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-16 14:22:38 +12:00
Assclaw 3465343a69 feat: route ready-title issues through guided workflow
Magent CI/CD / verify (push) Successful in 1m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-15 15:46:09 +12:00
Assclaw 4ba1a5763e feat: simplify ready requests and add global search
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 48s
2026-09-15 15:15:35 +12:00
Assclaw dd51332f3c Wait for Arr download queue hand-off before reporting search outcome
Magent CI/CD / verify (push) Successful in 2m10s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-15 14:15:24 +12:00
Assclaw 6a84e68a03 Show request submission progress in a focused follow-up dialog
Magent CI/CD / verify (push) Successful in 1m53s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 55s
2026-09-15 14:02:08 +12:00
Assclaw c194db167a Illustrate monthly reports and add viewing pattern breakdowns
Magent CI/CD / verify (push) Successful in 1m48s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 49s
2026-09-13 22:21:02 +12:00
Assclaw e232335ca9 Organize diagnostics into horizontal rows and group notification controls
Magent CI/CD / verify (push) Successful in 1m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 50s
2026-09-13 22:08:41 +12:00
Assclaw 8df02fdfd7 Feather the top edge of request artwork
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 53s
2026-09-13 21:57:26 +12:00
Assclaw ce756c1a65 Restore monitoring on recheck and prevent release-search proxy timeouts
Magent CI/CD / verify (push) Successful in 2m1s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
2026-09-13 19:47:36 +12:00
165 changed files with 21156 additions and 11107 deletions
+20
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
# 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
ADMIN_PASSWORD=replace-with-a-strong-bootstrap-password
AUTH_COOKIE_SECURE=false
AUTH_COOKIE_SAMESITE=strict
API_DOCS_ENABLED=false
+41 -39
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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"]
+10
View File
@@ -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.
+44 -9
View File
@@ -66,8 +66,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 +115,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 +136,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 +162,41 @@ 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.
- `SETTINGS_ENCRYPTION_KEY` protects service API keys, SMTP credentials, webhooks, and private keys stored in SQLite. Keep it in `.env`, outside the database and its backups. If omitted, Magent derives a migration-compatible key from `JWT_SECRET`; a dedicated key is recommended.
- Invite secrets are stored as one-way hashes. Existing invite links continue to work after migration, but the admin UI cannot reveal an old link. Copy a link when it is created, or generate a replacement link later; replacement immediately invalidates the prior link.
- Magent encrypts sensitive settings, not the entire SQLite database. Request metadata, account records, logs, the `data/` volume, and backups should live on encrypted host storage with access restricted to the deployment account.
- `REQUESTS_CLEANUP_DAYS` controls routine request-history retention (90 days by default). Account deletion removes authentication and subscription records and anonymizes retained request and portal history.
- Production and beta cookies require HTTPS and use `SameSite=Strict`. Keep the backend port bound to loopback and publish the frontend only through the intended reverse proxy.
## History endpoints ## History endpoints
+58
View File
@@ -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()
+4
View File
@@ -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),
} }
-7
View File
@@ -1,7 +1,6 @@
import re 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
@@ -186,7 +185,6 @@ 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 = {
@@ -214,7 +212,6 @@ class JellyfinClient(ApiClient):
if isinstance(item, dict) and item.get('Id'): if isinstance(item, dict) and item.get('Id'):
items[item['Id']] = item items[item['Id']] = item
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)} result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
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,
@@ -223,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,
@@ -277,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()
@@ -286,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,
@@ -294,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,
-10
View File
@@ -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,
+28 -2
View File
@@ -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")
@@ -42,7 +58,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 +67,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")
@@ -108,6 +125,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")
) )
+470 -217
View File
@@ -1,15 +1,19 @@
import json import json
import hmac
import os import os
import sqlite3 import sqlite3
import logging import logging
from contextlib import suppress
from hashlib import sha256 from hashlib import sha256
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from time import perf_counter from time import perf_counter, time as unix_time
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from .config import settings from .config import settings
from .models import Snapshot from .models import Snapshot
from .security import hash_password, verify_password from .security import hash_password, verify_and_update_password, verify_password
from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
from .schema_migrations import run_schema_migrations
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +34,10 @@ def _db_path() -> str:
if not os.path.isabs(path): if not os.path.isabs(path):
app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
path = os.path.join(app_root, path) path = os.path.join(app_root, path)
os.makedirs(os.path.dirname(path), exist_ok=True) directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
return path return path
@@ -53,12 +60,23 @@ def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True) logger.debug("sqlite pragma skipped: %s=%s", pragma, value, exc_info=True)
class _ClosingConnection(sqlite3.Connection):
def __exit__(self, exc_type, exc_value, traceback) -> bool:
try:
return super().__exit__(exc_type, exc_value, traceback)
finally:
self.close()
def _connect() -> sqlite3.Connection: def _connect() -> sqlite3.Connection:
conn = sqlite3.connect( conn = sqlite3.connect(
_db_path(), _db_path(),
timeout=SQLITE_BUSY_TIMEOUT_MS / 1000, timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
cached_statements=512, cached_statements=512,
factory=_ClosingConnection,
) )
with suppress(OSError):
os.chmod(_db_path(), 0o600)
_apply_connection_pragmas(conn) _apply_connection_pragmas(conn)
return conn return conn
@@ -185,6 +203,63 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
return bool(password and password != _DEFAULT_ADMIN_PASSWORD) return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
_INVITE_HASH_PREFIX = "sha256:"
def _normalize_invite_secret(value: str) -> str:
return "".join(character for character in str(value or "").strip().upper() if character.isalnum())
def _hash_signup_invite_code(value: str) -> str:
normalized = _normalize_invite_secret(value)
return _INVITE_HASH_PREFIX + sha256(normalized.encode("utf-8")).hexdigest()
def _invite_code_hint(value: str) -> str:
normalized = _normalize_invite_secret(value)
return normalized[-4:] if normalized else ""
def _masked_invite_code(hint: Optional[str]) -> str:
return f"••••{str(hint or '').upper()}" if hint else "Protected invite"
def _protect_legacy_signup_invite_codes(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT id, code, code_hint FROM signup_invites ORDER BY id"
).fetchall()
for invite_id, stored_code, stored_hint in rows:
if not isinstance(stored_code, str) or stored_code.startswith(_INVITE_HASH_PREFIX):
continue
code_hash = _hash_signup_invite_code(stored_code)
duplicate = conn.execute(
"SELECT id FROM signup_invites WHERE code = ? AND id != ?",
(code_hash, invite_id),
).fetchone()
if duplicate:
code_hash = _INVITE_HASH_PREFIX + sha256(
f"duplicate:{invite_id}:{stored_code}".encode("utf-8")
).hexdigest()
conn.execute(
"UPDATE users SET invited_by_code = ? WHERE invited_by_code = ? COLLATE NOCASE",
(f"invite:{invite_id}", stored_code),
)
conn.execute(
"UPDATE signup_invites SET code = ?, code_hint = ? WHERE id = ?",
(code_hash, stored_hint or _invite_code_hint(stored_code), invite_id),
)
def _encrypt_legacy_sensitive_settings(conn: sqlite3.Connection) -> None:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
for key, value in rows:
if value is None or not is_sensitive_setting(str(key)):
continue
encrypted = encrypt_setting_value(str(key), str(value))
if encrypted != value:
conn.execute("UPDATE settings SET value = ? WHERE key = ?", (encrypted, key))
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)")
@@ -278,7 +353,8 @@ def init_db() -> None:
invited_by_code TEXT, invited_by_code TEXT,
invited_at TEXT, invited_at TEXT,
jellyfin_password_hash TEXT, jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT last_jellyfin_auth_at TEXT,
auth_version INTEGER NOT NULL DEFAULT 1
) )
""" """
) )
@@ -297,6 +373,18 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS auth_rate_limits (
scope TEXT NOT NULL,
key_hash TEXT NOT NULL,
occurred_at REAL NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_auth_rate_limits_lookup ON auth_rate_limits (scope, key_hash, occurred_at)"
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS signup_invites ( CREATE TABLE IF NOT EXISTS signup_invites (
@@ -599,153 +687,9 @@ def init_db() -> None:
ON user_activity (last_seen_at) ON user_activity (last_seen_at)
""" """
) )
try: run_schema_migrations(conn)
conn.execute("ALTER TABLE users ADD COLUMN email TEXT") _protect_legacy_signup_invite_codes(conn)
except sqlite3.OperationalError: _encrypt_legacy_sensitive_settings(conn)
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_profile_id
ON users (profile_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_expires_at
ON users (expires_at)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_username_nocase
ON users (username COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
ON requests_cache (requested_by_id)
"""
)
except sqlite3.OperationalError:
pass
try: try:
conn.execute("PRAGMA optimize") conn.execute("PRAGMA optimize")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -1142,7 +1086,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
ORDER BY id ORDER BY id
@@ -1171,6 +1115,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
@@ -1181,7 +1126,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE jellyseerr_user_id = ? WHERE jellyseerr_user_id = ?
ORDER BY id ASC ORDER BY id ASC
@@ -1211,6 +1156,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
@@ -1221,7 +1167,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE id = ? WHERE id = ?
""", """,
@@ -1249,6 +1195,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
def get_all_users() -> list[Dict[str, Any]]: def get_all_users() -> list[Dict[str, Any]]:
@@ -1257,7 +1204,7 @@ def get_all_users() -> list[Dict[str, Any]]:
""" """
SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at, SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at,
last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
profile_id, expires_at, invited_by_code, invited_at profile_id, expires_at, invited_by_code, invited_at, auth_version
FROM users FROM users
ORDER BY username COLLATE NOCASE ORDER BY username COLLATE NOCASE
""" """
@@ -1281,6 +1228,7 @@ def get_all_users() -> list[Dict[str, Any]]:
"expires_at": row[12], "expires_at": row[12],
"invited_by_code": row[13], "invited_by_code": row[13],
"invited_at": row[14], "invited_at": row[14],
"auth_version": int(row[15] or 1),
"is_expired": _is_datetime_in_past(row[12]), "is_expired": _is_datetime_in_past(row[12]),
} }
) )
@@ -1375,24 +1323,181 @@ def set_user_blocked(username: str, blocked: bool) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE users SET is_blocked = ? WHERE username = ? UPDATE users SET is_blocked = ?, auth_version = auth_version + 1 WHERE username = ?
""", """,
(1 if blocked else 0, username), (1 if blocked else 0, username),
) )
logger.info("user blocked state updated username=%s blocked=%s", username, blocked) logger.info("user blocked state updated username=%s blocked=%s", username, blocked)
def delete_user_by_username(username: str) -> bool: def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return bool(row)
def _redact_user_json(value: Any, identifiers: set[str]) -> Any:
if isinstance(value, dict):
return {key: _redact_user_json(item, identifiers) for key, item in value.items()}
if isinstance(value, list):
return [_redact_user_json(item, identifiers) for item in value]
if isinstance(value, str) and value.strip().casefold() in identifiers:
return "Deleted user"
return value
def delete_user_data_by_username(username: str) -> Dict[str, int | bool]:
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( user = conn.execute(
""" "SELECT id, username, email FROM users WHERE username = ? COLLATE NOCASE",
DELETE FROM users WHERE username = ? COLLATE NOCASE
""",
(username,), (username,),
).fetchone()
if not user:
return {"deleted": False}
user_id, canonical_username, email = int(user[0]), str(user[1]), user[2]
pseudonym = f"deleted-user-{user_id}"
identifiers = {canonical_username.casefold()}
if isinstance(email, str) and email.strip():
identifiers.add(email.strip().casefold())
counts: Dict[str, int | bool] = {"deleted": False}
request_rows = conn.execute(
"""
SELECT request_id, payload_json FROM requests_cache
WHERE requested_by_id = ? OR requested_by_norm = ? OR requested_by = ? COLLATE NOCASE
""",
(user_id, canonical_username.casefold(), canonical_username),
).fetchall()
for request_id, payload_json in request_rows:
try:
payload = _redact_user_json(json.loads(payload_json), identifiers)
sanitized_payload = json.dumps(payload, separators=(",", ":"))
except (TypeError, json.JSONDecodeError):
sanitized_payload = "{}"
conn.execute(
"""
UPDATE requests_cache
SET requested_by = 'Deleted user', requested_by_norm = NULL,
requested_by_id = NULL, payload_json = ?
WHERE request_id = ?
""",
(sanitized_payload, request_id),
)
snapshot_rows = conn.execute(
"SELECT id, payload_json FROM snapshots WHERE request_id = ?",
(str(request_id),),
).fetchall()
for snapshot_id, snapshot_json in snapshot_rows:
try:
snapshot_payload = _redact_user_json(
json.loads(snapshot_json), identifiers
)
sanitized_snapshot = json.dumps(
snapshot_payload, separators=(",", ":")
)
except (TypeError, json.JSONDecodeError):
sanitized_snapshot = "{}"
conn.execute(
"UPDATE snapshots SET payload_json = ? WHERE id = ?",
(sanitized_snapshot, snapshot_id),
)
conn.execute(
"UPDATE actions SET message = REPLACE(message, ?, 'Deleted user') WHERE request_id = ? AND message IS NOT NULL",
(canonical_username, str(request_id)),
)
if email:
conn.execute(
"UPDATE actions SET message = REPLACE(message, ?, '[deleted email]') WHERE request_id = ? AND message IS NOT NULL",
(email, str(request_id)),
)
counts["requests_anonymized"] = len(request_rows)
direct_operations = (
("DELETE FROM user_activity WHERE username = ? COLLATE NOCASE", (canonical_username,), "activity_deleted"),
("DELETE FROM password_reset_tokens WHERE username = ? COLLATE NOCASE", (canonical_username,), "reset_tokens_deleted"),
("DELETE FROM user_feature_permissions WHERE user_id = ?", (user_id,), "feature_rows_deleted"),
("DELETE FROM jellyfin_user_links WHERE local_user_id = ?", (user_id,), "identity_links_deleted"),
("DELETE FROM user_identity_confirmations WHERE local_user_id = ?", (user_id,), "identity_confirmations_deleted"),
("DELETE FROM user_identity_repairs WHERE local_user_id = ?", (user_id,), "identity_repairs_deleted"),
("DELETE FROM user_duplicate_repairs WHERE kept_user_id = ?", (user_id,), "duplicate_repairs_deleted"),
) )
deleted = cursor.rowcount > 0 for sql, params, label in direct_operations:
logger.warning("user delete username=%s deleted=%s", username, deleted) counts[label] = int(conn.execute(sql, params).rowcount or 0)
return deleted
conn.execute(
"UPDATE signup_invites SET enabled = 0, created_by = ? WHERE created_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
if email:
conn.execute(
"UPDATE signup_invites SET recipient_email = NULL WHERE recipient_email = ? COLLATE NOCASE",
(email,),
)
conn.execute(
"UPDATE portal_items SET created_by_username = ?, created_by_id = NULL WHERE created_by_id = ? OR created_by_username = ? COLLATE NOCASE",
(pseudonym, user_id, canonical_username),
)
conn.execute(
"UPDATE portal_items SET assignee_username = NULL WHERE assignee_username = ? COLLATE NOCASE",
(canonical_username,),
)
conn.execute(
"UPDATE portal_comments SET author_username = ? WHERE author_username = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE portal_item_activity SET actor_username = ? WHERE actor_username = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE user_identity_confirmations SET confirmed_by = ? WHERE confirmed_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
conn.execute(
"UPDATE user_identity_repairs SET repaired_by = ? WHERE repaired_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
duplicate_rows = conn.execute(
"SELECT id, archive_json FROM user_duplicate_repairs"
).fetchall()
for repair_id, archive_json in duplicate_rows:
try:
archive_payload = _redact_user_json(
json.loads(archive_json), identifiers
)
except (TypeError, json.JSONDecodeError):
continue
conn.execute(
"UPDATE user_duplicate_repairs SET archive_json = ?, repaired_by = CASE WHEN repaired_by = ? COLLATE NOCASE THEN ? ELSE repaired_by END WHERE id = ?",
(
json.dumps(archive_payload, separators=(",", ":")),
canonical_username,
pseudonym,
repair_id,
),
)
for table in ("email_recap_subscriptions", "email_recap_deliveries", "newsletter_subscriptions", "newsletter_deliveries"):
if _table_exists(conn, table):
counts[f"{table}_deleted"] = int(
conn.execute(f"DELETE FROM {table} WHERE user_id = ?", (user_id,)).rowcount or 0
)
if _table_exists(conn, "newsletter_editions"):
conn.execute(
"UPDATE newsletter_editions SET created_by = ? WHERE created_by = ? COLLATE NOCASE",
(pseudonym, canonical_username),
)
deleted = conn.execute("DELETE FROM users WHERE id = ?", (user_id,)).rowcount > 0
counts["deleted"] = deleted
logger.warning("user data deleted user_id=%s deleted=%s", user_id, deleted)
return counts
def delete_user_by_username(username: str) -> bool:
return bool(delete_user_data_by_username(username).get("deleted"))
def delete_user_activity_by_username(username: str) -> int: def delete_user_activity_by_username(username: str) -> int:
@@ -1424,7 +1529,7 @@ def set_user_role(username: str, role: str) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE UPDATE users SET role = ?, auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE
""", """,
(role, username), (role, username),
) )
@@ -1635,29 +1740,31 @@ def delete_user_profile(profile_id: int) -> bool:
def _row_to_signup_invite(row: Any) -> Dict[str, Any]: def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = 1 if row[10] else row[6] max_uses = 1 if row[11] else row[7]
use_count = int(row[7] or 0) use_count = int(row[8] or 0)
expires_at = row[9] expires_at = row[10]
is_expired = _is_datetime_in_past(expires_at) is_expired = _is_datetime_in_past(expires_at)
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0) remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
return { return {
"id": row[0], "id": row[0],
"code": row[1], "code": _masked_invite_code(row[2]),
"label": row[2], "code_hint": row[2],
"description": row[3], "code_available": False,
"profile_id": row[4], "label": row[3],
"role": row[5], "description": row[4],
"profile_id": row[5],
"role": row[6],
"max_uses": max_uses, "max_uses": max_uses,
"use_count": use_count, "use_count": use_count,
"enabled": bool(row[8]), "enabled": bool(row[9]),
"expires_at": expires_at, "expires_at": expires_at,
"recipient_email": row[10], "recipient_email": row[11],
"created_by": row[11], "created_by": row[12],
"created_at": row[12], "created_at": row[13],
"updated_at": row[13], "updated_at": row[14],
"is_expired": is_expired, "is_expired": is_expired,
"remaining_uses": remaining_uses, "remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0), "is_usable": bool(row[9]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
} }
@@ -1665,7 +1772,7 @@ def list_signup_invites() -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
@@ -1678,7 +1785,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
WHERE id = ? WHERE id = ?
@@ -1694,16 +1801,19 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, SELECT id, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
FROM signup_invites FROM signup_invites
WHERE code = ? COLLATE NOCASE WHERE code = ?
""", """,
(code,), (_hash_signup_invite_code(code),),
).fetchone() ).fetchone()
if not row: if not row:
return None return None
return _row_to_signup_invite(row) invite = _row_to_signup_invite(row)
invite["code"] = _normalize_invite_secret(code)
invite["code_available"] = True
return invite
def create_signup_invite( def create_signup_invite(
@@ -1719,6 +1829,9 @@ def create_signup_invite(
recipient_email: Optional[str] = None, recipient_email: Optional[str] = None,
created_by: Optional[str] = None, created_by: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
normalized_code = _normalize_invite_secret(code)
if not normalized_code:
raise ValueError("Invite code is required")
if recipient_email: if recipient_email:
max_uses = 1 max_uses = 1
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
@@ -1726,13 +1839,14 @@ def create_signup_invite(
cursor = conn.execute( cursor = conn.execute(
""" """
INSERT INTO signup_invites ( INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled, code, code_hint, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, recipient_email, created_by, created_at, updated_at expires_at, recipient_email, created_by, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
""", """,
( (
code, _hash_signup_invite_code(normalized_code),
_invite_code_hint(normalized_code),
label, label,
description, description,
profile_id, profile_id,
@@ -1748,20 +1862,21 @@ def create_signup_invite(
) )
invite_id = int(cursor.lastrowid) invite_id = int(cursor.lastrowid)
logger.info( logger.info(
"signup invite created invite_id=%s code=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s recipient_email=%s created_by=%s", "signup invite created invite_id=%s role=%s profile_id=%s max_uses=%s enabled=%s expires_at=%s has_recipient=%s created_by=%s",
invite_id, invite_id,
code,
role, role,
profile_id, profile_id,
max_uses, max_uses,
enabled, enabled,
expires_at, expires_at,
recipient_email, bool(recipient_email),
created_by, created_by,
) )
invite = get_signup_invite_by_id(invite_id) invite = get_signup_invite_by_id(invite_id)
if not invite: if not invite:
raise RuntimeError("Invite creation failed") raise RuntimeError("Invite creation failed")
invite["code"] = normalized_code
invite["code_available"] = True
return invite return invite
@@ -1784,31 +1899,68 @@ def update_signup_invite(
if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'): if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'):
raise ValueError('A used email invitation cannot be reassigned.') raise ValueError('A used email invitation cannot be reassigned.')
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
requested_code = str(code or "").strip()
rotate_code = bool(requested_code) and not requested_code.startswith("••••") and requested_code != "Protected invite"
with _connect() as conn:
if rotate_code:
normalized_code = _normalize_invite_secret(requested_code)
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, code_hint = ?, label = ?, description = ?, profile_id = ?, role = ?,
max_uses = ?, enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ?
""",
(
_hash_signup_invite_code(normalized_code), _invite_code_hint(normalized_code),
label, description, profile_id, role, max_uses, 1 if enabled else 0,
expires_at, recipient_email, timestamp, invite_id,
),
)
else:
cursor = conn.execute(
"""
UPDATE signup_invites
SET label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
WHERE id = ?
""",
(
label, description, profile_id, role, max_uses, 1 if enabled else 0,
expires_at, recipient_email, timestamp, invite_id,
),
)
if cursor.rowcount <= 0:
return None
return get_signup_invite_by_id(invite_id)
def rotate_signup_invite_code(invite_id: int, code: str) -> Optional[Dict[str, Any]]:
normalized_code = _normalize_invite_secret(code)
if not normalized_code:
raise ValueError("Invite code is required")
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
""" """
UPDATE signup_invites UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, SET code = ?, code_hint = ?, updated_at = ?
enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ? WHERE id = ? AND enabled = 1
WHERE id = ?
""", """,
( (
code, _hash_signup_invite_code(normalized_code),
label, _invite_code_hint(normalized_code),
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
recipient_email,
timestamp, timestamp,
invite_id, invite_id,
), ),
) )
if cursor.rowcount <= 0: if cursor.rowcount <= 0:
return None return None
return get_signup_invite_by_id(invite_id) invite = get_signup_invite_by_id(invite_id)
if invite:
invite["code"] = normalized_code
invite["code_available"] = True
return invite
def delete_signup_invite(invite_id: int) -> bool: def delete_signup_invite(invite_id: int) -> bool:
@@ -1859,7 +2011,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
ORDER BY ORDER BY
@@ -1874,8 +2026,15 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
provider = str(row[4] or "local").lower() provider = str(row[4] or "local").lower()
if provider != "local": if provider != "local":
continue continue
if not verify_password(password, row[2]): verified, updated_hash = verify_and_update_password(password, row[2])
if not verified:
continue continue
if updated_hash:
with _connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(updated_hash, row[0]),
)
return { return {
"id": row[0], "id": row[0],
"username": row[1], "username": row[1],
@@ -1895,6 +2054,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
"is_expired": _is_datetime_in_past(row[12]), "is_expired": _is_datetime_in_past(row[12]),
"jellyfin_password_hash": row[15], "jellyfin_password_hash": row[15],
"last_jellyfin_auth_at": row[16], "last_jellyfin_auth_at": row[16],
"auth_version": int(row[17] or 1),
} }
return None return None
@@ -1906,7 +2066,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id, SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled, created_at, last_login_at, is_blocked, auto_search_enabled,
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at jellyfin_password_hash, last_jellyfin_auth_at, auth_version
FROM users FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
ORDER BY ORDER BY
@@ -1938,6 +2098,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
"is_expired": _is_datetime_in_past(row[13]), "is_expired": _is_datetime_in_past(row[13]),
"jellyfin_password_hash": row[16], "jellyfin_password_hash": row[16],
"last_jellyfin_auth_at": row[17], "last_jellyfin_auth_at": row[17],
"auth_version": int(row[18] or 1),
} }
) )
return results return results
@@ -1956,7 +2117,7 @@ def set_user_email(username: str, email: Optional[str]) -> bool:
) )
updated = cursor.rowcount > 0 updated = cursor.rowcount > 0
if updated: if updated:
logger.info("user email updated username=%s email=%s", username, normalized_email) logger.info("user email updated username=%s email_set=%s", username, bool(normalized_email))
else: else:
logger.debug("user email update skipped username=%s", username) logger.debug("user email update skipped username=%s", username)
return updated return updated
@@ -1967,12 +2128,74 @@ def set_user_password(username: str, password: str) -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE UPDATE users
SET password_hash = ?, auth_version = auth_version + 1
WHERE username = ? COLLATE NOCASE
""", """,
(password_hash, username), (password_hash, username),
) )
def increment_user_auth_version(username: str) -> int:
with _connect() as conn:
conn.execute(
"UPDATE users SET auth_version = auth_version + 1 WHERE username = ? COLLATE NOCASE",
(username,),
)
row = conn.execute(
"SELECT auth_version FROM users WHERE username = ? COLLATE NOCASE",
(username,),
).fetchone()
return int(row[0] or 1) if row else 0
def _rate_limit_key_hash(key: str) -> str:
key_material = str(
settings.jwt_secret or settings.settings_encryption_key or "magent-rate-limit"
).encode("utf-8")
return hmac.new(
key_material, str(key or "").encode("utf-8"), sha256
).hexdigest()
def get_rate_limit_status(
scope: str, key: str, window_seconds: int, maximum: int
) -> tuple[bool, int]:
now = unix_time()
cutoff = now - max(1, int(window_seconds))
key_hash = _rate_limit_key_hash(key)
with _connect() as conn:
conn.execute("DELETE FROM auth_rate_limits WHERE occurred_at < ?", (cutoff,))
row = conn.execute(
"""
SELECT COUNT(*), MIN(occurred_at)
FROM auth_rate_limits
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?
""",
(scope, key_hash, cutoff),
).fetchone()
count = int((row or [0])[0] or 0)
oldest = float(row[1]) if row and row[1] is not None else now
retry_after = max(1, int(window_seconds - (now - oldest)))
return count >= max(1, int(maximum)), retry_after
def record_rate_limit_event(scope: str, key: str) -> None:
with _connect() as conn:
conn.execute(
"INSERT INTO auth_rate_limits (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
(scope, _rate_limit_key_hash(key), unix_time()),
)
def clear_rate_limit_events(scope: str, key: str) -> None:
with _connect() as conn:
conn.execute(
"DELETE FROM auth_rate_limits WHERE scope = ? AND key_hash = ?",
(scope, _rate_limit_key_hash(key)),
)
def sync_jellyfin_password_state(username: str, password: str) -> None: def sync_jellyfin_password_state(username: str, password: str) -> None:
if not username or not password: if not username or not password:
return return
@@ -2943,11 +3166,12 @@ def get_setting(key: str) -> Optional[str]:
).fetchone() ).fetchone()
if not row: if not row:
return None return None
return row[0] return decrypt_setting_value(key, row[0])
def set_setting(key: str, value: Optional[str]) -> None: def set_setting(key: str, value: Optional[str]) -> None:
updated_at = datetime.now(timezone.utc).isoformat() updated_at = datetime.now(timezone.utc).isoformat()
stored_value = encrypt_setting_value(key, value)
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
@@ -2955,7 +3179,7 @@ def set_setting(key: str, value: Optional[str]) -> None:
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""", """,
(key, value, updated_at), (key, stored_value, updated_at),
) )
@@ -2981,7 +3205,7 @@ def get_settings_overrides() -> Dict[str, str]:
key = row[0] key = row[0]
value = row[1] value = row[1]
if key: if key:
overrides[key] = value overrides[key] = decrypt_setting_value(key, value)
return overrides return overrides
@@ -3067,12 +3291,10 @@ def create_password_reset_token(
), ),
) )
logger.info( logger.info(
"password reset token created username=%s provider=%s recipient=%s expires_at=%s requester_ip=%s", "password reset token created username=%s provider=%s expires_at=%s",
username, username,
auth_provider, auth_provider,
recipient_email,
expires_at, expires_at,
requested_by_ip,
) )
return { return {
"username": username, "username": username,
@@ -3114,7 +3336,7 @@ def mark_password_reset_token_used(token_value: str) -> None:
""", """,
(used_at, token_hash), (used_at, token_hash),
) )
logger.info("password reset token marked used token_hash=%s", token_hash[:12]) logger.info("password reset token marked used")
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]: def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
@@ -4020,6 +4242,7 @@ def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0: if days <= 0:
return {"actions": 0, "snapshots": 0} return {"actions": 0, "snapshots": 0}
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
cutoff_epoch = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
with _connect() as conn: with _connect() as conn:
actions = conn.execute( actions = conn.execute(
"DELETE FROM actions WHERE created_at < ?", "DELETE FROM actions WHERE created_at < ?",
@@ -4029,7 +4252,37 @@ def cleanup_history(days: int) -> Dict[str, int]:
"DELETE FROM snapshots WHERE created_at < ?", "DELETE FROM snapshots WHERE created_at < ?",
(cutoff,), (cutoff,),
).rowcount ).rowcount
return {"actions": actions, "snapshots": snapshots} reset_tokens = conn.execute(
"DELETE FROM password_reset_tokens WHERE expires_at < ? OR (used_at IS NOT NULL AND used_at < ?)",
(cutoff, cutoff),
).rowcount
invites = conn.execute(
"""
DELETE FROM signup_invites
WHERE updated_at < ?
AND (enabled = 0 OR expires_at < ? OR (max_uses IS NOT NULL AND use_count >= max_uses))
AND id != COALESCE((SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'self_service_invite_master_id'), -1)
""",
(cutoff, cutoff),
).rowcount
rate_limits = conn.execute(
"DELETE FROM auth_rate_limits WHERE occurred_at < ?",
(unix_time() - 86400,),
).rowcount
email_deliveries = 0
for table in ("email_recap_deliveries", "newsletter_deliveries"):
if _table_exists(conn, table):
email_deliveries += int(
conn.execute(f"DELETE FROM {table} WHERE created_at < ?", (cutoff_epoch,)).rowcount or 0
)
return {
"actions": int(actions or 0),
"snapshots": int(snapshots or 0),
"password_reset_tokens": int(reset_tokens or 0),
"invites": int(invites or 0),
"rate_limits": int(rate_limits or 0),
"email_deliveries": email_deliveries,
}
def get_request_stage_cache(): def get_request_stage_cache():
+41 -8
View File
@@ -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,13 +187,20 @@ 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()
formatter = logging.Formatter( if str(log_format or "text").strip().lower() == "json":
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s", formatter: logging.Formatter = JsonLogFormatter()
datefmt="%Y-%m-%d %H:%M:%S", else:
) formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers: for handler in handlers:
handler.addFilter(context_filter) handler.addFilter(context_filter)
handler.setFormatter(formatter) handler.setFormatter(formatter)
+43 -20
View File
@@ -7,6 +7,7 @@ from typing import Awaitable, Callable
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
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
@@ -47,11 +48,12 @@ 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 .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]] = []
@@ -82,22 +84,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(
{ {
@@ -124,7 +136,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:
@@ -140,6 +152,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(
@@ -149,7 +162,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(
@@ -203,9 +216,9 @@ 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 not admin_password or admin_password == "adminadmin":
@@ -218,10 +231,17 @@ 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"):
raise RuntimeError( raise RuntimeError(
@@ -239,9 +259,11 @@ 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()
init_db() init_db()
_enforce_secure_startup_configuration() _enforce_secure_startup_configuration()
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -252,6 +274,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",
+68 -25
View File
@@ -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",
@@ -260,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",
@@ -712,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)
@@ -727,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()
@@ -738,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}
@@ -766,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}
@@ -1294,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(
@@ -1561,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,
@@ -1904,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(
@@ -1917,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,
) )
@@ -1985,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 {
@@ -2016,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):
@@ -2050,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,
@@ -2059,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 {
@@ -2083,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)
+107 -114
View File
@@ -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(
@@ -775,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(
@@ -851,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(
@@ -873,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"}
@@ -884,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,
@@ -907,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()
@@ -923,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:
@@ -1039,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
@@ -1066,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,
@@ -1093,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")
@@ -1110,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,
@@ -1120,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}
@@ -1153,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")
@@ -1216,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()
@@ -1371,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()
@@ -1427,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,
@@ -1450,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)
@@ -1461,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):
@@ -1531,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"}
+24 -6
View File
@@ -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:
image = Image.open(BytesIO(content)) with warnings.catch_warnings():
except OSError as exc: 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.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()
+1 -1
View File
@@ -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
+7 -1
View File
@@ -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"}
+1 -1
View File
@@ -7,7 +7,7 @@ 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 ..services.public_urls import magent_public_url from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin 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
+303 -78
View File
@@ -20,6 +20,7 @@ from ..clients.sonarr import SonarrClient
from ..clients.bazarr import BazarrClient from ..clients.bazarr import BazarrClient
from ..ai.triage import triage_snapshot from ..ai.triage import triage_snapshot
from ..auth import get_current_user from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .images import cache_tmdb_image, is_tmdb_cached from .images import cache_tmdb_image, is_tmdb_cached
from ..db import ( from ..db import (
@@ -30,7 +31,6 @@ from ..db import (
save_action, save_action,
get_recent_actions, get_recent_actions,
get_recent_snapshots, get_recent_snapshots,
get_cached_requests,
get_cached_requests_since, get_cached_requests_since,
get_cached_request_by_media_id, get_cached_request_by_media_id,
get_request_cache_lookup, get_request_cache_lookup,
@@ -62,6 +62,7 @@ from ..db import (
) )
from ..services.media_repair import current_cycle_torrents from ..services.media_repair import current_cycle_torrents
from ..services.download_labels import label_episode_downloads from ..services.download_labels import label_episode_downloads
from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path
from ..models import Snapshot, TriageResult, RequestType from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import ( from ..services.snapshot import (
_summarize_qbit, _summarize_qbit,
@@ -70,7 +71,12 @@ from ..services.snapshot import (
jellyfin_item_matches_request, jellyfin_item_matches_request,
) )
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)]) router = APIRouter(
prefix="/requests",
tags=["requests"],
dependencies=[Depends(get_current_user), Depends(require_request_access)],
responses=COMMON_ERROR_RESPONSES,
)
CACHE_TTL_SECONDS = 600 CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} _detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
@@ -273,8 +279,14 @@ def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot: def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
if not _user_can_use_search_auto(user): can_add_seasons = _user_can_use_search_auto(user)
if not can_add_seasons:
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"] snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
pipeline = snapshot.presentation.get("pipeline")
if isinstance(pipeline, list):
for stage in pipeline:
if isinstance(stage, dict) and stage.get("id") == "library":
stage["canAddSeasons"] = can_add_seasons
if user.get("role") != "admin": if user.get("role") != "admin":
# The standard request view is intentionally collaborative, but service payloads can # The standard request view is intentionally collaborative, but service payloads can
# contain requester identities, internal URLs, download hashes and diagnostic errors. # contain requester identities, internal URLs, download hashes and diagnostic errors.
@@ -1587,11 +1599,54 @@ def get_requests_sync_state() -> Dict[str, Any]:
async def _ensure_request_access( async def _ensure_request_access(
client: JellyseerrClient, request_id: int, user: Dict[str, str] client: JellyseerrClient,
) -> None: request_id: int,
if user.get("role") == "admin" or user.get("username"): user: Dict[str, Any],
return *,
raise HTTPException(status_code=403, detail="Request not accessible for this user") require_owner: bool = False,
) -> Optional[Dict[str, Any]]:
if user.get("role") == "admin":
return None
if not user.get("username"):
raise HTTPException(status_code=403, detail="Request not accessible for this user")
if not require_owner:
return None
request_data = await client.get_request(str(request_id))
if not isinstance(request_data, dict):
raise HTTPException(status_code=404, detail="Request not found")
requester_id = _extract_requested_by_id(request_data)
current_seerr_id = user.get("jellyseerr_user_id")
if isinstance(current_seerr_id, int) and requester_id == current_seerr_id:
return request_data
if _request_matches_user(request_data, str(user.get("username") or "")):
return request_data
email = str(user.get("email") or "").strip()
if email and _request_matches_user(request_data, email):
return request_data
raise HTTPException(
status_code=403,
detail="Only the original requester or an administrator can change this request",
)
async def _ensure_request_mutation_access(
runtime: Any, request_id: int, user: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Fail closed when a non-admin request owner cannot be verified."""
if user.get("role") == "admin":
return None
client = JellyseerrClient(
getattr(runtime, "jellyseerr_base_url", None),
getattr(runtime, "jellyseerr_api_key", None),
)
if not client.configured():
raise HTTPException(
status_code=403,
detail="Request ownership cannot be verified while Seerr is unavailable",
)
return await _ensure_request_access(
client, request_id, user, require_owner=True
)
def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]: def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
@@ -1704,7 +1759,6 @@ def _filter_arr_release_results(results: Any, include_rejected: bool = False) ->
"approved": accepted, "approved": accepted,
"rejected": item.get("rejected"), "rejected": item.get("rejected"),
"temporarilyRejected": item.get("temporarilyRejected"), "temporarilyRejected": item.get("temporarilyRejected"),
"rejections": item.get("rejections"),
"downloadAllowed": item.get("downloadAllowed"), "downloadAllowed": item.get("downloadAllowed"),
"fullSeason": item.get("fullSeason"), "fullSeason": item.get("fullSeason"),
"seasonNumber": item.get("seasonNumber"), "seasonNumber": item.get("seasonNumber"),
@@ -1922,16 +1976,10 @@ def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit(): try:
folders = await client.get_root_folders() return await resolve_root_folder_path(client, root_folder, service_name)
if isinstance(folders, list): except RootFolderNotFoundError as exc:
for folder in folders: raise HTTPException(status_code=400, detail=str(exc)) from exc
if folder.get("id") == int(root_folder):
path = folder.get("path")
if isinstance(path, str) and path:
return path
raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found")
return root_folder
@router.get("/{request_id}/issue-options") @router.get("/{request_id}/issue-options")
@@ -1942,9 +1990,6 @@ async def issue_target_options(
if not request_id.isdigit(): if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id") raise HTTPException(status_code=400, detail="Invalid request id")
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -2130,9 +2175,7 @@ async def action_replace_media(
) )
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -2354,6 +2397,7 @@ async def action_search_missing_media(
payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0 payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
) )
runtime = get_runtime_settings() runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
@@ -2474,6 +2518,135 @@ async def action_search_missing_media(
return {"status": "ok", "message": message, "episode_ids": searched_ids} return {"status": "ok", "message": message, "episode_ids": searched_ids}
@router.post("/{request_id}/actions/add-seasons")
async def action_add_seasons(
request_id: str,
payload: Dict[str, Any],
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Adding seasons is disabled for this user")
season_numbers = _positive_id_list(
payload.get("season_numbers"), field="season_numbers", maximum=100
)
if not season_numbers:
raise HTTPException(status_code=400, detail="Choose at least one season")
runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
snapshot = await build_snapshot(request_id)
if snapshot.request_type != RequestType.tv:
raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
raise HTTPException(status_code=404, detail="Series not found in Sonarr")
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not sonarr.configured():
raise HTTPException(status_code=400, detail="Sonarr is not configured")
series_id = int(arr_item["id"])
label = "Add seasons"
try:
series = await sonarr.get_series(series_id)
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
raise HTTPException(status_code=502, detail="Sonarr did not return the series seasons")
known_seasons = {
season.get("seasonNumber")
for season in series["seasons"]
if isinstance(season, dict)
and isinstance(season.get("seasonNumber"), int)
and season.get("seasonNumber") > 0
}
if any(season_number not in known_seasons for season_number in season_numbers):
raise HTTPException(status_code=409, detail="One or more selected seasons are no longer available in Sonarr")
updated_seasons = [
{**season, "monitored": True}
if isinstance(season, dict) and season.get("seasonNumber") in season_numbers
else season
for season in series["seasons"]
]
if series.get("monitored") is not True or updated_seasons != series["seasons"]:
await sonarr.update_series({**series, "monitored": True, "seasons": updated_seasons})
episodes = await sonarr.get_episodes(series_id)
if not isinstance(episodes, list):
raise HTTPException(status_code=502, detail="Sonarr did not return an episode list")
selected_episodes = [
episode for episode in episodes
if isinstance(episode, dict)
and episode.get("seasonNumber") in season_numbers
and isinstance(episode.get("id"), int)
]
episode_ids = [int(episode["id"]) for episode in selected_episodes]
if episode_ids:
await sonarr.monitor_episodes(episode_ids, True)
search_ids = [
int(episode["id"])
for episode in selected_episodes
if _released_episode(episode)
and episode.get("hasFile") is not True
and not (
isinstance(episode.get("episodeFileId"), int)
and episode.get("episodeFileId") > 0
)
]
if search_ids:
await sonarr.search_episodes(search_ids)
verified_series = await sonarr.get_series(series_id)
verified_seasons = verified_series.get("seasons") if isinstance(verified_series, dict) else []
verified_season_map = {
season.get("seasonNumber"): season.get("monitored")
for season in verified_seasons
if isinstance(season, dict) and isinstance(season.get("seasonNumber"), int)
}
if (
not isinstance(verified_series, dict)
or verified_series.get("monitored") is not True
or any(verified_season_map.get(number) is not True for number in season_numbers)
):
raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected season")
if episode_ids:
verified_episodes = await sonarr.get_episodes(series_id)
if not isinstance(verified_episodes, list) or any(
isinstance(episode, dict)
and episode.get("id") in episode_ids
and episode.get("monitored") is not True
for episode in verified_episodes
):
raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected episode")
except HTTPException as exc:
detail = f"The seasons could not be added: {exc.detail}"
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
raise
except Exception as exc:
logger.exception("add seasons failed request_id=%s", request_id)
detail = "Sonarr could not add the selected seasons."
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
raise HTTPException(status_code=502, detail=detail) from exc
season_label = ", ".join(str(number) for number in season_numbers)
message = f"Season{'s' if len(season_numbers) != 1 else ''} {season_label} added to Sonarr."
if search_ids:
message += f" Searching for {len(search_ids)} released missing episode{'s' if len(search_ids) != 1 else ''}."
elif episode_ids:
message += " All known episodes are already collected or have not aired yet."
else:
message += " New episodes will be monitored when Sonarr discovers them."
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "ok", message)
fresh_snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user)
return {
"status": "ok",
"message": message,
"season_numbers": season_numbers,
"searched_episode_count": len(search_ids),
"snapshot": fresh_snapshot,
}
@router.post("/{request_id}/actions/repair-subtitles") @router.post("/{request_id}/actions/repair-subtitles")
async def action_repair_subtitles( async def action_repair_subtitles(
request_id: str, request_id: str,
@@ -2490,6 +2663,7 @@ async def action_repair_subtitles(
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100) episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
forced = payload.get("forced") is True forced = payload.get("forced") is True
runtime = get_runtime_settings() runtime = get_runtime_settings()
await _ensure_request_mutation_access(runtime, int(request_id), user)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key) bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
if not bazarr.configured() or not runtime.bazarr_api_key: if not bazarr.configured() or not runtime.bazarr_api_key:
raise HTTPException(status_code=400, detail="Bazarr is not configured") raise HTTPException(status_code=400, detail="Bazarr is not configured")
@@ -2578,6 +2752,54 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
return _filter_snapshot_for_user(snapshot, user) return _filter_snapshot_for_user(snapshot, user)
async def _restore_request_monitoring(snapshot: Snapshot, request: dict) -> bool:
# Pending or declined requests must not gain collection access via Recheck.
if request.get('status') != 2:
return False
item = (snapshot.raw.get('arr') or {}).get('item')
if not isinstance(item, dict) or not isinstance(item.get('id'), int):
return False
runtime = get_runtime_settings()
if snapshot.request_type == RequestType.movie:
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
fresh = await client.get_movie(item['id'])
if not isinstance(fresh, dict):
raise ValueError('Radarr did not return the requested movie')
if fresh.get('monitored') is True:
return False
await client.update_movie({**fresh, 'monitored': True})
verified = await client.get_movie(item['id'])
if not isinstance(verified, dict) or verified.get('monitored') is not True:
raise ValueError('Radarr did not enable monitoring')
return True
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
fresh = await client.get_series(item['id'])
if not isinstance(fresh, dict):
raise ValueError('Sonarr did not return the requested series')
requested = {season['seasonNumber'] for season in (request.get('seasons') or [])
if isinstance(season, dict) and isinstance(season.get('seasonNumber'), int)}
seasons = [{**season, 'monitored': True} if season.get('seasonNumber') in requested else season
for season in (fresh.get('seasons') or [])]
changed = fresh.get('monitored') is not True or seasons != fresh.get('seasons', [])
if changed:
await client.update_series({**fresh, 'monitored': True, 'seasons': seasons})
episodes = await client.get_episodes(item['id']) if requested else []
ids = [episode['id'] for episode in episodes if episode.get('seasonNumber') in requested
and episode.get('monitored') is not True and isinstance(episode.get('id'), int)]
if ids:
await client.monitor_episodes(ids, True)
verified_episodes = await client.get_episodes(item['id'])
if any(e.get('id') in ids and e.get('monitored') is not True for e in verified_episodes):
raise ValueError('Sonarr did not enable episode monitoring')
if changed:
verified = await client.get_series(item['id'])
if not isinstance(verified, dict) or verified.get('monitored') is not True or any(
season.get('seasonNumber') in requested and season.get('monitored') is not True
for season in verified.get('seasons', [])):
raise ValueError('Sonarr did not enable monitoring')
return changed or bool(ids)
@router.post("/{request_id}/actions/recheck") @router.post("/{request_id}/actions/recheck")
async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
if not request_id.isdigit(): if not request_id.isdigit():
@@ -2587,33 +2809,36 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not seerr.configured(): if not seerr.configured():
raise HTTPException(status_code=400, detail="Seerr is not configured") raise HTTPException(status_code=400, detail="Seerr is not configured")
await _ensure_request_access(seerr, int(request_id), user) fresh_request = await _ensure_request_access(
seerr, int(request_id), user, require_owner=True
)
try: if fresh_request is None:
fresh_request = await seerr.get_request(request_id) try:
except httpx.HTTPStatusError as exc: fresh_request = await seerr.get_request(request_id)
detail = _format_upstream_error("Seerr", exc) except httpx.HTTPStatusError as exc:
await asyncio.to_thread( detail = _format_upstream_error("Seerr", exc)
save_action, await asyncio.to_thread(
request_id, save_action,
"recheck_pipeline", request_id,
"Recheck request status", "recheck_pipeline",
"failed", "Recheck request status",
detail, "failed",
) detail,
raise HTTPException(status_code=502, detail=detail) from exc )
except Exception as exc: raise HTTPException(status_code=502, detail=detail) from exc
logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc) except Exception as exc:
detail = "Magent could not reach Seerr to recheck this request." logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
await asyncio.to_thread( detail = "Magent could not reach Seerr to recheck this request."
save_action, await asyncio.to_thread(
request_id, save_action,
"recheck_pipeline", request_id,
"Recheck request status", "recheck_pipeline",
"failed", "Recheck request status",
detail, "failed",
) detail,
raise HTTPException(status_code=502, detail=detail) from exc )
raise HTTPException(status_code=502, detail=detail) from exc
if not isinstance(fresh_request, dict): if not isinstance(fresh_request, dict):
raise HTTPException(status_code=404, detail="Request not found in Seerr") raise HTTPException(status_code=404, detail="Request not found in Seerr")
@@ -2634,9 +2859,17 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
_cache_set(f"request:{request_id}", fresh_request) _cache_set(f"request:{request_id}", fresh_request)
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user) snapshot = await build_snapshot(request_id)
try:
restored = await _restore_request_monitoring(snapshot, fresh_request)
except Exception as exc:
logger.warning('Recheck monitoring failed request_id=%s: %s', request_id, exc)
raise HTTPException(502, 'Could not restore monitoring in Sonarr/Radarr. Please try Recheck again.') from exc
if restored:
snapshot = await build_snapshot(request_id)
snapshot = _filter_snapshot_for_user(snapshot, user)
status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated") status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated")
message = f"Recheck complete. {status_label}." message = ("Monitoring restored. " if restored else "") + f"Recheck complete. {status_label}."
await asyncio.to_thread( await asyncio.to_thread(
save_action, save_action,
request_id, request_id,
@@ -2745,7 +2978,6 @@ async def recent_requests(
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
mode = (runtime.requests_data_source or "prefer_cache").lower()
# Browsing is always local. Synchronization is owned by background workers. # Browsing is always local. Synchronization is owned by background workers.
allow_remote = False allow_remote = False
username_norm = _normalize_username(user.get("username", "")) username_norm = _normalize_username(user.get("username", ""))
@@ -2773,8 +3005,6 @@ async def recent_requests(
allow_title_hydrate = False allow_title_hydrate = False
allow_artwork_hydrate = False allow_artwork_hydrate = False
stage_cache = await asyncio.to_thread(get_request_stage_cache) stage_cache = await asyncio.to_thread(get_request_stage_cache)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")
@@ -3251,11 +3481,14 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
return triage_snapshot(snapshot) return triage_snapshot(snapshot)
async def _request_language_context(request_id, user): async def _request_language_context(request_id, user, *, require_owner: bool = False):
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
await _ensure_request_access(seerr, int(request_id), user) request = await _ensure_request_access(
request = await seerr.get_request(request_id) seerr, int(request_id), user, require_owner=require_owner
)
if request is None:
request = await seerr.get_request(request_id)
if not isinstance(request, dict) or request.get('type') != 'movie': if not isinstance(request, dict) or request.get('type') != 'movie':
return runtime, None, None return runtime, None, None
tmdb_id = (request.get('media') or {}).get('tmdbId') tmdb_id = (request.get('media') or {}).get('tmdbId')
@@ -3286,7 +3519,9 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
raise HTTPException(403, 'Search and download changes are disabled for this account.') raise HTTPException(403, 'Search and download changes are disabled for this account.')
if payload.get('acceptOriginalLanguage') is not True: if payload.get('acceptOriginalLanguage') is not True:
raise HTTPException(400, 'Explicitly accept original-language audio before continuing.') raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
runtime, tmdb_id, language = await _request_language_context(request_id, user) runtime, tmdb_id, language = await _request_language_context(
request_id, user, require_owner=True
)
if not language: if not language:
raise HTTPException(409, 'This request has no verified non-English original language.') raise HTTPException(409, 'This request has no verified non-English original language.')
if payload.get('languageCode') != language['code']: if payload.get('languageCode') != language['code']:
@@ -3309,9 +3544,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
total_missing = 0 total_missing = 0
next_offset = None next_offset = None
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
@@ -3340,7 +3573,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
return {"status": "ok", "message": message, "collector": collector, "releases": []} return {"status": "ok", "message": message, "collector": collector, "releases": []}
missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]] missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]]
total_missing = len(missing_ids) total_missing = len(missing_ids)
batch = missing_ids[offset:offset + 20] batch = missing_ids[offset:offset + 3]
next_offset = offset + len(batch) if offset + len(batch) < total_missing else None next_offset = offset + len(batch) if offset + len(batch) < total_missing else None
semaphore = asyncio.Semaphore(3) semaphore = asyncio.Semaphore(3)
async def search_episode(identity): async def search_episode(identity):
@@ -3406,7 +3639,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
if len(results) > len(releases): if len(results) > len(releases):
message += ' Duplicate results are combined; up to 200 ranked releases are shown.' message += ' Duplicate results are combined; up to 200 ranked releases are shown.'
if total_missing: if total_missing:
message += f' Searched {min(20, max(0, total_missing - offset))} of {total_missing} missing monitored episodes.' message += f' Searched {min(3, max(0, total_missing - offset))} of {total_missing} missing monitored episodes.'
await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message) await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message)
return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message, return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message,
'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons, 'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons,
@@ -3419,9 +3652,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
if not _user_can_use_search_auto(user): if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user") raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item") arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict): if not isinstance(arr_item, dict):
@@ -3471,9 +3702,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
@router.post("/{request_id}/actions/qbit/resume") @router.post("/{request_id}/actions/qbit/resume")
async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
queue = snapshot.raw.get("arr", {}).get("queue") queue = snapshot.raw.get("arr", {}).get("queue")
download_ids = _download_ids(_queue_records(queue)) download_ids = _download_ids(_queue_records(queue))
@@ -3518,9 +3747,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr
@router.post("/{request_id}/actions/readd") @router.post("/{request_id}/actions/readd")
async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
jelly = snapshot.raw.get("jellyseerr") or {} jelly = snapshot.raw.get("jellyseerr") or {}
media = jelly.get("media") or {} media = jelly.get("media") or {}
@@ -3677,9 +3904,7 @@ async def action_grab(
request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user) request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) await _ensure_request_mutation_access(runtime, int(request_id), user)
if client.configured():
await _ensure_request_access(client, int(request_id), user)
snapshot = await build_snapshot(request_id) snapshot = await build_snapshot(request_id)
guid = payload.get("guid") guid = payload.get("guid")
indexer_id = payload.get("indexerId") indexer_id = payload.get("indexerId")
@@ -3717,7 +3942,7 @@ async def action_grab(
release_title = receipt.get('title') release_title = receipt.get('title')
arr_error: Optional[str] = None arr_error: Optional[str] = None
try: try:
response = await arr_client.grab_release(str(guid), arr_indexer_id) await arr_client.grab_release(str(guid), arr_indexer_id)
action_message = ( action_message = (
f"{release_title or 'Selected release'} was sent through {service_label} for download and import." f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '') + (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
+5
View File
@@ -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),
+116
View File
@@ -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
+74
View File
@@ -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",
}
)
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
+51 -8
View File
@@ -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:
return _pwd_context.verify(plain_password, hashed_password) try:
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):
+21
View File
@@ -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")
+31 -3
View File
@@ -142,6 +142,34 @@ 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)
@@ -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)
+12 -1
View File
@@ -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])],
+13 -10
View File
@@ -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"):
row = {**play} rows = []
media_id = row.pop("artwork_item_id", None) for play in data.get(field, []):
row["artwork_url"] = None row = {**play}
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key: media_id = row.pop("artwork_item_id", None)
token = f"{expires}.{signature(user, runtime, media_id, expires)}" row["artwork_url"] = None
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}" if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
recent.append(row) token = f"{expires}.{signature(user, runtime, media_id, expires)}"
return {**data, "recent": recent} row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
rows.append(row)
result[field] = rows
return result
def verify_artwork_token(user, runtime, media_id, token): def verify_artwork_token(user, runtime, media_id, token):
+8 -21
View File
@@ -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 = {
+1 -1
View File
@@ -1,4 +1,3 @@
from .public_urls import magent_public_url
"""Independent newsletter consent and immutable edition snapshots using the shared email queue.""" """Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib import hashlib
@@ -11,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):
+3 -1
View File
@@ -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)
+18 -2
View File
@@ -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">&nbsp;</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:
+1 -1
View File
@@ -1,4 +1,3 @@
from .public_urls import magent_public_url
"""Durable consent, schedule and delivery records for personal email recaps.""" """Durable consent, schedule and delivery records for personal email recaps."""
import hashlib import hashlib
@@ -11,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:
+5 -5
View File
@@ -100,10 +100,11 @@ async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
movie = await client.get_movie(movie_id) movie = await client.get_movie(movie_id)
if (movie or {}).get('hasFile'): if (movie or {}).get('hasFile'):
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'} return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
return {'status': 'attention', 'message': 'Search finished, but no download appeared in Radarr. Use Search and choose a download to see matching releases and rejection reasons. For a foreign-language title, review the audio choice above.'} # Command completion precedes download-client queue refresh. Keep polling.
pass
if attempt + 1 < attempts: if attempt + 1 < attempts:
await asyncio.sleep(delay) await asyncio.sleep(delay)
return {'status': 'searching', 'message': 'Radarr is still searching. No download is confirmed yet; the pipeline will keep checking. You can close this window.'} 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): async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
@@ -122,8 +123,7 @@ async def series_search_outcome(client, series_id, commands, attempts=12, delay=
statuses = {str((state or {}).get('status', '')).lower() for state in states} statuses = {str((state or {}).get('status', '')).lower() for state in states}
if statuses & {'failed', 'aborted', 'cancelled'}: if statuses & {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'} return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
if statuses == {'completed'}: # Even completed commands can precede Sonarr's download queue refresh.
return {'status': 'attention', 'message': 'Sonarr finished searching, but no download is visible yet. Use Search and choose a download to review available releases and rejection reasons.'}
if attempt + 1 < attempts: if attempt + 1 < attempts:
await asyncio.sleep(delay) await asyncio.sleep(delay)
return {'status': 'searching', 'message': 'Sonarr is still searching. No download is confirmed yet; you can close this window and follow the pipeline.'} 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.'}
+41
View File
@@ -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)
+51 -13
View File
@@ -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__)
@@ -369,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."}
@@ -939,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 {},
}, },
{ {
@@ -1201,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:
@@ -1240,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)
@@ -1356,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:
radarr_client, runtime.radarr_root_folder, "Radarr" root_folder = await resolve_root_folder_path(
) 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),
@@ -1375,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:
sonarr_client, runtime.sonarr_root_folder, "Sonarr" root_folder = await resolve_root_folder_path(
) 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),
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
coverage==7.16.1
pip-audit==2.10.1
ruff==0.16.8
+2
View File
@@ -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
+24
View File
@@ -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()
+24
View File
@@ -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()
+409 -21
View File
@@ -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",
@@ -1133,6 +1402,13 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456') secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
secret.start() secret.start()
self.addCleanup(secret.stop) 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): def selection(self, payload, request_id, source):
payload['selectionToken'] = requests_router.manual_releases.issue_selection( payload['selectionToken'] = requests_router.manual_releases.issue_selection(
@@ -1553,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 (
@@ -1846,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",
@@ -1900,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,
@@ -2012,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)
+8 -1
View File
@@ -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')
+25
View File
@@ -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()
+12 -3
View File
@@ -61,6 +61,15 @@ class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase): 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): 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': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}] episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
@@ -75,9 +84,9 @@ class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}}) 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'): 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'}) first = await requests.action_search('42', {'username':'viewer','role':'user'})
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=20) second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=24)
self.assertEqual(first['nextOffset'],20); self.assertIsNone(second['nextOffset']) self.assertEqual(first['nextOffset'],3); self.assertIsNone(second['nextOffset'])
self.assertEqual(sonarr.search_episode_releases.await_count,25) self.assertEqual(sonarr.search_episode_releases.await_count,4)
self.assertLessEqual(peak,3) self.assertLessEqual(peak,3)
self.assertEqual(first['totalMissingEpisodes'],25) self.assertEqual(first['totalMissingEpisodes'],25)
+27
View File
@@ -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})
+34
View File
@@ -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"], [])
+20 -3
View File
@@ -85,8 +85,8 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
await apply_original_to_movie(client, 613) await apply_original_to_movie(client, 613)
async def test_search_reports_real_outcomes(self): async def test_search_reports_real_outcomes(self):
for command_status, queue, expected in [('completed', [], 'attention'), ('failed', [], 'attention'), for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'),
('started', [], 'searching'), ('completed', [{'movieId': 6940}], 'downloading')]: ('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]:
client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}), client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}),
get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False})) 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) result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0)
@@ -114,6 +114,23 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
async def test_tv_search_distinguishes_no_download_and_queue(self): async def test_tv_search_distinguishes_no_download_and_queue(self):
from backend.app.services.request_language import series_search_outcome from backend.app.services.request_language import series_search_outcome
client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []})) 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'], 'attention') self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'pending')
client.get_queue.return_value = {'records': [{'seriesId': 50}]} client.get_queue.return_value = {'records': [{'seriesId': 50}]}
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading') 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')
+187
View File
@@ -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()
+36
View File
@@ -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()
+9
View File
@@ -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
+8 -1
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
+211 -237
View File
@@ -1,298 +1,261 @@
'use client' "use client";
import PageHeading from './ui/PageHeading' import PageHeading from "./ui/PageHeading";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth' import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from "./lib/auth";
import {
const normalizeRecentResults = (items: any[]) => normalizeRecentResults,
items normalizeSearchResults,
.filter((item: any) => item?.id) type RecentRequest,
.map((item: any) => { type RequestSearchResult,
const id = item.id } from "./lib/request-results";
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 [recentDays, setRecentDays] = useState(90);
artwork?: { poster_url?: string; backdrop_url?: string } const [recentStage, setRecentStage] = useState<RequestStage>("all");
createdAt?: string | null 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 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();
if (cancelled) return if (cancelled) return;
const userRole = me?.role ?? null const userRole = me?.role ?? null;
setRole(userRole) setRole(userRole);
setAuthReady(true) setAuthReady(true);
const take = userRole === 'admin' ? 50 : 6 const take = userRole === "admin" ? 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 (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);
if (!cancelled) setRecentError('Recent requests are not available right now.') if (!cancelled) setRecentError("Recent requests are not available right now.");
} finally { } finally {
if (!cancelled) setRecentLoading(false) if (!cancelled) setRecentLoading(false);
} }
} };
void load() void load();
return () => { cancelled = true } return () => {
}, [recentDays, recentStage]) cancelled = true;
};
}, [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
<form onSubmit={submit} className="home-search"> title="My requests"
<label htmlFor="request-search">Title, year, or request number</label> description="Follow your requests from collection to ready to watch."
<div className="home-search-row"> actions={
<input <form onSubmit={submit} className="home-search">
id="request-search" <label htmlFor="request-search">Title, year, or request number</label>
value={query} <div className="home-search-row">
onChange={(event) => setQuery(event.target.value)} <input
placeholder="Dune 2021 or 1289" id="request-search"
/> value={query}
<button type="submit">Find request</button> onChange={(event) => setQuery(event.target.value)}
</div> placeholder="Dune 2021 or 1289"
</form> />
} /> <button type="submit">Find request</button>
</div>
</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>
@@ -302,17 +265,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>
@@ -321,16 +287,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>{role === "admin" ? "Recent requests" : "My recent requests"}</h2>
</div> </div>
{authReady && ( {authReady && (
<div className="recent-filter-group"> <div className="recent-filter-group">
@@ -347,21 +322,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">
@@ -386,30 +347,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>
) );
} }
+1
View File
@@ -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); }
+137 -47
View File
@@ -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>
<input
{...aria}
type="checkbox"
role="switch"
aria-checked={value.toLowerCase() === "true"}
checked={value.toLowerCase() === "true"}
onChange={(event) => onChange(String(event.target.checked))}
/>
</div> </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>
) );
} }
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -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>
) );
} }
+27 -27
View File
@@ -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} />;
} }
+5
View File
@@ -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; }
+97 -34
View File
@@ -1,37 +1,100 @@
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: "/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";
+5 -8
View File
@@ -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();
<div className="identity-resolve-content"> document.body.style.overflow = overflow;
<header><h2 id="duplicates-title">Repair duplicate accounts</h2><button type="button" className="ghost-button" disabled={saving} onClick={onClose}>Close</button></header> previous?.focus();
<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>} }, []);
{error && <p className="error-banner" role="alert">{error}</p>} return (
{!preview && !busy && <button type="button" disabled={saving} onClick={() => void submit()}>Check again</button>} <dialog
{preview && <section className="identity-confirm-panel" aria-label="Duplicate repair preview"> ref={dialog}
<label>Magent account to keep<select disabled={busy || saving} value={preview.keep_id} onChange={(event) => void submit(false, Number(event.target.value))}> className="identity-resolve-dialog"
{preview.accounts.map((account) => <option key={account.id} value={account.id}>{account.username} Magent {account.id}{account.id === preview.recommended_id ? ' (recommended)' : ''}</option>)} aria-labelledby="duplicates-title"
</select></label> onCancel={(event) => {
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p> event.preventDefault();
<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> if (!saving) onClose();
<h3>Resulting account</h3> }}
<p><strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr {preview.proposed.seerr_user_id ?? 'Not verified'}</p> >
<p>Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? 'Not verified'}</code></p> <div className="identity-resolve-content">
<p>Email: {preview.proposed.email || 'None'} · Profile: {preview.proposed.profile_id ?? 'None'}</p> <header>
<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> <h2 id="duplicates-title">Repair duplicate accounts</h2>
<ul>{FEATURES.map((feature) => <li key={feature.key}>{feature.label}: {preview.proposed.features[feature.key] ? 'Enabled' : 'Disabled'}</li>)}</ul> <button type="button" className="ghost-button" disabled={saving} onClick={onClose}>
<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> Close
<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> </button>
<p>Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different Jellyfin identities or delete upstream users.</p> </header>
{preview.issues.length > 0 && <ul className="identity-issues">{preview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>} <p>
<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> Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to
<button type="button" disabled={!preview.can_confirm || !acknowledged || busy || saving} onClick={() => void submit(true)}>{saving ? 'Rechecking and repairing...' : 'Confirm duplicate repair'}</button> the verified Jellyfin identity.
</section>} </p>
</div> {busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
</dialog> {error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{!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>
<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>
<p>
<strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr{" "}
{preview.proposed.seerr_user_id ?? "Not verified"}
</p>
<p>
Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? "Not verified"}</code>
</p>
<p>
Email: {preview.proposed.email || "None"} · Profile: {preview.proposed.profile_id ?? "None"}
</p>
<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>
<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>
</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: 'Seerrs 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: "Seerrs 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`, {
body: JSON.stringify({ revision: report.revision, user_ids: selected }), method: "POST",
})) headers: { "Content-Type": "application/json" },
setNotice(`${data.confirmed} account ${data.confirmed === 1 ? 'link' : 'links'} confirmed and saved. Run another check to see the updated mappings.`) 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.`,
);
// 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"> <>
<div><h2>Confirm user IDs</h2><p>Jellyfins 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> <section className="identity-intro admin-panel">
<button type="button" onClick={runCheck} disabled={busy || saving}>{busy ? 'Checking all accounts…' : report ? 'Run check again' : 'Check all user IDs'}</button> <div>
</section> <h2>Confirm user IDs</h2>
{busy && <p role="status">Reading the live user directories and checking Jellystat IDs. This can take up to a minute.</p>} <p>
{report && <> Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same
<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> user ID.
<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>
<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> Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs.
<div className="identity-filters"> Duplicate ownership and upstream changes require individual review.
<label>Find an account<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Username or user ID" disabled={saving} /></label> </p>
<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> <button type="button" onClick={runCheck} disabled={busy || saving}>
<div className="identity-selection"> {busy ? "Checking all accounts…" : report ? "Run check again" : "Check all user IDs"}
<span>{filtered.length} accounts shown · {selected.length} selected</span> </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>
{reviewing && <section className="identity-confirm-panel" ref={reviewPanel} tabIndex={-1} aria-label="Review links before saving">
<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>
<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">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => <article className="identity-account" key={row.user.id}>
<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>
<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> </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>} {busy && (
</>} <p role="status">
</>} Reading the live user directories and checking Jellystat IDs. This can take up to a minute.
{duplicates && <DuplicateAccountRepair row={duplicates} onClose={() => setDuplicates(null)} onSaved={() => { setDuplicates(null); void runCheck().then(() => setNotice('Duplicate accounts repaired. History retained and links rechecked.')) }} />} </p>
{resolving && report && <ResolveIdentityLink row={resolving} accounts={report.jellyfin_users} onClose={() => setResolving(null)} onSaved={() => { )}
setResolving(null); setReport(null); setSelected([]); setReviewing(false) {report && (
setNotice('Account links repaired and saved. Run another check to see the updated mappings.') <>
}} />} <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">
<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 className="identity-selection">
<span>
{filtered.length} accounts shown · {selected.length} selected
</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>
<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>
{reviewing && (
<section
className="identity-confirm-panel"
ref={reviewPanel}
tabIndex={-1}
aria-label="Review links before saving"
>
<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>
<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">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => (
<article className="identity-account" key={row.user.id}>
<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>
<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> </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 (
<div className="identity-resolve-content"> <dialog
<header><h2 id="resolve-title">Review account repair</h2><button type="button" className="ghost-button" onClick={onClose} disabled={saving}>Close</button></header> ref={dialog}
<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> className="identity-resolve-dialog"
<label>Jellyfin account<select value={chosen} disabled={saving} onChange={(event) => { aria-labelledby="resolve-title"
controller.current?.abort(); setBusy(false); setPreview(null); setError(''); setCreateSeerr(false); setChosen(event.target.value) onCancel={(event) => {
}}><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> event.preventDefault();
<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> if (!saving) onClose();
<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>} <div className="identity-resolve-content">
{preview && <section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite"> <header>
<h3>{preview.row.can_confirm ? 'Ready to repair' : 'This link needs attention'}</h3> <h2 id="resolve-title">Review account repair</h2>
<p className="identity-meta">Jellyfin server <code>{preview.server_id ?? 'Unavailable'}</code></p> <button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
<div className="identity-mapping"> Close
<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> </button>
<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> </header>
</div> <p>
<dl className="identity-mapping"> Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm
<div><dt>Jellyfin</dt><dd>{preview.row.jellyfin?.name ?? 'Account not found'}<code>{preview.row.candidate_jellyfin_id}</code></dd></div> that these identities belong to the same person before repairing Magent.
<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 users Jellyfin account link in Seerr, then check again.'}</dd></div> </p>
<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> <label>
</dl> Jellyfin account
{preview.row.issues.length > 0 && <ul className="identity-issues">{preview.row.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul>} <select
{preview.row.state === 'unavailable' && <p>A required service is unavailable. Restore its connection and check again.</p>} value={chosen}
{preview.row.seerr.length !== 1 && <div className="identity-upstream-guidance"> disabled={saving}
<h3>Check the existing Seerr account</h3> onChange={(event) => {
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p> controller.current?.abort();
<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> setBusy(false);
{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>)} setPreview(null);
<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> setError("");
<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> setCreateSeerr(false);
</div>} setChosen(event.target.value);
<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> >
{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>} <option value="">Choose an account</option>
<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> {[...accounts]
</section>} .sort((a, b) => a.name.localeCompare(b.name))
</div> .map((account) => (
</dialog> <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>}
{preview && (
<section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
<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>
<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>
<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>
<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 users 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>
{preview.row.issues.length > 0 && (
<ul className="identity-issues">
{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>
<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>
{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>
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>
{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>
</dialog>
);
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function IdentityReviewPage() { export default function IdentityReviewPage() {
redirect('/users?view=identities') redirect("/users?view=identities");
} }
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
import PortalClient from '../../portal/PortalClient' import PortalClient from "../../portal/PortalClient";
export default function AdminIssuesPage() { export default function AdminIssuesPage() {
return <PortalClient workspace="issue" /> return <PortalClient workspace="issue" />;
} }
File diff suppressed because it is too large Load Diff
+95 -46
View File
@@ -1,77 +1,126 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase, getToken } from '../lib/auth' import { authFetch, getApiBase, getToken } from "../lib/auth";
import AdminShell from '../ui/AdminShell' import AdminShell from "../ui/AdminShell";
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation' import { CONFIG_GROUPS, serviceStatusLabel } from "./configNavigation";
type ServiceState = { name: string; status: string } type ServiceState = { name: string; status: string };
export default function AdminLandingPage() { export default function AdminLandingPage() {
const router = useRouter() const router = useRouter();
const [services, setServices] = useState<ServiceState[]>([]) const [services, setServices] = useState<ServiceState[]>([]);
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
let active = true let active = true;
const load = async () => { const load = async () => {
if (!getToken()) { router.replace('/login'); return } if (!getToken()) {
try { router.replace("/login");
const response = await authFetch(`${getApiBase()}/auth/me`) return;
if (!response.ok) { router.replace('/login'); return }
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
if (!active) return
setReady(true)
const status = await authFetch(`${getApiBase()}/status/services`)
if (!status.ok) throw new Error('Status unavailable')
const data = await status.json()
if (active) setServices(Array.isArray(data.services) ? data.services : [])
} catch {
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
} }
} try {
void load() const response = await authFetch(`${getApiBase()}/auth/me`);
return () => { active = false } if (!response.ok) {
}, [router]) router.replace("/login");
return;
}
if ((await response.json())?.role !== "admin") {
router.replace("/");
return;
}
if (!active) return;
setReady(true);
const status = await authFetch(`${getApiBase()}/status/services`);
if (!status.ok) throw new Error("Status unavailable");
const data = await status.json();
if (active) setServices(Array.isArray(data.services) ? data.services : []);
} catch {
if (active) setError("Connection status is unavailable. Refresh the page to try again.");
}
};
void load();
return () => {
active = false;
};
}, [router]);
return ( return (
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works."> <AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings</p> : ( {!ready ? (
error ? (
<p className="error-banner" role="alert">
{error}
</p>
) : (
<p role="status">Loading settings</p>
)
) : (
<div className="config-directory"> <div className="config-directory">
{error && <p className="error-banner" role="alert">{error}</p>} {error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => ( {CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
<section className="config-directory-region" key={group.title}> <section className="config-directory-region" key={group.title}>
<header><h2>{group.title}</h2><p>{group.description}</p></header> <header>
<h2>{group.title}</h2>
<p>{group.description}</p>
</header>
<div className="config-directory-links"> <div className="config-directory-links">
{group.items.map((item) => { {group.items.map((item) => {
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase()) const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase());
return ( return (
<a href={item.href} key={item.href} className="config-directory-link"> <a href={item.href} key={item.href} className="config-directory-link">
{item.symbol && <span className="config-link-icon" aria-hidden="true">{item.symbol}</span>} {item.symbol && (
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span> <span className="config-link-icon" aria-hidden="true">
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>} {item.symbol}
<span className="config-link-arrow" aria-hidden="true"></span> </span>
)}
<span className="config-link-copy">
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
{item.service && (
<span className={`config-connection-badge is-${service?.status ?? "unknown"}`}>
{serviceStatusLabel(service?.status)}
</span>
)}
<span className="config-link-arrow" aria-hidden="true">
</span>
</a> </a>
) );
})} })}
</div> </div>
</section> </section>
))} ))}
<details className="config-advanced-directory"> <details className="config-advanced-directory">
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary> <summary>
<strong>Advanced tools</strong>
<span>Hosting, logs, caches and recovery</span>
</summary>
<div className="config-directory-links"> <div className="config-directory-links">
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => ( {CONFIG_GROUPS.filter((group) => group.advanced)
<a href={item.href} key={item.href} className="config-directory-link"> .flatMap((group) => group.items)
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span> .map((item) => (
<span className="config-link-arrow" aria-hidden="true"></span> <a href={item.href} key={item.href} className="config-directory-link">
</a> <span className="config-link-copy">
))} <strong>{item.label}</strong>
<small>{item.description}</small>
</span>
<span className="config-link-arrow" aria-hidden="true">
</span>
</a>
))}
</div> </div>
</details> </details>
</div> </div>
)} )}
</AdminShell> </AdminShell>
) );
} }
+2 -3
View File
@@ -1,6 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function AdminProfilesRedirectPage() { export default function AdminProfilesRedirectPage() {
redirect('/admin/invites') redirect("/admin/invites");
} }
+506 -112
View File
@@ -1,132 +1,526 @@
'use client' "use client";
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import Link from 'next/link' import Link from "next/link";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import '../../email-recaps/recaps.css' import "../../email-recaps/recaps.css";
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null } type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null };
type Delivery = { id: string; month: string; kind: string; email: string; username: string | null; state: string; attempts: number; created_at: number; updated_at: number; next_attempt_at: number; detail: string } type Delivery = {
type Overview = { settings: Settings; ready: boolean; detail: string; months: string[]; deliveries: Delivery[]; total: number; subscribers: number; worker_enabled: boolean } id: string;
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null } month: string;
const monthLabel = (month: string) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' }) kind: string;
const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled' email: string;
const stateLabels: Record<string, string> = { queued: 'Queued', preparing: 'Preparing report', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' } username: string | null;
state: string;
attempts: number;
created_at: number;
updated_at: number;
next_attempt_at: number;
detail: string;
};
type Overview = {
settings: Settings;
ready: boolean;
detail: string;
months: string[];
deliveries: Delivery[];
total: number;
subscribers: number;
worker_enabled: boolean;
};
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null };
const monthLabel = (month: string) =>
new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
const dateLabel = (value?: number | null) =>
value
? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`
: "Not scheduled";
const stateLabels: Record<string, string> = {
queued: "Queued",
preparing: "Preparing report",
sending: "Sending",
sent: "Accepted by mail server",
retry: "Retry scheduled",
failed: "Failed",
unknown: "Needs review",
cancelled: "Cancelled",
};
export default function EmailRecapsAdminPage() { export default function EmailRecapsAdminPage() {
const router = useRouter() const router = useRouter();
const [data, setData] = useState<Overview | null>(null) const [data, setData] = useState<Overview | null>(null);
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: '' }) const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: "" });
const [month, setMonth] = useState('') const [month, setMonth] = useState("");
const [preview, setPreview] = useState<Preview | null>(null) const [preview, setPreview] = useState<Preview | null>(null);
const [previewMode, setPreviewMode] = useState<'html' | 'text'>('html') const [previewMode, setPreviewMode] = useState<"html" | "text">("html");
const [error, setError] = useState('') const [error, setError] = useState("");
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [busy, setBusy] = useState('') const [busy, setBusy] = useState("");
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const testRequest = useRef<{ month: string; id: string } | null>(null) const testRequest = useRef<{ month: string; id: string } | null>(null);
const initialized = useRef(false) const initialized = useRef(false);
const previewController = useRef<AbortController | null>(null) const previewController = useRef<AbortController | null>(null);
const responseData = useCallback(async (response: Response) => { const responseData = useCallback(
if (response.status === 401) { router.replace('/login?next=%2Fadmin%2Frecaps'); throw new Error('Sign in to continue.') } async (response: Response) => {
if (response.status === 403) { router.replace('/'); throw new Error('Administrator access is required.') } if (response.status === 401) {
const result = await response.json().catch(() => ({})) router.replace("/login?next=%2Fadmin%2Frecaps");
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not complete this action. Check your settings and try again.') throw new Error("Sign in to continue.");
return result }
}, [router]) if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not complete this action. Check your settings and try again.",
);
return result;
},
[router],
);
useEffect(() => { useEffect(() => {
const abort = new AbortController() void revision;
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal }).then(responseData).then((result: Overview) => { const abort = new AbortController();
if (abort.signal.aborted) return void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal })
setData(result) .then(responseData)
if (!initialized.current) { setSettings(result.settings); setMonth(result.months[0] || ''); initialized.current = true } .then((result: Overview) => {
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) if (abort.signal.aborted) return;
return () => abort.abort() setData(result);
}, [offset, revision, responseData]) if (!initialized.current) {
setSettings(result.settings);
setMonth(result.months[0] || "");
initialized.current = true;
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [offset, revision, responseData]);
useEffect(() => { useEffect(() => {
if (!data?.deliveries.some((delivery) => ['queued', 'preparing', 'sending', 'retry'].includes(delivery.state))) return if (!data?.deliveries.some((delivery) => ["queued", "preparing", "sending", "retry"].includes(delivery.state)))
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000) return;
return () => window.clearInterval(timer) const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
}, [data]) return () => window.clearInterval(timer);
useEffect(() => () => previewController.current?.abort(), []) }, [data]);
useEffect(() => () => previewController.current?.abort(), []);
const dirty = !!data && (settings.enabled !== data.settings.enabled || settings.day !== data.settings.day || settings.hour !== data.settings.hour || settings.public_url !== data.settings.public_url) const dirty =
!!data &&
(settings.enabled !== data.settings.enabled ||
settings.day !== data.settings.day ||
settings.hour !== data.settings.hour ||
settings.public_url !== data.settings.public_url);
const save = async (event: FormEvent) => { const save = async (event: FormEvent) => {
event.preventDefault() event.preventDefault();
if (busy) return if (busy) return;
setBusy('save'); setError(''); setNotice('') setBusy("save");
setError("");
setNotice("");
try { try {
const { enabled, day, hour } = settings const { enabled, day, hour } = settings;
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour }) })) as Settings const result = (await responseData(
setSettings(result); setData((current) => current ? { ...current, settings: result } : current) await authFetch(`${getApiBase()}/admin/email-recaps`, {
setPreview(null) method: "PUT",
setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.') headers: { "Content-Type": "application/json" },
setRevision((value) => value + 1) body: JSON.stringify({ enabled, day, hour }),
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save the schedule.') } }),
finally { setBusy('') } )) as Settings;
} setSettings(result);
setData((current) => (current ? { ...current, settings: result } : current));
setPreview(null);
setNotice(
result.enabled
? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.`
: "Settings saved. Scheduled delivery is paused.",
);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the schedule.");
} finally {
setBusy("");
}
};
const loadPreview = async () => { const loadPreview = async () => {
if (busy || !month) return if (busy || !month) return;
const abort = new AbortController() const abort = new AbortController();
previewController.current?.abort(); previewController.current = abort previewController.current?.abort();
setBusy('preview'); setError(''); setNotice(''); setPreview(null) previewController.current = abort;
setBusy("preview");
setError("");
setNotice("");
setPreview(null);
try { try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal })) as Preview const result = (await responseData(
if (!abort.signal.aborted) setPreview(result) await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal }),
} catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : 'Could not prepare your preview.') } )) as Preview;
finally { if (!abort.signal.aborted) setBusy('') } if (!abort.signal.aborted) setPreview(result);
} } catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not prepare your preview.");
} finally {
if (!abort.signal.aborted) setBusy("");
}
};
const sendTest = async () => { const sendTest = async () => {
if (busy || !preview || preview.month !== month) return if (busy || !preview || preview.month !== month) return;
if (!testRequest.current || testRequest.current.month !== month) testRequest.current = { month, id: crypto.randomUUID() } if (!testRequest.current || testRequest.current.month !== month)
setBusy('test'); setError(''); setNotice('') testRequest.current = { month, id: crypto.randomUUID() };
setBusy("test");
setError("");
setNotice("");
try { try {
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, request_id: testRequest.current.id }) })) const result = await responseData(
setNotice(result.message); testRequest.current = null; setOffset(0); setRevision((value) => value + 1) await authFetch(`${getApiBase()}/admin/email-recaps/test`, {
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your test.') } method: "POST",
finally { setBusy('') } headers: { "Content-Type": "application/json" },
} body: JSON.stringify({ month, request_id: testRequest.current.id }),
}),
);
setNotice(result.message);
testRequest.current = null;
setOffset(0);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your test.");
} finally {
setBusy("");
}
};
return <AdminShell title="Monthly email recaps" subtitle="Give each user a personal look back at their month in viewing." actions={<a className="ghost-button" href="/admin/notifications">Email settings </a>}> return (
<div className="recap-admin"> <AdminShell
{error && <p className="error-banner" role="alert">{error}</p>} title="Monthly email recaps"
{notice && <p className="status-banner" role="status">{notice}</p>} subtitle="Give each user a personal look back at their month in viewing."
{!data && !error && <p role="status">Loading email recaps</p>} actions={
{!data && error && <button className="ghost-button" type="button" onClick={() => { setError(''); setRevision((value) => value + 1) }}>Try again</button>} <a className="ghost-button" href="/admin/notifications">
{data && <> Email settings
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Schedule running' : 'Schedule paused'}</span><p>{data.settings.enabled ? `Next send ${dateLabel(data.settings.next_send_at)}` : 'Start the schedule when youre ready for monthly delivery.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div> </a>
<div className="recap-admin-grid"> }
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div> >
<p>Send the previous months report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p> <div className="recap-admin">
<form className="recap-schedule-form" onSubmit={save}> {error && (
<label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" value={settings.public_url} readOnly /><small>Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when that address changes.</small></label> <p className="error-banner" role="alert">
<div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div> {error}
<label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label> </p>
<p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p> )}
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save schedule'}</button> {notice && (
</form> <p className="status-banner" role="status">
{!data.ready && <p className="recap-setup-note">{data.detail} <a href="/admin/notifications">Review email settings </a></p>} {notice}
</section> </p>
<section className="admin-panel recap-panel"><span className="recap-eyebrow">Make it yours</span><h2>Preview your recap</h2><p>See your own viewing highlights in the email design. A test goes only to your confirmed profile email.</p> )}
<label className="recap-month-label" htmlFor="recap-month">Report month<select id="recap-month" value={month} disabled={!!busy} onChange={(event) => { setMonth(event.target.value); setPreview(null); testRequest.current = null }}>{data.months.map((value) => <option key={value} value={value}>{monthLabel(value)}</option>)}</select></label> {!data && !error && <p role="status">Loading email recaps</p>}
<div className="recap-actions"><button type="button" className="account-primary" onClick={() => void loadPreview()} disabled={!!busy || dirty || !month}>{busy === 'preview' ? 'Preparing preview…' : 'Preview my recap'}</button><button type="button" className="account-secondary" onClick={() => void sendTest()} disabled={!!busy || dirty || !preview || !data.ready}>{busy === 'test' ? 'Queuing test…' : 'Send test to me'}</button></div> {!data && error && (
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>} <button
<div className="recap-preview-guidance"><h3>One email. Your month.</h3><ul><li>Minutes, movies, episodes and requests</li><li>Changes from the previous month</li><li>Most watched titles and your longest run</li><li>A link to the full report and easy unsubscribe</li></ul><a href="/profile#monthly-recaps">Confirm your email in Profile </a></div> className="ghost-button"
</section> type="button"
</div> onClick={() => {
{preview && <section className="admin-panel recap-panel recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h2>{preview.subject}</h2><p>For {preview.email || 'your profile email'} · Preview links use your saved public address.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={previewMode === 'html'} onClick={() => setPreviewMode('html')}>Email design</button><button type="button" aria-pressed={previewMode === 'text'} onClick={() => setPreviewMode('text')}>Plain text</button></div></div>{previewMode === 'html' ? <iframe title="Monthly recap email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</section>} setError("");
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => { setError(''); setRevision((value) => value + 1) }}>Refresh history</button></div> setRevision((value) => value + 1);
{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Report</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((delivery) => <tr key={delivery.id}><td><strong>{delivery.username || 'Removed account'}</strong><small>{delivery.email}</small></td><td>{monthLabel(delivery.month)}<small>{delivery.kind === 'test' ? 'Test email' : delivery.kind === 'on_demand' ? 'Requested by user' : 'Scheduled recap'}</small></td><td><span className={`recap-pill ${delivery.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(delivery.state) ? 'is-attention' : ''}`}>{stateLabels[delivery.state] || delivery.state}</span><small>{delivery.attempts} {delivery.attempts === 1 ? 'attempt' : 'attempts'} · {delivery.detail || 'Waiting for the next worker check.'}</small>{delivery.state === 'retry' && <small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>}{delivery.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(delivery.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button className="ghost-button" type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button className="ghost-button" type="button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first recap starts here</h3><p>Preview your email, send yourself a test, then start the monthly schedule. Delivery results will appear here.</p></div>} }}
</section> >
</>} Try again
</div> </button>
</AdminShell> )}
{data && (
<>
<div className="recap-overview-strip">
<div>
<span className={`recap-pill ${data.settings.enabled ? "is-enabled" : ""}`}>
{data.settings.enabled ? "Schedule running" : "Schedule paused"}
</span>
<p>
{data.settings.enabled
? `Next send ${dateLabel(data.settings.next_send_at)}`
: "Start the schedule when youre ready for monthly delivery."}
</p>
</div>
<div className="recap-subscriber-count">
<strong>{data.subscribers}</strong>
<span>confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}</span>
</div>
</div>
<div className="recap-admin-grid">
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Set the rhythm</span>
<h2>Monthly schedule</h2>
</div>
</div>
<p>
Send the previous months report to users who have opted in and confirmed their email. All report
periods and send times use UTC.
</p>
<form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">
Public Magent address
<input id="recap-public-url" type="url" value={settings.public_url} readOnly />
<small>
Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when
that address changes.
</small>
</label>
<div className="recap-schedule-fields">
<label htmlFor="recap-day">
Day of the month
<select
id="recap-day"
value={settings.day}
onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 28 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}
</option>
))}
</select>
</label>
<label htmlFor="recap-hour">
Send time (UTC)
<select
id="recap-hour"
value={settings.hour}
onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 24 }, (_, hour) => (
<option key={hour} value={hour}>
{String(hour).padStart(2, "0")}:00 UTC
</option>
))}
</select>
</label>
</div>
<label className="recap-checkbox">
<input
type="checkbox"
checked={settings.enabled}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })}
/>
<span>Enable scheduled monthly recaps</span>
</label>
<p className="recap-muted">
Starting or changing the schedule begins at its next future send time. Pausing cancels queued
monthly emails.
</p>
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>
{busy === "save" ? "Saving…" : "Save schedule"}
</button>
</form>
{!data.ready && (
<p className="recap-setup-note">
{data.detail} <a href="/admin/notifications">Review email settings </a>
</p>
)}
</section>
<section className="admin-panel recap-panel">
<span className="recap-eyebrow">Make it yours</span>
<h2>Preview your recap</h2>
<p>
See your own viewing highlights in the email design. A test goes only to your confirmed profile email.
</p>
<label className="recap-month-label" htmlFor="recap-month">
Report month
<select
id="recap-month"
value={month}
disabled={!!busy}
onChange={(event) => {
setMonth(event.target.value);
setPreview(null);
testRequest.current = null;
}}
>
{data.months.map((value) => (
<option key={value} value={value}>
{monthLabel(value)}
</option>
))}
</select>
</label>
<div className="recap-actions">
<button
type="button"
className="account-primary"
onClick={() => void loadPreview()}
disabled={!!busy || dirty || !month}
>
{busy === "preview" ? "Preparing preview…" : "Preview my recap"}
</button>
<button
type="button"
className="account-secondary"
onClick={() => void sendTest()}
disabled={!!busy || dirty || !preview || !data.ready}
>
{busy === "test" ? "Queuing test…" : "Send test to me"}
</button>
</div>
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
<div className="recap-preview-guidance">
<h3>One email. Your month.</h3>
<ul>
<li>Minutes, movies, episodes and requests</li>
<li>Changes from the previous month</li>
<li>Most watched titles and your longest run</li>
<li>A link to the full report and easy unsubscribe</li>
</ul>
<a href="/profile#monthly-recaps">Confirm your email in Profile </a>
</div>
</section>
</div>
{preview && (
<section className="admin-panel recap-panel recap-preview">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Email preview</span>
<h2>{preview.subject}</h2>
<p>For {preview.email || "your profile email"} · Preview links use your saved public address.</p>
</div>
<div className="recap-mode-buttons">
<button type="button" aria-pressed={previewMode === "html"} onClick={() => setPreviewMode("html")}>
Email design
</button>
<button type="button" aria-pressed={previewMode === "text"} onClick={() => setPreviewMode("text")}>
Plain text
</button>
</div>
</div>
{previewMode === "html" ? (
<iframe
title="Monthly recap email preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={preview.body_html}
/>
) : (
<pre className="recap-plain-preview">{preview.body_text}</pre>
)}
</section>
)}
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">From queue to inbox</span>
<h2>Delivery history</h2>
<p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p>
</div>
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => {
setError("");
setRevision((value) => value + 1);
}}
>
Refresh history
</button>
</div>
{data.deliveries.length ? (
<>
<div className="recap-history-scroll">
<table className="recap-history">
<thead>
<tr>
<th scope="col">Recipient</th>
<th scope="col">Report</th>
<th scope="col">Delivery</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{data.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td>
<strong>{delivery.username || "Removed account"}</strong>
<small>{delivery.email}</small>
</td>
<td>
{monthLabel(delivery.month)}
<small>
{delivery.kind === "test"
? "Test email"
: delivery.kind === "on_demand"
? "Requested by user"
: "Scheduled recap"}
</small>
</td>
<td>
<span
className={`recap-pill ${delivery.state === "sent" ? "is-enabled" : ["failed", "unknown"].includes(delivery.state) ? "is-attention" : ""}`}
>
{stateLabels[delivery.state] || delivery.state}
</span>
<small>
{delivery.attempts} {delivery.attempts === 1 ? "attempt" : "attempts"} ·{" "}
{delivery.detail || "Waiting for the next worker check."}
</small>
{delivery.state === "retry" && (
<small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>
)}
{delivery.state === "unknown" && (
<small>Automatic retries are stopped to avoid a duplicate email.</small>
)}
</td>
<td>{dateLabel(delivery.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="recap-pagination">
<span>
{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}
</span>
<div className="recap-actions">
<button
className="ghost-button"
type="button"
disabled={!offset}
onClick={() => setOffset(Math.max(0, offset - 50))}
>
Previous
</button>
<button
className="ghost-button"
type="button"
disabled={offset + 50 >= data.total}
onClick={() => setOffset(offset + 50)}
>
Next
</button>
</div>
</div>
</>
) : (
<div className="recap-empty">
<span aria-hidden="true"></span>
<h3>Your first recap starts here</h3>
<p>
Preview your email, send yourself a test, then start the monthly schedule. Delivery results will
appear here.
</p>
</div>
)}
</section>
</>
)}
</div>
</AdminShell>
);
} }
+76 -88
View File
@@ -1,115 +1,111 @@
'use client' "use client";
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
type RequestRow = { type RequestRow = {
id: number id: number;
title?: string | null title?: string | null;
year?: number | null year?: number | null;
type?: string | null type?: string | null;
statusLabel?: string | null statusLabel?: string | null;
requestedBy?: string | null requestedBy?: string | null;
createdAt?: string | null createdAt?: string | null;
} };
const REQUEST_STAGE_OPTIONS = [ const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' }, { value: "all", label: "All stages" },
{ value: 'pending', label: 'Waiting for approval' }, { value: "pending", label: "Waiting for approval" },
{ value: 'approved', label: 'Approved' }, { value: "approved", label: "Approved" },
{ value: 'in_progress', label: 'In progress' }, { value: "in_progress", label: "In progress" },
{ value: 'working', label: 'Working on it' }, { value: "working", label: "Working on it" },
{ value: 'partial', label: 'Partially ready' }, { value: "partial", label: "Partially ready" },
{ value: 'ready', label: 'Ready to watch' }, { value: "ready", label: "Ready to watch" },
{ value: 'declined', label: 'Declined' }, { value: "declined", label: "Declined" },
] ];
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown' if (!value) return "Unknown";
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();
} };
export default function AdminRequestsAllPage() { export default function AdminRequestsAllPage() {
const router = useRouter() const router = useRouter();
const [rows, setRows] = useState<RequestRow[]>([]) const [rows, setRows] = useState<RequestRow[]>([]);
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [pageSize, setPageSize] = useState(50) const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [stage, setStage] = useState('all') const [stage, setStage] = useState("all");
const pageCount = useMemo(() => { const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1 if (!total || pageSize <= 0) return 1;
return Math.max(1, Math.ceil(total / pageSize)) return Math.max(1, Math.ceil(total / pageSize));
}, [total, pageSize]) }, [total, pageSize]);
const load = async () => { const load = useCallback(async () => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const skip = (page - 1) * pageSize const skip = (page - 1) * pageSize;
const params = new URLSearchParams({ const params = new URLSearchParams({
take: String(pageSize), take: String(pageSize),
skip: String(skip), skip: String(skip),
}) });
if (stage !== 'all') { if (stage !== "all") {
params.set('stage', stage) params.set("stage", stage);
} }
const response = await authFetch( const response = await authFetch(`${baseUrl}/admin/requests/all?${params.toString()}`);
`${baseUrl}/admin/requests/all?${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;
} }
if (response.status === 403) { if (response.status === 403) {
router.push('/') router.push("/");
return return;
} }
throw new Error(`Load failed: ${response.status}`) throw new Error(`Load failed: ${response.status}`);
} }
const data = await response.json() const data = await response.json();
setRows(Array.isArray(data?.results) ? data.results : []) setRows(Array.isArray(data?.results) ? data.results : []);
setTotal(Number(data?.total ?? 0)) setTotal(Number(data?.total ?? 0));
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError('Unable to load requests.') setError("Unable to load requests.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} }, [page, pageSize, router, stage]);
useEffect(() => { useEffect(() => {
void load() void load();
}, [page, pageSize, stage]) }, [load]);
useEffect(() => { useEffect(() => {
if (page > pageCount) { if (page > pageCount) {
setPage(pageCount) setPage(pageCount);
} }
}, [pageCount, page]) }, [pageCount, page]);
useEffect(() => { useEffect(() => {
setPage(1) void stage;
}, [stage]) setPage(1);
}, [stage]);
return ( return (
<AdminShell <AdminShell title="All requests" subtitle="Paginated view of every cached request.">
title="All requests"
subtitle="Paginated view of every cached request."
>
<section className="admin-section"> <section className="admin-section">
<div className="admin-toolbar"> <div className="admin-toolbar">
<div className="admin-toolbar-info"> <div className="admin-toolbar-info">
@@ -160,10 +156,10 @@ export default function AdminRequestsAllPage() {
> >
<span> <span>
{row.title || `Request #${row.id}`} {row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''} {row.year ? ` (${row.year})` : ""}
</span> </span>
<span>{row.statusLabel || 'Unknown'}</span> <span>{row.statusLabel || "Unknown"}</span>
<span>{row.requestedBy || 'Unknown'}</span> <span>{row.requestedBy || "Unknown"}</span>
<span>{formatDateTime(row.createdAt)}</span> <span>{formatDateTime(row.createdAt)}</span>
</button> </button>
))} ))}
@@ -179,22 +175,14 @@ export default function AdminRequestsAllPage() {
<span> <span>
Page {page} of {pageCount} Page {page} of {pageCount}
</span> </span>
<button <button type="button" onClick={() => setPage(page + 1)} disabled={page >= pageCount}>
type="button"
onClick={() => setPage(page + 1)}
disabled={page >= pageCount}
>
Next Next
</button> </button>
<button <button type="button" onClick={() => setPage(pageCount)} disabled={page >= pageCount}>
type="button"
onClick={() => setPage(pageCount)}
disabled={page >= pageCount}
>
Last Last
</button> </button>
</div> </div>
</section> </section>
</AdminShell> </AdminShell>
) );
} }
+92 -115
View File
@@ -1,106 +1,106 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import AdminShell from '../../ui/AdminShell' import AdminShell from "../../ui/AdminShell";
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
type FlowStage = { type FlowStage = {
title: string title: string;
input: string input: string;
action: string action: string;
output: string output: string;
} };
const REQUEST_FLOW: FlowStage[] = [ const REQUEST_FLOW: FlowStage[] = [
{ {
title: 'Identity + access', title: "Identity + access",
input: 'Jellyfin/local login', input: "Jellyfin/local login",
action: 'Magent validates credentials and role', action: "Magent validates credentials and role",
output: 'JWT token + user scope', output: "JWT token + user scope",
}, },
{ {
title: 'Request intake', title: "Request intake",
input: 'Seerr request ID', input: "Seerr request ID",
action: 'Magent snapshots request + media metadata', action: "Magent snapshots request + media metadata",
output: 'Unified request state', output: "Unified request state",
}, },
{ {
title: 'Queue orchestration', title: "Queue orchestration",
input: 'Approved request', input: "Approved request",
action: 'Sonarr/Radarr add/search operations', action: "Sonarr/Radarr add/search operations",
output: 'Grab decision', output: "Grab decision",
}, },
{ {
title: 'Download execution', title: "Download execution",
input: 'Selected release', input: "Selected release",
action: 'qBittorrent downloads + reports progress', action: "qBittorrent downloads + reports progress",
output: 'Import-ready payload', output: "Import-ready payload",
}, },
{ {
title: 'Library import', title: "Library import",
input: 'Completed download', input: "Completed download",
action: 'Sonarr/Radarr import and finalize', action: "Sonarr/Radarr import and finalize",
output: 'Available media object', output: "Available media object",
}, },
{ {
title: 'Playback availability', title: "Playback availability",
input: 'Imported media', input: "Imported media",
action: 'Jellyfin refresh + link resolution', action: "Jellyfin refresh + link resolution",
output: 'Ready-to-watch state', output: "Ready-to-watch state",
}, },
] ];
export default function AdminSystemGuidePage() { export default function AdminSystemGuidePage() {
const router = useRouter() const router = useRouter();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false) const [authorized, setAuthorized] = useState(false);
useEffect(() => { useEffect(() => {
let active = true let active = true;
const load = async () => { const load = async () => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
router.push('/login') router.push("/login");
return return;
} }
router.push('/') router.push("/");
return return;
} }
const me = await response.json() const me = await response.json();
if (!active) return if (!active) return;
if (me?.role !== 'admin') { if (me?.role !== "admin") {
router.push('/') router.push("/");
return return;
} }
setAuthorized(true) setAuthorized(true);
} catch (error) { } catch (error) {
console.error(error) console.error(error);
router.push('/') router.push("/");
} finally { } finally {
if (active) setLoading(false) if (active) setLoading(false);
} }
} };
void load() void load();
return () => { return () => {
active = false active = false;
} };
}, [router]) }, [router]);
if (loading) { if (loading) {
return <main className="card">Loading system guide...</main> return <main className="card">Loading system guide...</main>;
} }
if (!authorized) { if (!authorized) {
return null return null;
} }
const rail = ( const rail = (
@@ -112,26 +112,23 @@ export default function AdminSystemGuidePage() {
<span className="small-pill">Admin only</span> <span className="small-pill">Admin only</span>
</div> </div>
</div> </div>
) );
return ( return (
<AdminShell <AdminShell title="System guide" subtitle="Service connections, controls, and recovery paths." rail={rail}>
title="System guide"
subtitle="Service connections, controls, and recovery paths."
rail={rail}
>
<section className="admin-section system-guide"> <section className="admin-section system-guide">
<div className="admin-panel"> <div className="admin-panel">
<h2>End-to-end system flow</h2> <h2>End-to-end system flow</h2>
<p className="lede"> <p className="lede">
This is the runtime path the platform follows from authentication through to playback This is the runtime path the platform follows from authentication through to playback availability.
availability.
</p> </p>
<div className="system-flow-track"> <div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => ( {REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment"> <div key={stage.title} className="system-flow-segment">
<article className="system-flow-card"> <article className="system-flow-card">
<div className="system-flow-card-title">{index + 1}. {stage.title}</div> <div className="system-flow-card-title">
{index + 1}. {stage.title}
</div>
<div className="system-flow-card-row"> <div className="system-flow-card-row">
<span>Input</span> <span>Input</span>
<strong>{stage.input}</strong> <strong>{stage.input}</strong>
@@ -145,7 +142,11 @@ export default function AdminSystemGuidePage() {
<strong>{stage.output}</strong> <strong>{stage.output}</strong>
</div> </div>
</article> </article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>} {index < REQUEST_FLOW.length - 1 && (
<div className="system-flow-arrow" aria-hidden="true">
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -157,30 +158,23 @@ export default function AdminSystemGuidePage() {
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Magent</h3> <h3>Magent</h3>
<p> <p>
Handles authentication, request pages, live event updates, invite workflows, Handles authentication, request pages, live event updates, invite workflows, diagnostics, notifications,
diagnostics, notifications, and admin operations. and admin operations.
</p> </p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Seerr</h3> <h3>Seerr</h3>
<p> <p>
Stores the request itself and remains the request-state source for approval and Stores the request itself and remains the request-state source for approval and media request metadata.
media request metadata.
</p> </p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Jellyfin</h3> <h3>Jellyfin</h3>
<p> <p>Provides user sign-in identity and the final playback destination once content is available.</p>
Provides user sign-in identity and the final playback destination once content is
available.
</p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Sonarr / Radarr</h3> <h3>Sonarr / Radarr</h3>
<p> <p>Control queue placement, quality-profile decisions, import handling, and release monitoring.</p>
Control queue placement, quality-profile decisions, import handling, and release
monitoring.
</p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Prowlarr</h3> <h3>Prowlarr</h3>
@@ -188,10 +182,7 @@ export default function AdminSystemGuidePage() {
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>qBittorrent</h3> <h3>qBittorrent</h3>
<p> <p>Executes the download and exposes live progress, paused states, and queue visibility.</p>
Executes the download and exposes live progress, paused states, and queue
visibility.
</p>
</article> </article>
</div> </div>
</div> </div>
@@ -213,10 +204,7 @@ export default function AdminSystemGuidePage() {
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Invite management</h3> <h3>Invite management</h3>
<p> <p>Master template, profile assignment, invite access policy, invite emails, and trace map lineage.</p>
Master template, profile assignment, invite access policy, invite emails, and trace
map lineage.
</p>
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Requests + cache</h3> <h3>Requests + cache</h3>
@@ -225,8 +213,8 @@ export default function AdminSystemGuidePage() {
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Maintenance + diagnostics</h3> <h3>Maintenance + diagnostics</h3>
<p> <p>
Connectivity checks, live diagnostics, database repair, cleanup, log review, and Connectivity checks, live diagnostics, database repair, cleanup, log review, and nuclear flush/resync
nuclear flush/resync operations. operations.
</p> </p>
</article> </article>
</div> </div>
@@ -235,23 +223,11 @@ export default function AdminSystemGuidePage() {
<div className="admin-panel"> <div className="admin-panel">
<h2>User and invite model</h2> <h2>User and invite model</h2>
<ol className="system-decision-list"> <ol className="system-decision-list">
<li> <li>Jellyfin is used for sign-in identity and user presence across the platform.</li>
Jellyfin is used for sign-in identity and user presence across the platform. <li>Seerr provides request ownership and request-state data for Magent request pages.</li>
</li> <li>Invite links, invite profiles, blanket rules, and invite-access controls are managed inside Magent.</li>
<li> <li>If invite tracing is enabled, the lineage view shows who invited whom and how the chain branches.</li>
Seerr provides request ownership and request-state data for Magent request pages. <li>Cross-system removal and ban flows are initiated from Magent admin controls.</li>
</li>
<li>
Invite links, invite profiles, blanket rules, and invite-access controls are managed
inside Magent.
</li>
<li>
If invite tracing is enabled, the lineage view shows who invited whom and how the
chain branches.
</li>
<li>
Cross-system removal and ban flows are initiated from Magent admin controls.
</li>
</ol> </ol>
</div> </div>
@@ -265,7 +241,8 @@ export default function AdminSystemGuidePage() {
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options. In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li> </li>
<li> <li>
Release exists and user should not pick manually <span></span> run <strong>Search + auto-download</strong>. Release exists and user should not pick manually <span></span> run{" "}
<strong>Search + auto-download</strong>.
</li> </li>
<li> <li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>. Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
@@ -295,5 +272,5 @@ export default function AdminSystemGuidePage() {
</div> </div>
</section> </section>
</AdminShell> </AdminShell>
) );
} }
+56 -56
View File
@@ -1,95 +1,95 @@
'use client' "use client";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type SiteInfo = { type SiteInfo = {
changelog?: string changelog?: string;
} };
type ChangelogGroup = { type ChangelogGroup = {
date: string date: string;
entries: string[] entries: string[];
} };
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const parseChangelog = (raw: string): ChangelogGroup[] => { const parseChangelog = (raw: string): ChangelogGroup[] => {
const groups: ChangelogGroup[] = [] const groups: ChangelogGroup[] = [];
for (const rawLine of raw.split('\n')) { for (const rawLine of raw.split("\n")) {
const line = rawLine.trim() const line = rawLine.trim();
if (!line) continue if (!line) continue;
const [candidateDate, ...messageParts] = line.split('|') const [candidateDate, ...messageParts] = line.split("|");
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) { if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
const message = messageParts.join('|').trim() const message = messageParts.join("|").trim();
if (!message) continue if (!message) continue;
const currentGroup = groups[groups.length - 1] const currentGroup = groups[groups.length - 1];
if (currentGroup?.date === candidateDate) { if (currentGroup?.date === candidateDate) {
currentGroup.entries.push(message) currentGroup.entries.push(message);
} else { } else {
groups.push({ date: candidateDate, entries: [message] }) groups.push({ date: candidateDate, entries: [message] });
} }
continue continue;
} }
if (groups.length === 0) { if (groups.length === 0) {
groups.push({ date: 'Updates', entries: [line] }) groups.push({ date: "Updates", entries: [line] });
} else { } else {
groups[groups.length - 1].entries.push(line) groups[groups.length - 1].entries.push(line);
} }
} }
return groups return groups;
} };
export default function ChangelogPage() { export default function ChangelogPage() {
const router = useRouter() const router = useRouter();
const [groups, setGroups] = useState<ChangelogGroup[]>([]) const [groups, setGroups] = useState<ChangelogGroup[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const token = getToken() const token = getToken();
if (!token) { if (!token) {
router.push('/login') router.push("/login");
return return;
} }
let active = true let active = true;
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/site/info`) const response = await authFetch(`${baseUrl}/site/info`);
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('Failed to load changelog') throw new Error("Failed to load changelog");
} }
const data: SiteInfo = await response.json() const data: SiteInfo = await response.json();
if (!active) return if (!active) return;
setGroups(parseChangelog(data?.changelog ?? '')) setGroups(parseChangelog(data?.changelog ?? ""));
} catch (err) { } catch (err) {
console.error(err) console.error(err);
if (!active) return if (!active) return;
setGroups([]) setGroups([]);
} finally { } finally {
if (active) setLoading(false) if (active) setLoading(false);
} }
} };
void load() void load();
return () => { return () => {
active = false active = false;
} };
}, [router]) }, [router]);
const content = useMemo(() => { const content = useMemo(() => {
if (loading) { if (loading) {
return <div className="loading-text">Loading changelog...</div> return <div className="loading-text">Loading changelog...</div>;
} }
if (groups.length === 0) { if (groups.length === 0) {
return <div className="meta">No updates posted yet.</div> return <div className="meta">No updates posted yet.</div>;
} }
return ( return (
<div className="changelog-groups"> <div className="changelog-groups">
@@ -104,13 +104,13 @@ export default function ChangelogPage() {
</section> </section>
))} ))}
</div> </div>
) );
}, [groups, loading]) }, [groups, loading]);
return ( return (
<main className="card changelog-page"> <main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." /> <PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content} {content}
</main> </main>
) );
} }
+30 -13
View File
@@ -1,17 +1,34 @@
import './style.css' import "./style.css";
export const metadata = { title: 'Coming soon | Magent — Grizzlyflix' } export const metadata = { title: "Coming soon | Magent — Grizzlyflix" };
export default function ComingSoonPage() { export default function ComingSoonPage() {
return <main className="launch-cover"> return (
<div className="launch-brand">GRIZZLYFLIX</div> <main className="launch-cover">
<span className="launch-badge">COMING SOON</span> <div className="launch-brand">GRIZZLYFLIX</div>
<h1>Your next watch.<br /><em>Made simpler.</em></h1> <span className="launch-badge">COMING SOON</span>
<p className="launch-intro">The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.</p> <h1>
<div className="launch-path" aria-label="Request journey"> Your next watch.
{['Request', 'Track', 'Watch'].map((label, index) => <div key={label}><span>0{index + 1}</span><strong>{label}</strong></div>)} <br />
</div> <em>Made simpler.</em>
<p className="launch-note">Were getting everything ready. Check back soon.</p> </h1>
<footer><strong>Magent</strong><span>Grizzlyflix member portal</span><a href="/login">Admin sign in</a></footer> <p className="launch-intro">
</main> The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.
</p>
<ol className="launch-path" aria-label="Request journey">
{["Request", "Track", "Watch"].map((label, index) => (
<li key={label}>
<span>0{index + 1}</span>
<strong>{label}</strong>
</li>
))}
</ol>
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer>
<strong>Magent</strong>
<span>Grizzlyflix member portal</span>
<a href="/login">Admin sign in</a>
</footer>
</main>
);
} }
+3 -3
View File
@@ -5,9 +5,9 @@
.launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; } .launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; }
.launch-cover h1 em { color: #c7bdff; font-style: normal; } .launch-cover h1 em { color: #c7bdff; font-style: normal; }
.launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; } .launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; }
.launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; } .launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; padding: 0; list-style: none; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
.launch-path > div { padding: 22px 12px; display: grid; gap: 8px; } .launch-path > li { padding: 22px 12px; display: grid; gap: 8px; }
.launch-path > div + div { border-left: 1px solid #ffffff15; } .launch-path > li + li { border-left: 1px solid #ffffff15; }
.launch-path span { color: #8be7f1; font-size: 12px; } .launch-path span { color: #8be7f1; font-size: 12px; }
.launch-path strong { font-size: 18px; } .launch-path strong { font-size: 18px; }
.launch-note { color: #a9a4b5; font-size: 14px; } .launch-note { color: #a9a4b5; font-size: 14px; }
+131 -52
View File
@@ -1,66 +1,145 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
import BrandingLogo from '../ui/BrandingLogo' import BrandingLogo from "../ui/BrandingLogo";
import './recaps.css' import "./recaps.css";
type LinkAction = { action: 'confirm' | 'unsubscribe'; token: string } type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
export default function EmailRecapLinkPage() { export default function EmailRecapLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null) const [link, setLink] = useState<LinkAction | null>(null);
const [state, setState] = useState('loading') const [state, setState] = useState("loading");
const [error, setError] = useState('') const [error, setError] = useState("");
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
let controller: AbortController | null = null let controller: AbortController | null = null;
const checkLink = () => { const checkLink = () => {
controller?.abort() controller?.abort();
const abort = new AbortController() const abort = new AbortController();
controller = abort controller = abort;
setError(''); setState('loading'); setLink(null) setError("");
setState("loading");
setLink(null);
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it. // Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1)) const params = new URLSearchParams(window.location.hash.slice(1));
const action = params.get('action') const action = params.get("action");
const token = params.get('token') || '' const token = params.get("token") || "";
if ((action !== 'confirm' && action !== 'unsubscribe') || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) { if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError('This email link is incomplete. Open Profile to manage your monthly recaps.'); setState('error'); return setError("This email link is incomplete. Open Profile to manage your monthly recaps.");
setState("error");
return;
} }
const payload = { action, token } as LinkAction const payload = { action, token } as LinkAction;
setLink(payload) setLink(payload);
void fetch(`${getApiBase()}/email-recaps/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abort.signal, credentials: 'omit' }).then(async (response) => { void fetch(`${getApiBase()}/email-recaps/check`, {
const result = await response.json().catch(() => ({})) method: "POST",
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not check this email link. Please open it again.') headers: { "Content-Type": "application/json" },
if (!abort.signal.aborted) setState(result.state) body: JSON.stringify(payload),
}).catch((err: Error) => { if (!abort.signal.aborted) { setError(err.message); setState('error') } }) signal: abort.signal,
} credentials: "omit",
checkLink() })
window.addEventListener('hashchange', checkLink) .then(async (response) => {
return () => { controller?.abort(); window.removeEventListener('hashchange', checkLink) } const result = await response.json().catch(() => ({}));
}, []) if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not check this email link. Please open it again.",
);
if (!abort.signal.aborted) setState(result.state);
})
.catch((err: Error) => {
if (!abort.signal.aborted) {
setError(err.message);
setState("error");
}
});
};
checkLink();
window.addEventListener("hashchange", checkLink);
return () => {
controller?.abort();
window.removeEventListener("hashchange", checkLink);
};
}, []);
const apply = async () => { const apply = async () => {
if (!link || busy) return if (!link || busy) return;
setBusy(true); setError('') setBusy(true);
setError("");
try { try {
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(link), credentials: 'omit' }) const response = await fetch(`${getApiBase()}/email-recaps/confirm`, {
const result = await response.json().catch(() => ({})) method: "POST",
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not update your preference. Please try again.') headers: { "Content-Type": "application/json" },
setState(result.state) body: JSON.stringify(link),
window.history.replaceState(null, '', '/email-recaps') credentials: "omit",
} catch (err) { setError(err instanceof Error ? err.message : 'Could not update your preference.') } });
finally { setBusy(false) } const result = await response.json().catch(() => ({}));
} if (!response.ok)
throw new Error(
typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
);
setState(result.state);
window.history.replaceState(null, "", "/email-recaps");
} catch (err) {
setError(err instanceof Error ? err.message : "Could not update your preference.");
} finally {
setBusy(false);
}
};
const done = state === 'enabled' || state === 'off' const done = state === "enabled" || state === "off";
return <main className="recap-link-page"><a className="recap-brand" href="/login"><BrandingLogo className="brand-logo" /><span>Magent</span></a><section className="account-panel"> return (
<span className="recap-eyebrow">Personal viewing reports</span> <main className="recap-link-page">
<h1>{state === 'enabled' ? 'Youre on the list.' : state === 'off' ? 'Recaps are turned off.' : state === 'loading' ? 'Checking your email link' : state === 'error' ? 'This link needs another look' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps?' : 'Your month, delivered.'}</h1> <a className="recap-brand" href="/login">
<p>{state === 'enabled' ? 'Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs.' : state === 'off' ? 'You wont receive further monthly recaps. You can turn them back on in Profile.' : state === 'ready' && link?.action === 'unsubscribe' ? 'This turns off all personal viewing report emails. You can still explore all your reports in Magent.' : state === 'ready' ? 'Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile.' : ''}</p> <BrandingLogo className="brand-logo" />
{error && <p className="account-notice is-error" role="alert">{error}</p>} <span>Magent</span>
{state === 'ready' && <button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>{busy ? 'Updating…' : link?.action === 'unsubscribe' ? 'Unsubscribe from recaps' : 'Confirm email recaps'}</button>} </a>
{(done || state === 'error') && <a className="recap-text-link" href="/profile#monthly-recaps">Manage email preferences </a>} <section className="account-panel">
{state === 'loading' && <p role="status">One moment</p>} <span className="recap-eyebrow">Personal viewing reports</span>
</section></main> <h1>
{state === "enabled"
? "Youre on the list."
: state === "off"
? "Recaps are turned off."
: state === "loading"
? "Checking your email link"
: state === "error"
? "This link needs another look"
: link?.action === "unsubscribe"
? "Unsubscribe from recaps?"
: "Your month, delivered."}
</h1>
<p>
{state === "enabled"
? "Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs."
: state === "off"
? "You wont receive further monthly recaps. You can turn them back on in Profile."
: state === "ready" && link?.action === "unsubscribe"
? "This turns off all personal viewing report emails. You can still explore all your reports in Magent."
: state === "ready"
? "Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile."
: ""}
</p>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
{state === "ready" && (
<button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>
{busy ? "Updating…" : link?.action === "unsubscribe" ? "Unsubscribe from recaps" : "Confirm email recaps"}
</button>
)}
{(done || state === "error") && (
<a className="recap-text-link" href="/profile#monthly-recaps">
Manage email preferences
</a>
)}
{state === "loading" && <p role="status">One moment</p>}
</section>
</main>
);
} }
+49 -53
View File
@@ -1,83 +1,83 @@
'use client' "use client";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth' import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from "../lib/auth";
type Profile = { type Profile = {
username?: string username?: string;
} };
export default function FeedbackPage() { export default function FeedbackPage() {
const router = useRouter() const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null) const [profile, setProfile] = useState<Profile | null>(null);
const [category, setCategory] = useState('bug') const [category, setCategory] = useState("bug");
const [message, setMessage] = useState('') const [message, setMessage] = useState("");
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push("/login");
return return;
} }
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetchOrThrow(`${baseUrl}/auth/me`) const response = await authFetchOrThrow(`${baseUrl}/auth/me`);
if (!response.ok) { if (!response.ok) {
throw new Error('Could not load profile.') throw new Error("Could not load profile.");
} }
const data = await response.json() const data = await response.json();
setProfile({ username: data?.username }) setProfile({ username: data?.username });
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) { if (error instanceof UnauthorizedError) {
router.push('/login') router.push("/login");
return return;
} }
console.error(error) console.error(error);
} }
} };
void load() void load();
}, [router]) }, [router]);
const submit = async (event: React.FormEvent<HTMLFormElement>) => { const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault();
setStatus(null) setStatus(null);
if (!message.trim()) { if (!message.trim()) {
setStatus('Please write a short message before sending.') setStatus("Please write a short message before sending.");
return return;
} }
setSubmitting(true) setSubmitting(true);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetchOrThrow(`${baseUrl}/feedback`, { const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
type: category, type: category,
message: message.trim(), message: message.trim(),
}), }),
}) });
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text();
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`);
} }
setMessage('') setMessage("");
setStatus('Thanks! Your message has been sent.') setStatus("Thanks! Your message has been sent.");
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) { if (error instanceof UnauthorizedError) {
router.push('/login') router.push("/login");
return return;
} }
console.error(error) console.error(error);
setStatus('That did not send. Please try again.') setStatus("That did not send. Please try again.");
} finally { } finally {
setSubmitting(false) setSubmitting(false);
} }
} };
return ( return (
<main className="card feedback-page"> <main className="card feedback-page">
@@ -85,14 +85,10 @@ export default function FeedbackPage() {
<form className="account-panel account-form feedback-form" onSubmit={submit}> <form className="account-panel account-form feedback-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label> <label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ''} readOnly /> <input id="feedback-user" value={profile?.username ?? ""} readOnly />
<label htmlFor="feedback-type">What is this about?</label> <label htmlFor="feedback-type">What is this about?</label>
<select <select id="feedback-type" value={category} onChange={(event) => setCategory(event.target.value)}>
id="feedback-type"
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="bug">Bug (something is broken)</option> <option value="bug">Bug (something is broken)</option>
<option value="feature">Feature idea (new option)</option> <option value="feature">Feature idea (new option)</option>
</select> </select>
@@ -109,9 +105,9 @@ export default function FeedbackPage() {
{status && <div className="status-banner">{status}</div>} {status && <div className="status-banner">{status}</div>}
<button type="submit" disabled={submitting}> <button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send feedback'} {submitting ? "Sending..." : "Send feedback"}
</button> </button>
</form> </form>
</main> </main>
) );
} }
+42 -34
View File
@@ -1,49 +1,49 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import AuthLayout from '../ui/AuthLayout' import AuthLayout from "../ui/AuthLayout";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const router = useRouter() const router = useRouter();
const [identifier, setIdentifier] = useState('') const [identifier, setIdentifier] = useState("");
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null);
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault();
if (!identifier.trim()) { if (!identifier.trim()) {
setError('Enter your username or email.') setError("Enter your username or email.");
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
setStatus(null) setStatus(null);
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/password/forgot`, { const response = await fetch(`${baseUrl}/auth/password/forgot`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: identifier.trim() }), body: JSON.stringify({ identifier: identifier.trim() }),
}) });
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
throw new Error(typeof data?.detail === 'string' ? data.detail : 'Unable to send reset link.') throw new Error(typeof data?.detail === "string" ? data.detail : "Unable to send reset link.");
} }
setStatus( setStatus(
typeof data?.message === 'string' typeof data?.message === "string"
? data.message ? data.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.",
) );
} catch (err) { } catch (err) {
console.error(err) console.error(err);
setError(err instanceof Error ? err.message : 'Unable to send reset link.') setError(err instanceof Error ? err.message : "Unable to send reset link.");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
return ( return (
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link."> <AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
@@ -57,17 +57,25 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com" placeholder="you@example.com"
/> />
</label> </label>
{error && <div className="account-notice is-error" role="alert">{error}</div>} {error && (
{status && <div className="account-notice is-status" role="status">{status}</div>} <div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions"> <div className="auth-actions">
<button type="submit" className="account-primary" disabled={loading}> <button type="submit" className="account-primary" disabled={loading}>
{loading ? 'Sending reset link…' : 'Send reset link'} {loading ? "Sending reset link…" : "Send reset link"}
</button> </button>
</div> </div>
<button type="button" className="ghost-button" onClick={() => router.push('/login')} disabled={loading}> <button type="button" className="ghost-button" onClick={() => router.push("/login")} disabled={loading}>
Back to sign in Back to sign in
</button> </button>
</form> </form>
</AuthLayout> </AuthLayout>
) );
} }
-110
View File
@@ -1,41 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
:root {
color-scheme: light;
--ink: #0f1117;
--ink-muted: #3f4656;
--paper: #f0f4ff;
--paper-strong: #ffffff;
--accent: #ff6b2b;
--accent-2: #1c6bff;
--accent-3: #11d6c6;
--border: rgba(15, 17, 23, 0.12);
--shadow: rgba(15, 17, 23, 0.18);
--glow: 0 0 18px rgba(28, 107, 255, 0.25);
--input-bg: rgba(15, 17, 23, 0.04);
--input-ink: var(--ink);
--error-bg: rgba(255, 107, 43, 0.12);
--error-ink: #6b2c17;
}
[data-theme='dark'] {
color-scheme: dark;
--ink: #e9ecf5;
--ink-muted: #9aa3b8;
--paper: #0b0f18;
--paper-strong: #111827;
--accent: #ff6b2b;
--accent-2: #3b82f6;
--accent-3: #22f6e3;
--border: rgba(255, 255, 255, 0.08);
--shadow: rgba(0, 0, 0, 0.6);
--glow: 0 0 22px rgba(59, 130, 246, 0.45);
--input-bg: rgba(255, 255, 255, 0.08);
--input-ink: var(--ink);
--error-bg: rgba(255, 107, 43, 0.18);
--error-ink: #ffd3bf;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -2505,38 +2467,6 @@ button span {
/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */ /* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
:root {
--ink: #10151d;
--ink-muted: #5b6472;
--paper: #eaedf1;
--paper-strong: #f8fafc;
--accent: #3f78d7;
--accent-2: #5ea0ff;
--accent-3: #8fa7c8;
--border: rgba(16, 21, 29, 0.1);
--shadow: rgba(16, 21, 29, 0.14);
--glow: 0 0 0 transparent;
--input-bg: rgba(16, 21, 29, 0.03);
--error-bg: rgba(185, 28, 28, 0.08);
--error-ink: #7f1d1d;
}
[data-theme='dark'] {
--ink: #edf1f7;
--ink-muted: #98a2b3;
--paper: #090c10;
--paper-strong: #11151b;
--accent: #4b7fdb;
--accent-2: #66a3ff;
--accent-3: #93a6c4;
--border: rgba(255, 255, 255, 0.07);
--shadow: rgba(0, 0, 0, 0.45);
--glow: 0 0 0 transparent;
--input-bg: rgba(255, 255, 255, 0.035);
--error-bg: rgba(248, 113, 113, 0.12);
--error-ink: #fecaca;
}
body { body {
font-family: "Manrope", "Segoe UI", sans-serif; font-family: "Manrope", "Segoe UI", sans-serif;
background: background:
@@ -3075,40 +3005,6 @@ button:disabled {
} }
/* Release 1.1 UI Refresh: Professional control-panel theme */ /* Release 1.1 UI Refresh: Professional control-panel theme */
:root {
--ink: #111318;
--ink-muted: #5f6776;
--paper: #eef1f6;
--paper-strong: #ffffff;
--accent: #4e8ef7;
--accent-2: #77abff;
--accent-3: #9dbdff;
--border: rgba(17, 19, 24, 0.1);
--shadow: rgba(17, 19, 24, 0.16);
--glow: 0 0 0 1px rgba(78, 142, 247, 0.08), 0 14px 30px rgba(16, 20, 28, 0.08);
--input-bg: rgba(17, 19, 24, 0.035);
--input-ink: var(--ink);
--error-bg: rgba(225, 81, 81, 0.12);
--error-ink: #6f1f1f;
}
[data-theme='dark'] {
--ink: #eef1f7;
--ink-muted: #9aa3b2;
--paper: #0a0d12;
--paper-strong: #12161d;
--accent: #5d9cff;
--accent-2: #87b5ff;
--accent-3: #a5c4ff;
--border: rgba(255, 255, 255, 0.08);
--shadow: rgba(0, 0, 0, 0.55);
--glow: 0 0 0 1px rgba(93, 156, 255, 0.12), 0 18px 42px rgba(0, 0, 0, 0.38);
--input-bg: rgba(255, 255, 255, 0.035);
--input-ink: var(--ink);
--error-bg: rgba(248, 113, 113, 0.14);
--error-ink: #ffd4d4;
}
body { body {
font-family: "Manrope", "Segoe UI", sans-serif; font-family: "Manrope", "Segoe UI", sans-serif;
background: background:
@@ -3853,12 +3749,6 @@ button:disabled {
} }
/* Enterprise polish pass */ /* Enterprise polish pass */
[data-theme='dark'] {
--accent: #6f95c6;
--accent-2: #8aa9d1;
--accent-3: #b2c5de;
}
body { body {
background: background:
radial-gradient(circle at 12% -8%, rgba(111, 149, 198, 0.08), transparent 40%), radial-gradient(circle at 12% -8%, rgba(111, 149, 198, 0.08), transparent 40%),
+174 -53
View File
@@ -1,56 +1,177 @@
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import '../welcome.css' import "../welcome.css";
export default function HowItWorksPage() { export default function HowItWorksPage() {
return <main className="friendly-guide"> return (
<PageHeading title="A little help getting started." description="Magent looks after your requests. GrizzlyFlix is where you watch them." /> <main className="friendly-guide">
<nav aria-label="Quick links"><a href="/welcome">Welcome page</a><a href="/">My Requests</a><a href="/profile">My profile</a></nav> <PageHeading
<details open><summary>Request a movie or TV show</summary> title="A little help getting started."
<ol> description="Magent looks after your requests. GrizzlyFlix is where you watch them."
<li><strong>Choose Movie or TV show.</strong><p>Open <a href="/new-requests">New Requests</a> and pick what youre looking for.</p></li> />
<li><strong>Search and choose the right title.</strong><p>For TV, choose the seasons you want. If its already requested, open that request to see its progress.</p></li> <nav aria-label="Quick links">
<li><strong>Check your choices and send it.</strong><p>Choose from the quality options shown. These come from the librarys settings.</p></li> <a href="/welcome">Welcome page</a>
<li><strong>Follow it in My Requests.</strong><p>Well show whats happening and any next step you can take. Some titles need approval or may not have a suitable download yet.</p></li> <a href="/">My Requests</a>
</ol> <a href="/profile">My profile</a>
</details> </nav>
<details><summary>Understand the six progress steps</summary> <details open>
<ol> <summary>Request a movie or TV show</summary>
<li><strong>Requested:</strong> Your request has been received.</li> <ol>
<li><strong>Approved:</strong> It has permission to go ahead.</li> <li>
<li><strong>Library collection:</strong> The library is tracking whats collected and whats missing.</li> <strong>Choose Movie or TV show.</strong>
<li><strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt a good match yet.</li> <p>
<li><strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a season pack.</li> Open <a href="/new-requests">New Requests</a> and pick what youre looking for.
<li><strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.</li> </p>
</ol> </li>
<p>A finished download still needs to be added to the media library. Wait for Available to watch before heading over.</p> <li>
</details> <strong>Search and choose the right title.</strong>
<details><summary>Something looks stuck</summary> <p>
<ol> For TV, choose the seasons you want. If its already requested, open that request to see its progress.
<li><strong>Open the request.</strong><p>Read its current status and next step.</p></li> </p>
<li><strong>Choose Recheck request.</strong><p>Magent checks the connected services again to refresh where things are up to.</p></li> </li>
<li><strong>Follow the action offered.</strong><p>You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre unsure.</p></li> <li>
</ol> <strong>Check your choices and send it.</strong>
<p>Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always mean a download was found.</p> <p>Choose from the quality options shown. These come from the librarys settings.</p>
</details> </li>
<details><summary>Report a problem and follow the fix</summary> <li>
<ol> <strong>Follow it in My Requests.</strong>
<li><strong>Open <a href="/portal/issues">Issues</a>.</strong><p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p></li> <p>
<li><strong>Choose the affected content.</strong><p>Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.</p></li> Well show whats happening and any next step you can take. Some titles need approval or may not have a
<li><strong>Read What will happen, then submit.</strong><p>It tells you whether the selected files will be replaced, missing content searched for, subtitles checked, or playback investigated.</p></li> suitable download yet.
<li><strong>Follow the issues progress.</strong><p>Open your reported issue to see the work recorded and where the fix is up to.</p></li> </p>
<li><strong>Tell us if it worked.</strong><p>When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose Yes if its fixed or No if you still need help.</p></li> </li>
</ol> </ol>
<p>Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure timings depend on the sites settings.</p> </details>
</details> <details>
<details><summary>Invite someone</summary> <summary>Understand the six progress steps</summary>
<ol> <ol>
<li><strong>Open <a href="/profile/invites">Invites</a>.</strong><p>If invites are enabled for your account, give your invite a name youll recognise.</p></li> <li>
<li><strong>Add a welcome note, or skip it.</strong><p>A custom invite code is optional too.</p></li> <strong>Requested:</strong> Your request has been received.
<li><strong>Choose how to share it.</strong><p>Copy the link yourself, or enter an email address to send it directly.</p></li> </li>
<li><strong>Manage it later.</strong><p>You can return to your invites to check them or disable a link. Your accounts invite limits apply automatically.</p></li> <li>
</ol> <strong>Approved:</strong> It has permission to go ahead.
</details> </li>
<details><summary>Update your account</summary><p>Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your activity, or use the password options available for your account.</p><p>Looking for your downloads instead? <a href="/">My Requests</a> is your starting point.</p></details> <li>
<footer>Ready? <a href="/welcome">Choose where to go next </a></footer> <strong>Library collection:</strong> The library is tracking whats collected and whats missing.
</main> </li>
<li>
<strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt
a good match yet.
</li>
<li>
<strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a
season pack.
</li>
<li>
<strong>Available to watch:</strong> GrizzlyFlix has added the content. Use the watch button to open it.
</li>
</ol>
<p>
A finished download still needs to be added to the media library. Wait for Available to watch before heading
over.
</p>
</details>
<details>
<summary>Something looks stuck</summary>
<ol>
<li>
<strong>Open the request.</strong>
<p>Read its current status and next step.</p>
</li>
<li>
<strong>Choose Recheck request.</strong>
<p>Magent checks the connected services again to refresh where things are up to.</p>
</li>
<li>
<strong>Follow the action offered.</strong>
<p>
You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre
unsure.
</p>
</li>
</ol>
<p>
Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always
mean a download was found.
</p>
</details>
<details>
<summary>Report a problem and follow the fix</summary>
<ol>
<li>
<strong>
Open <a href="/portal/issues">Issues</a>.
</strong>
<p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p>
</li>
<li>
<strong>Choose the affected content.</strong>
<p>
Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.
</p>
</li>
<li>
<strong>Read What will happen, then submit.</strong>
<p>
It tells you whether the selected files will be replaced, missing content searched for, subtitles checked,
or playback investigated.
</p>
</li>
<li>
<strong>Follow the issues progress.</strong>
<p>Open your reported issue to see the work recorded and where the fix is up to.</p>
</li>
<li>
<strong>Tell us if it worked.</strong>
<p>
When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose
Yes if its fixed or No if you still need help.
</p>
</li>
</ol>
<p>
Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure
timings depend on the sites settings.
</p>
</details>
<details>
<summary>Invite someone</summary>
<ol>
<li>
<strong>
Open <a href="/profile/invites">Invites</a>.
</strong>
<p>If invites are enabled for your account, give your invite a name youll recognise.</p>
</li>
<li>
<strong>Add a welcome note, or skip it.</strong>
<p>A custom invite code is optional too.</p>
</li>
<li>
<strong>Choose how to share it.</strong>
<p>Copy the link yourself, or enter an email address to send it directly.</p>
</li>
<li>
<strong>Manage it later.</strong>
<p>
You can return to your invites to check them or disable a link. Your accounts invite limits apply
automatically.
</p>
</li>
</ol>
</details>
<details>
<summary>Update your account</summary>
<p>
Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your
activity, or use the password options available for your account.
</p>
<p>
Looking for your downloads instead? <a href="/">My Requests</a> is your starting point.
</p>
</details>
<footer>
Ready? <a href="/welcome">Choose where to go next </a>
</footer>
</main>
);
} }
+251 -62
View File
@@ -1,93 +1,282 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { getApiBase } from '../lib/auth' import { getApiBase } from "../lib/auth";
export type Breakdown = { name: string; minutes: number } export type Breakdown = { name: string; minutes: number };
export type Day = { date: string; minutes: number } export type Day = { date: string; minutes: number };
export type Transcoding = { export type Transcoding = {
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number video_minutes: number;
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number audio_minutes: number;
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null hardware_video_minutes: number;
} software_video_minutes: number;
unknown_hardware_minutes: number;
unknown_video_minutes: number;
unknown_audio_minutes: number;
hardware: Breakdown[];
audio_codecs: Breakdown[];
gpu_busy_minutes: null;
};
export type Stats = { export type Stats = {
state: 'ready' | 'not_configured' | 'unlinked' state: "ready" | "not_configured" | "unlinked";
is_admin: boolean is_admin: boolean;
days: number days: number;
updated_at?: string updated_at?: string;
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number } summary: null | {
daily?: Day[] minutes: number;
top_titles?: { title: string; type: string; minutes: number; plays: number }[] movies: number;
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string; artwork_url?: string | null }[] episodes: number;
clients?: Breakdown[] plays: number;
methods?: Breakdown[] current_streak: number;
transcoding?: Transcoding longest_streak: number;
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] } active_days: number;
} };
daily?: Day[];
patterns?: {
average_play_minutes: number;
longest_play_minutes: number;
weekend_percent: number;
weekdays: Breakdown[];
media: Breakdown[];
};
top_titles?: { artwork_url?: string | null; title: string; type: string; minutes: number; plays: number }[];
recent?: {
id: string;
title: string;
series: string;
episode?: string;
type: string;
minutes: number;
played_at: string;
client: string;
method: string;
artwork_url?: string | null;
}[];
clients?: Breakdown[];
methods?: Breakdown[];
transcoding?: Transcoding;
requests: {
total: number;
movies: number;
tv: number;
pending: number;
approved: number;
declined: number;
recent: { request_id: number; title: string; media_type: string; status: number }[];
};
};
export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 }) export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 });
export const dateLabel = (date: string) => new Date(date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) export const dateLabel = (date: string) =>
new Date(date).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
export function ViewingChart({ daily }: { daily: Day[] }) { export function ViewingChart({ daily }: { daily: Day[] }) {
const [selected, setSelected] = useState<number | null>(null) const [selected, setSelected] = useState<number | null>(null);
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1 const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1;
const bars: { start: string; end: string; minutes: number }[] = [] const bars: { start: string; end: string; minutes: number }[] = [];
for (let i = 0; i < daily.length; i += bucket) { for (let i = 0; i < daily.length; i += bucket) {
const group = daily.slice(i, i + bucket) const group = daily.slice(i, i + bucket);
bars.push({ start: group[0].date, end: group[group.length - 1].date, minutes: group.reduce((sum, day) => sum + day.minutes, 0) }) bars.push({
start: group[0].date,
end: group[group.length - 1].date,
minutes: group.reduce((sum, day) => sum + day.minutes, 0),
});
} }
const peak = Math.max(1, ...bars.map((bar) => bar.minutes)) const peak = Math.max(1, ...bars.map((bar) => bar.minutes));
const active = selected === null ? null : bars[selected] const active = selected === null ? null : bars[selected];
return ( return (
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title"> <section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
<div className="stats-panel-heading"><div><h2 id="viewing-title">Your viewing rhythm</h2><p>{bucket === 1 ? 'Daily' : `${bucket}-day`} watch time · UTC</p></div><span className="stats-unit">Minutes</span></div> <div className="stats-panel-heading">
<div className="stats-chart-detail" aria-live="polite">{active ? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ''} · ${number(active.minutes)} minutes` : 'Select a bar to explore your watch time.'}</div> <div>
<h2 id="viewing-title">Your viewing rhythm</h2>
<p>{bucket === 1 ? "Daily" : `${bucket}-day`} watch time · UTC</p>
</div>
<span className="stats-unit">Minutes</span>
</div>
<div className="stats-chart-detail" aria-live="polite">
{active
? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ""} · ${number(active.minutes)} minutes`
: "Select a bar to explore your watch time."}
</div>
<div className="stats-chart"> <div className="stats-chart">
<div className="stats-chart-scale" aria-hidden="true"><span>{number(peak)}</span><span>{number(peak / 2)}</span><span>0</span></div> <div className="stats-chart-scale" aria-hidden="true">
<span>{number(peak)}</span>
<span>{number(peak / 2)}</span>
<span>0</span>
</div>
<div className="stats-chart-bars"> <div className="stats-chart-bars">
{bars.map((bar, index) => <button type="button" className={selected === index ? 'is-selected' : ''} key={bar.start} aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ''}: ${number(bar.minutes)} minutes`} aria-pressed={selected === index} onClick={() => setSelected(index)} onFocus={() => setSelected(index)}><span style={{ height: `${bar.minutes > 0 ? Math.max(2, bar.minutes / peak * 100) : 1}%` }} /></button>)} {bars.map((bar, index) => (
<button
type="button"
className={selected === index ? "is-selected" : ""}
key={bar.start}
aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ""}: ${number(bar.minutes)} minutes`}
aria-pressed={selected === index}
onClick={() => setSelected(index)}
onFocus={() => setSelected(index)}
>
<span style={{ height: `${bar.minutes > 0 ? Math.max(2, (bar.minutes / peak) * 100) : 1}%` }} />
</button>
))}
</div> </div>
</div> </div>
<div className="stats-chart-axis" aria-hidden="true"><span>{bars[0] && dateLabel(bars[0].start)}</span><span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span></div> <div className="stats-chart-axis" aria-hidden="true">
<span>{bars[0] && dateLabel(bars[0].start)}</span>
<span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span>
</div>
</section> </section>
) );
} }
export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) { export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0) const total = rows.reduce((sum, row) => sum + row.minutes, 0);
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section> return (
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>{title}</h2>
</div>
{rows.length ? (
<div className="stats-breakdown">
{rows.map((row) => (
<div key={row.name}>
<div className="stats-breakdown-label">
<span>{row.name}</span>
<strong>{number(row.minutes)} min</strong>
</div>
<div className="stats-meter">
<span style={{ width: `${total ? (row.minutes / total) * 100 : 0}%` }} />
</div>
</div>
))}
</div>
) : (
<p className="stats-muted">Your next watch will start the story here.</p>
)}
</section>
);
} }
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min` const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`;
export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) { export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0) const total = rows.reduce((sum, row) => sum + row.minutes, 0);
return <section className="stats-panel stats-streaming"> return (
<div className="stats-panel-heading"><h2>How you streamed</h2></div> <section className="stats-panel stats-streaming">
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> <div className="stats-panel-heading">
{transcoding && <div className="stats-transcoding"> <h2>How you streamed</h2>
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
</div> </div>
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl> <div className="stats-breakdown">
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>} {rows.map((row) => (
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p> <div key={row.name}>
</div>} <div className="stats-breakdown-label">
</section> <span>{row.name}</span>
<strong>{minutes(row.minutes)}</strong>
</div>
<div className="stats-meter">
<span style={{ width: `${total ? (row.minutes / total) * 100 : 0}%` }} />
</div>
</div>
))}
</div>
{transcoding && (
<div className="stats-transcoding">
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div>
<span>GPU-assisted video</span>
<strong>
{transcoding.hardware_video_minutes === 0 &&
(transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0)
? "Not recorded"
: minutes(transcoding.hardware_video_minutes)}
</strong>
<small>
{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(" / ") ||
"Hardware-accelerated video"}
</small>
</div>
<div>
<span>Audio transcoding</span>
<strong>
{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0
? "Not recorded"
: minutes(transcoding.audio_minutes)}
</strong>
<small>
{transcoding.audio_codecs
.slice(0, 3)
.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`)
.join(" / ") || "Audio converted for your player"}
</small>
</div>
</div>
<dl className="stats-transcode-details">
<div>
<dt>Total video transcoding</dt>
<dd>
{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0
? "Not recorded"
: minutes(transcoding.video_minutes)}
</dd>
</div>
{transcoding.software_video_minutes > 0 && (
<div>
<dt>Software video</dt>
<dd>{minutes(transcoding.software_video_minutes)}</dd>
</div>
)}
{transcoding.unknown_hardware_minutes > 0 && (
<div>
<dt>Video hardware not recorded</dt>
<dd>{minutes(transcoding.unknown_hardware_minutes)}</dd>
</div>
)}
</dl>
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && (
<p className="stats-muted">
Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio{" "}
{minutes(transcoding.unknown_audio_minutes)}.
</p>
)}
<p className="stats-muted">
Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded
by Jellystat.
</p>
</div>
)}
</section>
);
} }
export function RecentArtwork({ url, type }: { url?: string | null; type: string }) { export function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false);
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true"> return (
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>} <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
</div> {url && !failed ? (
<img
src={`${getApiBase()}${url}`}
alt=""
width={44}
height={66}
loading="lazy"
onError={() => setFailed(true)}
/>
) : (
<span>{type === "episode" ? "TV" : type === "movie" ? "MV" : "▶"}</span>
)}
</div>
);
} }
export function StatsNavigation({ reports = false }: { reports?: boolean }) { export function StatsNavigation({ reports = false }: { reports?: boolean }) {
return <nav className="stats-view-tabs" aria-label="My Stats views"> return (
<a href="/insights" aria-current={!reports ? 'page' : undefined}>Overview</a> <nav className="stats-view-tabs" aria-label="My Stats views">
<a href="/insights/reports" aria-current={reports ? 'page' : undefined}>Monthly reports</a> <a href="/insights" aria-current={!reports ? "page" : undefined}>
</nav> Overview
</a>
<a href="/insights/reports" aria-current={reports ? "page" : undefined}>
Monthly reports
</a>
</nav>
);
} }
+318 -68
View File
@@ -1,86 +1,336 @@
'use client' "use client";
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../lib/auth' import { authFetch, getApiBase } from "../lib/auth";
import PageHeading from '../ui/PageHeading' import PageHeading from "../ui/PageHeading";
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from './components' import {
import './stats.css' type Stats,
BreakdownCard,
RecentArtwork,
StatsNavigation,
StreamingCard,
ViewingChart,
dateLabel,
number,
} from "./components";
import "./stats.css";
export default function InsightsPage() { export default function InsightsPage() {
const router = useRouter() const router = useRouter();
const [days, setDays] = useState(30) const [days, setDays] = useState(30);
const [data, setData] = useState<Stats | null>(null) const [data, setData] = useState<Stats | null>(null);
const [busy, setBusy] = useState(true) const [busy, setBusy] = useState(true);
const [error, setError] = useState('') const [error, setError] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const load = useCallback(async (signal: AbortSignal) => { const load = useCallback(
setBusy(true) async (signal: AbortSignal) => {
setError('') setBusy(true);
setData(null) setError("");
try { setData(null);
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal }) try {
if (response.status === 401) { router.replace('/login?next=%2Finsights'); return } const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal });
if (response.status === 403) throw new Error('Your account cannot access viewing stats. Please contact an administrator.') if (response.status === 401) {
if (!response.ok) { router.replace("/login?next=%2Finsights");
const result = await response.json().catch(() => ({})) return;
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your viewing stats are temporarily unavailable. Please try again shortly.') }
if (response.status === 403)
throw new Error("Your account cannot access viewing stats. Please contact an administrator.");
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
typeof result.detail === "string"
? result.detail
: "Your viewing stats are temporarily unavailable. Please try again shortly.",
);
}
const result = (await response.json()) as Stats;
if (!signal.aborted) setData(result);
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : "Could not load your stats.");
} finally {
if (!signal.aborted) setBusy(false);
} }
const result = await response.json() as Stats },
if (!signal.aborted) setData(result) [days, router],
} catch (err) { );
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your stats.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [days, router])
useEffect(() => { useEffect(() => {
const controller = new AbortController() void revision;
void load(controller.signal) const controller = new AbortController();
return () => controller.abort() void load(controller.signal);
}, [load, revision]) return () => controller.abort();
}, [load, revision]);
const summary = data?.summary const summary = data?.summary;
return ( return (
<main className="stats-page"> <main className="stats-page">
<PageHeading title="My Stats" description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all." actions={<button className="ghost-button" type="button" disabled={busy} onClick={() => setRevision((value) => value + 1)}>{busy ? 'Loading…' : 'Refresh stats'}</button>} /> <PageHeading
title="My Stats"
description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all."
actions={
<button
className="ghost-button"
type="button"
disabled={busy}
onClick={() => setRevision((value) => value + 1)}
>
{busy ? "Loading…" : "Refresh stats"}
</button>
}
/>
<StatsNavigation /> <StatsNavigation />
<div className="stats-toolbar"> <div className="stats-toolbar">
<fieldset className="stats-period"><legend className="stats-sr-only">Stats period</legend>{[7, 30, 90, 365].map((value) => <button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>{value === 365 ? 'Past year' : `${value} days`}</button>)}</fieldset> <fieldset className="stats-period">
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat{data?.updated_at && <span> · Updated {new Date(data.updated_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span>}</p> <legend className="stats-sr-only">Stats period</legend>
{[7, 30, 90, 365].map((value) => (
<button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>
{value === 365 ? "Past year" : `${value} days`}
</button>
))}
</fieldset>
<p className="stats-source">
<span className={data?.state === "ready" ? "stats-source-dot is-ready" : "stats-source-dot"} />
From Jellystat
{data?.updated_at && (
<span>
{" "}
· Updated{" "}
{new Date(data.updated_at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</p>
</div> </div>
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Gathering your stats</h2><p>Fetching your viewing history from Jellystat.</p></div>} {busy && (
{error && <div className="stats-state" role="alert"><h2>Stats couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button></div>} <div className="stats-state" role="status">
{data?.state === 'not_configured' && <section className="stats-state"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Your viewing story starts here</h2><p>{data.is_admin ? 'Connect your Jellystat instance to bring personal viewing stats into Magent.' : 'Viewing stats will appear here once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>} <span className="stats-state-symbol" aria-hidden="true">
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your administrator to sync Jellyfin users.</p></section>}
{summary && <> </span>
<section className="stats-metrics" aria-label="Viewing totals"> <h2>Gathering your stats</h2>
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{number(summary.minutes / 60)} hours across {number(summary.plays)} plays</small></article> <p>Fetching your viewing history from Jellystat.</p>
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small></article> </div>
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small></article> )}
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests</small></article> {error && (
<div className="stats-state" role="alert">
<h2>Stats couldnt load</h2>
<p>{error}</p>
<button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
</div>
)}
{data?.state === "not_configured" && (
<section className="stats-state">
<span className="stats-state-symbol" aria-hidden="true">
</span>
<h2>Your viewing story starts here</h2>
<p>
{data.is_admin
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
: "Viewing stats will appear here once your administrator connects Jellystat."}
</p>
{data.is_admin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
)}
</section> </section>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history in this period yet. Try a longer period, or come back after your next watch.</div>} )}
{data?.state === "unlinked" && (
<section className="stats-state">
<h2>Link your viewing account</h2>
<p>
Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your
administrator to sync Jellyfin users.
</p>
</section>
)}
{summary && (
<>
<section className="stats-metrics" aria-label="Viewing totals">
<article className="stats-metric stats-metric-accent">
<span>Minutes watched</span>
<strong>{number(summary.minutes)}</strong>
<small>
{number(summary.minutes / 60)} hours across {number(summary.plays)} plays
</small>
</article>
<article className="stats-metric">
<span>Movies played</span>
<strong>{number(summary.movies)}</strong>
<small>Different movies you pressed play on</small>
</article>
<article className="stats-metric">
<span>Episodes played</span>
<strong>{number(summary.episodes)}</strong>
<small>Different episodes in your history</small>
</article>
<article className="stats-metric">
<span>Requests made</span>
<strong>{number(data.requests.total)}</strong>
<small>
{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests
</small>
</article>
</section>
{summary.plays === 0 && (
<div className="stats-notice" role="status">
No viewing history in this period yet. Try a longer period, or come back after your next watch.
</div>
)}
<div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel stats-highlights">
<div className="stats-panel-heading">
<h2>A little watch history</h2>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.current_streak}
<small> days</small>
</span>
<div>
<strong>Current streak</strong>
<p>Consecutive viewing days through today or yesterday.</p>
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.longest_streak}
<small> days</small>
</span>
<div>
<strong>Longest run</strong>
<p>Your best streak in this period.</p>
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.active_days}
<small> days</small>
</span>
<div>
<strong>Time for a story</strong>
<p>Days with at least a minute watched.</p>
</div>
</div>
</section>
</div>
<div className="stats-three-grid">
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>Most watched</h2>
<span className="stats-unit">By minutes</span>
</div>
{data.top_titles?.length ? (
<ol className="stats-top-titles">
{data.top_titles.map((title, index) => (
<li key={`${title.title}-${index}`}>
<span className="stats-rank">{String(index + 1).padStart(2, "0")}</span>
<div>
<strong>{title.title}</strong>
<small>
{title.type === "series" ? "TV series" : title.type === "movie" ? "Movie" : "Other media"} ·{" "}
{title.plays} plays
</small>
</div>
<span>
{number(title.minutes)}
<small>min</small>
</span>
</li>
))}
</ol>
) : (
<p className="stats-muted">Your favourites will find their place here.</p>
)}
</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
</>
)}
{data && (
<div className="stats-main-grid"> <div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} /> {summary && (
<section className="stats-panel stats-highlights"><div className="stats-panel-heading"><h2>A little watch history</h2></div> <section className="stats-panel stats-history">
<div className="stats-highlight"><span className="stats-highlight-number">{summary.current_streak}<small> days</small></span><div><strong>Current streak</strong><p>Consecutive viewing days through today or yesterday.</p></div></div> <div className="stats-panel-heading">
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Your best streak in this period.</p></div></div> <h2>Recently watched</h2>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Time for a story</strong><p>Days with at least a minute watched.</p></div></div> <span className="stats-unit">Latest 20 plays</span>
</div>
{data.recent?.length ? (
<div className="stats-history-list">
{data.recent.map((play) => (
<article key={play.id}>
<RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} />
<div className="stats-history-title">
<strong>{play.series || play.title}</strong>
<small>
{play.series ? `${play.episode} · ${play.title}` : play.type === "movie" ? "Movie" : "Media"}
</small>
<span>
{play.client} · {play.method}
</span>
</div>
<div className="stats-history-time">
<strong>{number(play.minutes)} min</strong>
<time dateTime={play.played_at}>{dateLabel(play.played_at)}</time>
</div>
</article>
))}
</div>
) : (
<p className="stats-muted">Plays recorded by Jellystat will appear here.</p>
)}
</section>
)}
<section className="stats-panel stats-requests">
<div className="stats-panel-heading">
<h2>Your requests</h2>
<a href="/">View all</a>
</div>
<div className="stats-request-total">
<strong>{data.requests.total}</strong>
<span>submitted in the past {days} days</span>
</div>
<div className="stats-request-counts">
<span>
<strong>{data.requests.pending}</strong> Pending
</span>
<span>
<strong>{data.requests.approved}</strong> Approved
</span>
<span>
<strong>{data.requests.declined}</strong> Declined
</span>
</div>
{data.requests.recent.length > 0 ? (
<ul className="stats-request-list">
{data.requests.recent.map((request) => (
<li key={request.request_id}>
<a href={`/requests/${request.request_id}`}>
{request.title || `Request ${request.request_id}`}
<span aria-hidden="true"></span>
</a>
</li>
))}
</ul>
) : (
<p className="stats-muted">
Something on your watchlist? <a href="/new-requests">Make a request.</a>
</p>
)}
</section> </section>
</div> </div>
<div className="stats-three-grid"> )}
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your favourites will find their place here.</p>}</section> {summary && (
<BreakdownCard title="Your players" rows={data.clients ?? []} /> <p className="stats-footnote">
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} /> Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays,
</div> including unfinished watches. Movies are identified from Jellystats movie libraries; other media still
</>} contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.
{data && <div className="stats-main-grid"> </p>
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>} )}
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
</div>}
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystats movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
</main> </main>
) );
} }
@@ -1,60 +1,108 @@
'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";
type Delivery = { id: string; month: string; state: string; detail: string } type Delivery = { id: string; month: string; state: string; detail: string };
type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] } type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] };
export default function EmailReportControl({ month }: { month: string }) { export default function EmailReportControl({ month }: { month: string }) {
const [data, setData] = useState<Preference | null>(null) const [data, setData] = useState<Preference | null>(null);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('') const [notice, setNotice] = useState("");
const [error, setError] = useState('') const [error, setError] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const request = useRef<{ month: string; id: string } | null>(null) const request = useRef<{ month: string; id: string } | null>(null);
const pending = data?.deliveries.some((item) => ['queued', 'preparing', 'sending', 'retry'].includes(item.state)) ?? false const pending =
data?.deliveries.some((item) => ["queued", "preparing", "sending", "retry"].includes(item.state)) ?? false;
useEffect(() => { useEffect(() => {
const abort = new AbortController() void revision;
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal }).then(async (response) => { const abort = new AbortController();
if (!response.ok) throw new Error('Could not load your report email preferences. Refresh to try again.') void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
const result = await response.json() .then(async (response) => {
if (!abort.signal.aborted) setData(result) if (!response.ok) throw new Error("Could not load your report email preferences. Refresh to try again.");
}).catch((err: Error) => { if (!abort.signal.aborted) setError(err.message) }) const result = await response.json();
return () => abort.abort() if (!abort.signal.aborted) setData(result);
}, [revision]) })
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision]);
useEffect(() => { useEffect(() => {
if (!pending) return if (!pending) return;
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000) const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
return () => window.clearInterval(timer) return () => window.clearInterval(timer);
}, [pending]) }, [pending]);
const send = async () => { const send = async () => {
if (busy || !data?.can_send) return if (busy || !data?.can_send) return;
setBusy(true); setError(''); setNotice('') setBusy(true);
if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() } setError("");
setNotice("");
if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() };
try { try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, { const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ month, request_id: request.current.id }), body: JSON.stringify({ month, request_id: request.current.id }),
}) });
const result = await response.json().catch(() => ({})) const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Could not queue your report. Try again.') if (!response.ok)
setNotice(result.message); request.current = null throw new Error(typeof result.detail === "string" ? result.detail : "Could not queue your report. Try again.");
setRevision((value) => value + 1) setNotice(result.message);
} catch (err) { setError(err instanceof Error ? err.message : 'Could not queue your report.') } request.current = null;
finally { setBusy(false) } setRevision((value) => value + 1);
} } catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your report.");
} finally {
setBusy(false);
}
};
return <section className="stats-panel report-email-panel" aria-label="Email your report"> return (
<div className="stats-panel-heading"><h2>Email yourself this report</h2><a href="/profile#monthly-recaps">Email preferences</a></div> <section className="stats-panel report-email-panel" aria-label="Email your report">
<p>Choose a month above, including the current month so far, then send its viewing and request summary to your confirmed profile email.</p> <div className="stats-panel-heading">
{data?.can_send ? <p><strong>{data.email}</strong> · One report email every five minutes.</p> : data && <p>{data.state === 'enabled' ? data.detail : 'Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails.'}</p>} <h2>Email yourself this report</h2>
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>{busy ? 'Queueing report…' : 'Email this report'}</button> <a href="/profile#monthly-recaps">Email preferences</a>
{notice && <p role="status">{notice}</p>} </div>
{error && <p role="alert">{error}</p>} <p>
{!!data?.deliveries.length && <details><summary>Recent report emails</summary><ul>{data.deliveries.map((item) => <li key={item.id}><strong>{item.month}</strong> · {item.state === 'sent' ? 'Accepted by mail server' : item.state} {item.detail || 'Waiting for delivery'}</li>)}</ul></details>} Choose a month above, including the current month so far, then send its viewing and request summary to your
</section> confirmed profile email.
</p>
{data?.can_send ? (
<p>
<strong>{data.email}</strong> · One report email every five minutes.
</p>
) : (
data && (
<p>
{data.state === "enabled"
? data.detail
: "Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails."}
</p>
)
)}
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>
{busy ? "Queueing report…" : "Email this report"}
</button>
{notice && <p role="status">{notice}</p>}
{error && <p role="alert">{error}</p>}
{!!data?.deliveries.length && (
<details>
<summary>Recent report emails</summary>
<ul>
{data.deliveries.map((item) => (
<li key={item.id}>
<strong>{item.month}</strong> · {item.state === "sent" ? "Accepted by mail server" : item.state} {" "}
{item.detail || "Waiting for delivery"}
</li>
))}
</ul>
</details>
)}
</section>
);
} }
+508 -143
View File
@@ -1,166 +1,531 @@
'use client' "use client";
import EmailReportControl from './EmailReportControl' import EmailReportControl from "./EmailReportControl";
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from "../../lib/auth";
import PageHeading from '../../ui/PageHeading' import PageHeading from "../../ui/PageHeading";
import { type Stats, BreakdownCard, RecentArtwork, StatsNavigation, StreamingCard, ViewingChart, dateLabel, number } from '../components' import {
import '../stats.css' type Stats,
import './reports.css' BreakdownCard,
RecentArtwork,
StatsNavigation,
StreamingCard,
ViewingChart,
dateLabel,
number,
} from "../components";
import "../stats.css";
import "./reports.css";
type Change = { current: number; previous: number; difference: number; percent: number | null } type Change = { current: number; previous: number; difference: number; percent: number | null };
type MonthlyReport = Omit<Stats, 'days'> & { type MonthlyReport = Omit<Stats, "days"> & {
month: string; available_months: string[]; is_partial: boolean; comparison_capped: boolean month: string;
period_start: string; period_end: string; comparison_month: string; comparison_start: string; comparison_end: string available_months: string[];
previous_summary?: Stats['summary']; previous_requests?: Omit<Stats['requests'], 'recent'> is_partial: boolean;
changes?: Record<'minutes' | 'movies' | 'episodes' | 'plays' | 'active_days' | 'longest_streak' | 'requests', Change> comparison_capped: boolean;
} period_start: string;
period_end: string;
comparison_month: string;
comparison_start: string;
comparison_end: string;
previous_summary?: Stats["summary"];
previous_requests?: Omit<Stats["requests"], "recent">;
changes?: Record<"minutes" | "movies" | "episodes" | "plays" | "active_days" | "longest_streak" | "requests", Change>;
};
const monthLabel = (month: string, short = false) => new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: short ? 'short' : 'long', year: 'numeric', timeZone: 'UTC' }) const monthLabel = (month: string, short = false) =>
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 }) new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, {
month: short ? "short" : "long",
year: "numeric",
timeZone: "UTC",
});
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 });
function ChangeLabel({ change, unit = '' }: { change: Change; unit?: string }) { function ChangeLabel({ change, unit = "" }: { change: Change; unit?: string }) {
const delta = change.difference const delta = change.difference;
return <div className={`report-change ${delta > 0 ? 'is-up' : delta < 0 ? 'is-down' : 'is-flat'}`}> return (
<span>{delta === 0 ? 'No change' : `${delta > 0 ? '+' : ''}${decimal(Math.abs(delta))}${unit}${change.percent === null ? '' : ` (${delta > 0 ? '+' : ''}${decimal(Math.abs(change.percent))}%)`}`}</span> <div className={`report-change ${delta > 0 ? "is-up" : delta < 0 ? "is-down" : "is-flat"}`}>
<small>{change.percent === null ? 'No activity recorded in the comparison period' : `Previously ${decimal(change.previous)}${unit}`}</small> <span>
</div> {delta === 0
? "No change"
: `${delta > 0 ? "+" : ""}${decimal(Math.abs(delta))}${unit}${change.percent === null ? "" : ` (${delta > 0 ? "+" : ""}${decimal(Math.abs(change.percent))}%)`}`}
</span>
<small>
{change.percent === null
? "No activity recorded in the comparison period"
: `Previously ${decimal(change.previous)}${unit}`}
</small>
</div>
);
} }
export default function MonthlyReportsPage() { export default function MonthlyReportsPage() {
const router = useRouter() const router = useRouter();
const [month, setMonth] = useState('') const [month, setMonth] = useState("");
const [monthReady, setMonthReady] = useState(false) const [monthReady, setMonthReady] = useState(false);
const [months, setMonths] = useState<string[]>([]) const [months, setMonths] = useState<string[]>([]);
const [data, setData] = useState<MonthlyReport | null>(null) const [data, setData] = useState<MonthlyReport | null>(null);
const [busy, setBusy] = useState(true) const [busy, setBusy] = useState(true);
const [error, setError] = useState('') const [error, setError] = useState("");
const [revision, setRevision] = useState(0) const [revision, setRevision] = useState(0);
const [downloading, setDownloading] = useState(false) const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState('') const [downloadError, setDownloadError] = useState("");
const downloadController = useRef<AbortController | null>(null) const downloadController = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
setMonth(new URLSearchParams(window.location.search).get('month') || '') setMonth(new URLSearchParams(window.location.search).get("month") || "");
setMonthReady(true) setMonthReady(true);
}, []) }, []);
useEffect(() => { useEffect(() => {
if (monthReady) window.history.replaceState(null, '', `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ''}`) if (monthReady)
}, [month, monthReady]) window.history.replaceState(null, "", `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ""}`);
useEffect(() => () => downloadController.current?.abort(), []) }, [month, monthReady]);
const load = useCallback(async (signal: AbortSignal) => { useEffect(() => () => downloadController.current?.abort(), []);
setBusy(true) const load = useCallback(
setError('') async (signal: AbortSignal) => {
setData(null) setBusy(true);
setDownloadError('') setError("");
try { setData(null);
const query = month ? `?month=${encodeURIComponent(month)}` : '' setDownloadError("");
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal }) try {
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`); return } const query = month ? `?month=${encodeURIComponent(month)}` : "";
if (response.status === 403) throw new Error('Your account cannot access viewing reports. Please contact an administrator.') const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal });
if (!response.ok) { if (response.status === 401) {
const result = await response.json().catch(() => ({})) router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`);
throw new Error(typeof result.detail === 'string' ? result.detail : 'Your report is temporarily unavailable. Please try again shortly.') return;
}
if (response.status === 403)
throw new Error("Your account cannot access viewing reports. Please contact an administrator.");
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
typeof result.detail === "string"
? result.detail
: "Your report is temporarily unavailable. Please try again shortly.",
);
}
const result = (await response.json()) as MonthlyReport;
if (!signal.aborted) {
setData(result);
setMonths(result.available_months);
}
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : "Could not load your report.");
} finally {
if (!signal.aborted) setBusy(false);
} }
const result = await response.json() as MonthlyReport },
if (!signal.aborted) { setData(result); setMonths(result.available_months) } [month, router],
} catch (err) { );
if (!signal.aborted) setError(err instanceof Error ? err.message : 'Could not load your report.')
} finally {
if (!signal.aborted) setBusy(false)
}
}, [month, router])
useEffect(() => { useEffect(() => {
if (!monthReady) return void revision;
const controller = new AbortController() if (!monthReady) return;
void load(controller.signal) const controller = new AbortController();
return () => controller.abort() void load(controller.signal);
}, [load, revision, monthReady]) return () => controller.abort();
}, [load, revision, monthReady]);
const download = async () => { const download = async () => {
if (data?.state !== 'ready' || downloading) return if (data?.state !== "ready" || downloading) return;
const selected = data.month const selected = data.month;
const controller = new AbortController() const controller = new AbortController();
downloadController.current = controller downloadController.current = controller;
setDownloading(true) setDownloading(true);
setDownloadError('') setDownloadError("");
try { try {
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, { signal: controller.signal }) const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, {
if (response.status === 401) { router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`); return } signal: controller.signal,
if (!response.ok) throw new Error('The report could not be downloaded. Please try again.') });
const blob = await response.blob() if (response.status === 401) {
if (controller.signal.aborted) return router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`);
const url = URL.createObjectURL(blob) return;
const link = document.createElement('a') }
link.href = url if (!response.ok) throw new Error("The report could not be downloaded. Please try again.");
link.download = `magent-monthly-report-${selected}.csv` const blob = await response.blob();
document.body.appendChild(link) if (controller.signal.aborted) return;
link.click() const url = URL.createObjectURL(blob);
link.remove() const link = document.createElement("a");
window.setTimeout(() => URL.revokeObjectURL(url), 1000) link.href = url;
link.download = `magent-monthly-report-${selected}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) { } catch (err) {
if (!controller.signal.aborted) setDownloadError(err instanceof Error ? err.message : 'Could not download your report.') if (!controller.signal.aborted)
setDownloadError(err instanceof Error ? err.message : "Could not download your report.");
} finally { } finally {
if (!controller.signal.aborted) setDownloading(false) if (!controller.signal.aborted) setDownloading(false);
} }
} };
const selectedMonth = month || data?.month || '' const selectedMonth = month || data?.month || "";
const monthIndex = months.indexOf(selectedMonth) const monthIndex = months.indexOf(selectedMonth);
const summary = data?.summary const summary = data?.summary;
const changes = data?.changes const changes = data?.changes;
return <main className="stats-page reports-page"> return (
<PageHeading title="Monthly report" description="Your month in viewing. See what you watched, what changed, and what you requested." actions={<> <main className="stats-page reports-page">
<button className="ghost-button" type="button" disabled={busy || downloading} onClick={() => setRevision((value) => value + 1)}>Refresh report</button> <PageHeading
<button className="ghost-button" type="button" disabled={busy || downloading || data?.state !== 'ready'} onClick={() => void download()}>{downloading ? 'Downloading…' : 'Download CSV'}</button> title="Monthly report"
</>} /> description="Your month in viewing. See what you watched, what changed, and what you requested."
<StatsNavigation reports /> actions={
<div className="stats-toolbar"> <>
<div className="report-month-picker"> <button
<button type="button" className="ghost-button" aria-label="Previous month" disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1} onClick={() => setMonth(months[monthIndex + 1])}></button> className="ghost-button"
<label><span className="stats-sr-only">Report month</span><select value={selectedMonth} disabled={busy || downloading || !months.length} onChange={(event) => setMonth(event.target.value)}>{!selectedMonth && <option value="">Latest complete month</option>}{months.map((value, index) => <option value={value} key={value}>{monthLabel(value)}{index === 0 ? ' · month to date' : ''}</option>)}</select></label> type="button"
<button type="button" className="ghost-button" aria-label="Next month" disabled={busy || downloading || monthIndex <= 0} onClick={() => setMonth(months[monthIndex - 1])}></button> disabled={busy || downloading}
onClick={() => setRevision((value) => value + 1)}
>
Refresh report
</button>
<button
className="ghost-button"
type="button"
disabled={busy || downloading || data?.state !== "ready"}
onClick={() => void download()}
>
{downloading ? "Downloading…" : "Download CSV"}
</button>
</>
}
/>
<StatsNavigation reports />
<div className="stats-toolbar">
<div className="report-month-picker">
<button
type="button"
className="ghost-button"
aria-label="Previous month"
disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1}
onClick={() => setMonth(months[monthIndex + 1])}
>
</button>
<label>
<span className="stats-sr-only">Report month</span>
<select
value={selectedMonth}
disabled={busy || downloading || !months.length}
onChange={(event) => setMonth(event.target.value)}
>
{!selectedMonth && <option value="">Latest complete month</option>}
{months.map((value, index) => (
<option value={value} key={value}>
{monthLabel(value)}
{index === 0 ? " · month to date" : ""}
</option>
))}
</select>
</label>
<button
type="button"
className="ghost-button"
aria-label="Next month"
disabled={busy || downloading || monthIndex <= 0}
onClick={() => setMonth(months[monthIndex - 1])}
>
</button>
</div>
<p className="stats-source">
<span className={data?.state === "ready" ? "stats-source-dot is-ready" : "stats-source-dot"} />
From Jellystat · UTC
</p>
</div> </div>
<p className="stats-source"><span className={data?.state === 'ready' ? 'stats-source-dot is-ready' : 'stats-source-dot'} />From Jellystat · UTC</p> {downloadError && (
</div> <p className="stats-notice" role="alert">
{downloadError && <p className="stats-notice" role="alert">{downloadError}</p>} {downloadError}
{data?.state === 'ready' && !busy && <EmailReportControl month={data.month} />} </p>
{busy && <div className="stats-state" role="status"><span className="stats-state-symbol" aria-hidden="true"></span><h2>Putting your month together</h2><p>Gathering your viewing history and the previous months comparison.</p></div>} )}
{error && <div className="stats-state" role="alert"><h2>Report couldnt load</h2><p>{error}</p><button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>Try again</button>{month && <button type="button" className="ghost-button" onClick={() => setMonth('')}>Latest complete month</button>}</div>} {data?.state === "ready" && !busy && <EmailReportControl month={data.month} />}
{data?.state === 'not_configured' && <section className="stats-state"><h2>Your monthly story starts here</h2><p>{data.is_admin ? 'Connect Jellystat to bring your monthly viewing reports into Magent.' : 'Monthly reports will appear once your administrator connects Jellystat.'}</p>{data.is_admin && <a className="stats-action" href="/admin/jellystat">Connect Jellystat</a>}</section>} {busy && (
{data?.state === 'unlinked' && <section className="stats-state"><h2>Link your viewing account</h2><p>Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review your user identities.</p>{data.is_admin && <a className="stats-action" href="/admin/identities">Review user identities</a>}</section>} <div className="stats-state" role="status">
{data && summary && changes && <> <span className="stats-state-symbol" aria-hidden="true">
<section className="report-intro" aria-label="Report period">
<div><span className="report-kicker">{data.is_partial ? 'Month to date' : 'Your monthly recap'}</span><h2>{monthLabel(data.month)}</h2><p>{data.is_partial ? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ', capped at the end of that month' : ''}.` : `Compared with ${monthLabel(data.comparison_month)}.`}</p></div> </span>
<div className="report-period-meta"><span>{data.is_partial ? 'In progress' : 'Complete month'}</span><small>{data.updated_at && `Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC`}</small></div> <h2>Putting your month together</h2>
</section> <p>Gathering your viewing history and the previous months comparison.</p>
<section className="stats-metrics" aria-label="Monthly totals"> </div>
<article className="stats-metric stats-metric-accent"><span>Minutes watched</span><strong>{number(summary.minutes)}</strong><small>{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays</small><ChangeLabel change={changes.minutes} unit=" min" /></article> )}
<article className="stats-metric"><span>Movies played</span><strong>{number(summary.movies)}</strong><small>Different movies you pressed play on</small><ChangeLabel change={changes.movies} /></article> {error && (
<article className="stats-metric"><span>Episodes played</span><strong>{number(summary.episodes)}</strong><small>Different episodes in your history</small><ChangeLabel change={changes.episodes} /></article> <div className="stats-state" role="alert">
<article className="stats-metric"><span>Requests made</span><strong>{number(data.requests.total)}</strong><small>{data.requests.movies} movies · {data.requests.tv} TV requests</small><ChangeLabel change={changes.requests} /></article> <h2>Report couldnt load</h2>
</section> <p>{error}</p>
{summary.plays === 0 && <div className="stats-notice" role="status">No viewing history was recorded for this month. Your request totals and comparison are still shown.</div>} <button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>
<div className="stats-main-grid"> Try again
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} /> </button>
<section className="stats-panel report-highlights"><div className="stats-panel-heading"><h2>Your viewing habits</h2></div> {month && (
<div className="stats-highlight"><span className="stats-highlight-number">{summary.active_days}<small> days</small></span><div><strong>Days you watched</strong><p>At least one minute of viewing.</p><ChangeLabel change={changes.active_days} unit=" days" /></div></div> <button type="button" className="ghost-button" onClick={() => setMonth("")}>
<div className="stats-highlight"><span className="stats-highlight-number">{summary.longest_streak}<small> days</small></span><div><strong>Longest run</strong><p>Consecutive viewing days this month.</p><ChangeLabel change={changes.longest_streak} unit=" days" /></div></div> Latest complete month
<div className="stats-highlight"><span className="stats-highlight-number">{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}<small> min</small></span><div><strong>Daily average</strong><p>Across the calendar days in this report.</p></div></div> </button>
)}
</div>
)}
{data?.state === "not_configured" && (
<section className="stats-state">
<h2>Your monthly story starts here</h2>
<p>
{data.is_admin
? "Connect Jellystat to bring your monthly viewing reports into Magent."
: "Monthly reports will appear once your administrator connects Jellystat."}
</p>
{data.is_admin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
)}
</section> </section>
</div> )}
<div className="stats-three-grid"> {data?.state === "unlinked" && (
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles report-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your most watched titles will appear here.</p>}</section> <section className="stats-state">
<BreakdownCard title="Your players" rows={data.clients ?? []} /> <h2>Link your viewing account</h2>
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} /> <p>
</div> Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
<div className="stats-main-grid"> your user identities.
<section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>A look back</h2><span className="stats-unit">Latest 20 plays this month</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded during this month will appear here.</p>}</section> </p>
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in {monthLabel(data.month, true)}</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true"></span></a></li>)}</ul> : <p className="stats-muted">No requests recorded during this month.</p>}<p className="stats-muted">Statuses reflect where these requests are now.</p></section> {data.is_admin && (
</div> <a className="stats-action" href="/admin/identities">
<p className="stats-footnote">Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical totals can change when retained history or library metadata changes.</p> Review user identities
</>} </a>
</main> )}
</section>
)}
{data && summary && changes && (
<>
<section className="report-intro" aria-label="Report period">
<div>
<span className="report-kicker">{data.is_partial ? "Month to date" : "Your monthly recap"}</span>
<h2>{monthLabel(data.month)}</h2>
<p>
{data.is_partial
? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ", capped at the end of that month" : ""}.`
: `Compared with ${monthLabel(data.comparison_month)}.`}
</p>
</div>
<div className="report-period-meta">
<span>{data.is_partial ? "In progress" : "Complete month"}</span>
<small>
{data.updated_at &&
`Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`}
</small>
</div>
</section>
<section className="stats-metrics" aria-label="Monthly totals">
<article className="stats-metric stats-metric-accent">
<span>Minutes watched</span>
<strong>{number(summary.minutes)}</strong>
<small>
{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays
</small>
<ChangeLabel change={changes.minutes} unit=" min" />
</article>
<article className="stats-metric">
<span>Movies played</span>
<strong>{number(summary.movies)}</strong>
<small>Different movies you pressed play on</small>
<ChangeLabel change={changes.movies} />
</article>
<article className="stats-metric">
<span>Episodes played</span>
<strong>{number(summary.episodes)}</strong>
<small>Different episodes in your history</small>
<ChangeLabel change={changes.episodes} />
</article>
<article className="stats-metric">
<span>Requests made</span>
<strong>{number(data.requests.total)}</strong>
<small>
{data.requests.movies} movies · {data.requests.tv} TV requests
</small>
<ChangeLabel change={changes.requests} />
</article>
</section>
{summary.plays === 0 && (
<div className="stats-notice" role="status">
No viewing history was recorded for this month. Your request totals and comparison are still shown.
</div>
)}
<div className="stats-main-grid">
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel report-highlights">
<div className="stats-panel-heading">
<h2>Your viewing habits</h2>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.active_days}
<small> days</small>
</span>
<div>
<strong>Days you watched</strong>
<p>At least one minute of viewing.</p>
<ChangeLabel change={changes.active_days} unit=" days" />
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.longest_streak}
<small> days</small>
</span>
<div>
<strong>Longest run</strong>
<p>Consecutive viewing days this month.</p>
<ChangeLabel change={changes.longest_streak} unit=" days" />
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}
<small> min</small>
</span>
<div>
<strong>Daily average</strong>
<p>Across the calendar days in this report.</p>
</div>
</div>
</section>
</div>
{data.patterns && (
<>
<section className="report-pattern-summary" aria-label="Viewing insights">
<article>
<span>Average play</span>
<strong>
{decimal(data.patterns.average_play_minutes)} <small>min</small>
</strong>
<p>Time per recorded playback session.</p>
</article>
<article>
<span>Longest play</span>
<strong>
{decimal(data.patterns.longest_play_minutes)} <small>min</small>
</strong>
<p>Your longest recorded session this month.</p>
</article>
<article>
<span>Weekend viewing</span>
<strong>
{decimal(data.patterns.weekend_percent)}
<small>%</small>
</strong>
<p>Share of viewing on Saturday and Sunday (UTC).</p>
</article>
</section>
<div className="stats-main-grid">
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} />
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} />
</div>
</>
)}
<div className="stats-three-grid">
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>Most watched</h2>
<span className="stats-unit">By minutes</span>
</div>
{data.top_titles?.length ? (
<ol className="stats-top-titles report-top-titles">
{data.top_titles.map((title, index) => (
<li key={`${title.title}-${index}`}>
<RecentArtwork url={title.artwork_url} type={title.type} />
<div>
<strong>{title.title}</strong>
<small>
{title.type === "series" ? "TV series" : title.type === "movie" ? "Movie" : "Other media"} ·{" "}
{title.plays} plays
</small>
</div>
<span>
{number(title.minutes)}
<small>min</small>
</span>
</li>
))}
</ol>
) : (
<p className="stats-muted">Your most watched titles will appear here.</p>
)}
</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
<div className="stats-main-grid">
<section className="stats-panel stats-history">
<div className="stats-panel-heading">
<h2>A look back</h2>
<span className="stats-unit">Latest 20 plays this month</span>
</div>
{data.recent?.length ? (
<div className="stats-history-list">
{data.recent.map((play) => (
<article key={play.id}>
<RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} />
<div className="stats-history-title">
<strong>{play.series || play.title}</strong>
<small>
{play.series ? `${play.episode} · ${play.title}` : play.type === "movie" ? "Movie" : "Media"}
</small>
<span>
{play.client} · {play.method}
</span>
</div>
<div className="stats-history-time">
<strong>{number(play.minutes)} min</strong>
<time dateTime={play.played_at}>{dateLabel(play.played_at)}</time>
</div>
</article>
))}
</div>
) : (
<p className="stats-muted">Plays recorded during this month will appear here.</p>
)}
</section>
<section className="stats-panel stats-requests">
<div className="stats-panel-heading">
<h2>Your requests</h2>
<a href="/">View all</a>
</div>
<div className="stats-request-total">
<strong>{data.requests.total}</strong>
<span>submitted in {monthLabel(data.month, true)}</span>
</div>
<div className="stats-request-counts">
<span>
<strong>{data.requests.pending}</strong> Pending
</span>
<span>
<strong>{data.requests.approved}</strong> Approved
</span>
<span>
<strong>{data.requests.declined}</strong> Declined
</span>
</div>
{data.requests.recent.length ? (
<ul className="stats-request-list">
{data.requests.recent.map((request) => (
<li key={request.request_id}>
<a href={`/requests/${request.request_id}`}>
{request.title || `Request ${request.request_id}`}
<span aria-hidden="true"></span>
</a>
</li>
))}
</ul>
) : (
<p className="stats-muted">No requests recorded during this month.</p>
)}
<p className="stats-muted">Statuses reflect where these requests are now.</p>
</section>
</div>
<p className="stats-footnote">
Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays
include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical
totals can change when retained history or library metadata changes.
</p>
</>
)}
</main>
);
} }
+11
View File
@@ -28,3 +28,14 @@
.report-email-panel { display: grid; gap: 12px; } .report-email-panel { display: grid; gap: 12px; }
.report-email-panel > button { justify-self: start; } .report-email-panel > button { justify-self: start; }
.report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; } .report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; }
.report-pattern-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin:20px 0; }
.report-pattern-summary article { padding:24px; border:1px solid #45404e; border-radius:16px; background:linear-gradient(135deg,#262137,#14292d); }
.report-pattern-summary span { color:#d0c6e7; font-size:13px; }
.report-pattern-summary strong { display:block; font-size:36px; margin:12px 0; color:#c5baff; }
.report-pattern-summary small { font-size:16px; }
.report-pattern-summary p { color:#b6b6c0; font-size:13px; margin:0; }
.report-top-titles li { grid-template-columns:44px minmax(0,1fr) auto; gap:12px; }
.report-top-titles li > div { flex:1; min-width:0; }
.report-top-titles li > .stats-media-icon { width:44px; }
@media(max-width:640px) { .report-pattern-summary { grid-template-columns:1fr; } }
+108 -50
View File
@@ -1,63 +1,121 @@
'use client' "use client";
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from "next/navigation";
import { authFetch, getApiBase, clearToken } from '../../../lib/auth' import { authFetch, getApiBase, clearToken } from "../../../lib/auth";
import ResolutionChoice from '../../../ui/ResolutionChoice' import ResolutionChoice from "../../../ui/ResolutionChoice";
type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } } type Issue = {
id: number;
kind: string;
title: string;
status: string;
permissions?: { can_confirm_resolution?: boolean };
};
export default function ConfirmIssuePage() { export default function ConfirmIssuePage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>();
const router = useRouter() const router = useRouter();
const [item, setItem] = useState<Issue | null>(null) const [item, setItem] = useState<Issue | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
const [error, setError] = useState('') const [error, setError] = useState("");
const [result, setResult] = useState('') const [result, setResult] = useState("");
const login = () => { const login = useCallback(() => {
clearToken() clearToken();
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`) router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`);
} }, [id, router]);
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController();
setLoading(true); setItem(null); setError(''); setResult('') setLoading(true);
setItem(null);
setError("");
setResult("");
const load = async () => { const load = async () => {
try { try {
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' }) const response = await authFetch(`${getApiBase()}/portal/items/${id}`, {
if (response.status === 401) { login(); return } signal: controller.signal,
if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.') cache: "no-store",
const data = await response.json() });
if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.') if (response.status === 401) {
setItem(data.item) login();
} catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') } return;
finally { if (!controller.signal.aborted) setLoading(false) } }
} if (!response.ok)
void load() throw new Error("This issue is unavailable. Please sign in with the account that reported it.");
return () => controller.abort() const data = await response.json();
if (data.item?.kind !== "issue") throw new Error("This link does not belong to an issue.");
setItem(data.item);
} catch (err) {
if (!controller.signal.aborted)
setError(err instanceof Error ? err.message : "Could not load this issue. Please try again.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
void load();
return () => controller.abort();
// The confirmation link identifies one issue. Never submit an answer on GET. // The confirmation link identifies one issue. Never submit an answer on GET.
// eslint-disable-next-line react-hooks/exhaustive-deps }, [id, login]);
}, [id])
const answer = async (resolved: boolean) => { const answer = async (resolved: boolean) => {
if (busy) return if (busy) return;
setBusy(true); setError('') setBusy(true);
setError("");
try { try {
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, { const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }), method: "POST",
}) headers: { "Content-Type": "application/json" },
if (response.status === 401) { login(); return } body: JSON.stringify({ resolved }),
if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.') });
setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.') if (response.status === 401) {
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') } login();
finally { setBusy(false) } return;
} }
return <main className="resolution-response-page"> if (!response.ok)
{error && <p role="alert" className="status-banner">{error}</p>} throw new Error(
{loading ? <p role="status">Loading your issue</p> : result ? <section className="resolution-choice" role="status"><h2>{result}</h2><a href="/portal/issues">Back to issues</a></section> : item ? ( "Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.",
item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution );
? <ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} /> setResult(
: <section className="resolution-choice"><h2>{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}</h2><p>{item.title}</p><a href={`/portal/issues?item=${item.id}`}>View issue</a></section> resolved
) : null} ? "Thanks! Your issue is now closed."
</main> : "Thanks for letting us know. Your issue stays open for another look.",
);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save your answer. Please try again.");
} finally {
setBusy(false);
}
};
return (
<main className="resolution-response-page">
{error && (
<p role="alert" className="status-banner">
{error}
</p>
)}
{loading ? (
<p role="status">Loading your issue</p>
) : result ? (
<section className="resolution-choice" role="status">
<h2>{result}</h2>
<a href="/portal/issues">Back to issues</a>
</section>
) : item ? (
item.status === "awaiting_confirmation" && item.permissions?.can_confirm_resolution ? (
<ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
) : (
<section className="resolution-choice">
<h2>
{item.status === "awaiting_confirmation"
? "This question is for the person who reported the issue."
: "No answer is needed right now."}
</h2>
<p>{item.title}</p>
<a href={`/portal/issues?item=${item.id}`}>View issue</a>
</section>
)
) : null}
</main>
);
} }
+20 -14
View File
@@ -1,18 +1,24 @@
import './globals.css' import "./styles/tokens.css";
import './ops-redesign.css' import "./globals.css";
import './admin/config.css' import "./ops-redesign.css";
import './account.css' import "./admin/config.css";
import './workspace.css' import "./account.css";
import './portal/issue-flow.css' import "./workspace.css";
import type { ReactNode } from 'react' import "./portal/issue-flow.css";
import BrandingFavicon from './ui/BrandingFavicon' import type { ReactNode } from "react";
import FeatureGate from './ui/FeatureGate' import BrandingFavicon from "./ui/BrandingFavicon";
import ApplicationChrome from './ui/ApplicationChrome' import FeatureGate from "./ui/FeatureGate";
import ApplicationChrome from "./ui/ApplicationChrome";
export const metadata = { export const metadata = {
title: 'Magent', title: "Magent",
description: 'Request timeline and AI triage for media requests', description: "Request timeline and AI triage for media requests",
} icons: { icon: "/api/branding/favicon.ico" },
};
// A request-specific CSP nonce is generated in proxy.ts. Dynamic rendering lets
// Next.js apply that nonce to its framework and hydration scripts.
export const dynamic = "force-dynamic";
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
@@ -25,5 +31,5 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</div> </div>
</body> </body>
</html> </html>
) );
} }
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { apiUrl, requestJson } from "./api-client";
describe("api client", () => {
it("normalizes relative API paths", () => {
expect(apiUrl("health")).toBe("/api/health");
expect(apiUrl("/health")).toBe("/api/health");
});
it("returns typed JSON from successful responses", async () => {
const transport = async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 });
const result = await requestJson<{ status: string }>("/health", undefined, transport);
expect(result).toEqual({ status: "ok" });
});
it("uses the API error detail when a request fails", async () => {
const transport = async () => new Response(JSON.stringify({ detail: "Not available" }), { status: 409 });
await expect(requestJson("/requests/1", undefined, transport)).rejects.toEqual(
expect.objectContaining({ status: 409, message: "Not available" }),
);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { authFetchOrThrow, getApiBase } from "./auth";
export type ApiTransport = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export class ApiClientError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiClientError";
this.status = status;
}
}
const errorMessage = (payload: unknown, fallback: string) => {
if (!payload || typeof payload !== "object") return fallback;
const record = payload as Record<string, unknown>;
for (const key of ["detail", "error", "message"]) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fallback;
};
export const apiUrl = (path: string) => {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${getApiBase()}${normalizedPath}`;
};
export async function requestJson<T>(
path: string,
init?: RequestInit,
transport: ApiTransport = authFetchOrThrow,
): Promise<T> {
const response = await transport(apiUrl(path), init);
if (response.status === 204) return undefined as T;
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
if (!response.ok) {
throw new ApiClientError(response.status, errorMessage(payload, text || `Request failed: ${response.status}`));
}
return payload as T;
}
+55 -55
View File
@@ -1,100 +1,100 @@
const AUTH_STATE_COOKIE = 'magent_logged_in' const AUTH_STATE_COOKIE = "magent_logged_in";
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api' export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? "/api";
const setCookie = (name: string, value: string, maxAgeSeconds: number) => { const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof document === 'undefined') return if (typeof document === "undefined") return;
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax` document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`;
} };
const clearCookie = (name: string) => { const clearCookie = (name: string) => {
if (typeof document === 'undefined') return if (typeof document === "undefined") return;
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax` document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
} };
export const getToken = () => { export const getToken = () => {
if (typeof document === 'undefined') return null if (typeof document === "undefined") return null;
const cookies = document.cookie.split(';').map((entry) => entry.trim()) const cookies = document.cookie.split(";").map((entry) => entry.trim());
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`)) const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`));
if (!marker) return null if (!marker) return null;
const [, value] = marker.split('=', 2) const [, value] = marker.split("=", 2);
return value || null return value || null;
} };
export const setToken = (_token: string) => { export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12) setCookie(AUTH_STATE_COOKIE, "1", 60 * 60 * 12);
} };
export const clearToken = () => { export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE) clearCookie(AUTH_STATE_COOKIE);
if (typeof window === 'undefined') return if (typeof window === "undefined") return;
const baseUrl = getApiBase() const baseUrl = getApiBase();
void fetch(`${baseUrl}/auth/logout`, { void fetch(`${baseUrl}/auth/logout`, {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
keepalive: true, keepalive: true,
}).catch(() => undefined) }).catch(() => undefined);
} };
export const logout = async () => { export const logout = async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase();
clearCookie(AUTH_STATE_COOKIE) clearCookie(AUTH_STATE_COOKIE);
await fetch(`${baseUrl}/auth/logout`, { await fetch(`${baseUrl}/auth/logout`, {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
}) });
} };
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => { export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {}) const headers = new Headers(init?.headers || {});
return fetch(input, { ...init, headers, credentials: 'include' }) return fetch(input, { ...init, headers, credentials: "include" });
} };
export const getEventStreamToken = async () => { export const getEventStreamToken = async () => {
const baseUrl = getApiBase() const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/stream-token`) const response = await authFetch(`${baseUrl}/auth/stream-token`);
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text();
throw new Error(text || `Stream token request failed: ${response.status}`) throw new Error(text || `Stream token request failed: ${response.status}`);
} }
const data = await response.json() const data = await response.json();
const token = typeof data?.stream_token === 'string' ? data.stream_token : '' const token = typeof data?.stream_token === "string" ? data.stream_token : "";
if (!token) { if (!token) {
throw new Error('Stream token not returned') throw new Error("Stream token not returned");
} }
return token return token;
} };
export class UnauthorizedError extends Error { export class UnauthorizedError extends Error {
constructor() { constructor() {
super('Unauthorized') super("Unauthorized");
this.name = 'UnauthorizedError' this.name = "UnauthorizedError";
} }
} }
export class ForbiddenError extends Error { export class ForbiddenError extends Error {
constructor() { constructor() {
super('Forbidden') super("Forbidden");
this.name = 'ForbiddenError' this.name = "ForbiddenError";
} }
} }
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => { export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init) const response = await authFetch(input, init);
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken();
throw new UnauthorizedError() throw new UnauthorizedError();
} }
if (response.status === 403) { if (response.status === 403) {
throw new ForbiddenError() throw new ForbiddenError();
} }
return response return response;
} };
export const readResponseText = async (response: Response) => { export const readResponseText = async (response: Response) => {
try { try {
return (await response.text()).trim() return (await response.text()).trim();
} catch { } catch {
return '' return "";
} }
} };
+39 -20
View File
@@ -1,24 +1,43 @@
export const FEATURES = [ export const FEATURES = [
{ key: 'stats', label: 'My Stats', description: 'View personal viewing history, reports and request report emails.' }, { key: "stats", label: "My Stats", description: "View personal viewing history, reports and request report emails." },
{ key: 'requests', label: 'My Requests', description: 'View existing requests, their progress and request actions.' }, { key: "requests", label: "My Requests", description: "View existing requests, their progress and request actions." },
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' }, {
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' }, key: "new_requests",
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' }, label: "New Requests",
{ key: 'ignore_profile_limits', label: 'Ignore profile limits', description: 'Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.' }, description: "Search for movies and TV shows and submit new requests.",
] as const },
export type Feature = typeof FEATURES[number]['key'] {
export type FeatureAccess = Record<Feature, boolean> key: "issues",
label: "Issues",
description: "Report problems, follow up on issues and use available repair tools.",
},
{ key: "invites", label: "Invites", description: "Create and manage invitations within the existing invite limits." },
{
key: "ignore_profile_limits",
label: "Ignore profile limits",
description:
"Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.",
},
] as const;
export type Feature = (typeof FEATURES)[number]["key"];
export type FeatureAccess = Record<Feature, boolean>;
export function featureForPath(path: string): Feature | undefined { export function featureForPath(path: string): Feature | undefined {
if (path === '/insights' || path.startsWith('/insights/')) return 'stats' if (path === "/insights" || path.startsWith("/insights/")) return "stats";
if (path === '/' || path.startsWith('/requests/')) return 'requests' if (path === "/" || path.startsWith("/requests/")) return "requests";
if (path === '/new-requests') return 'new_requests' if (path === "/new-requests") return "new_requests";
if (path.startsWith('/issues/confirm/') || path.startsWith('/portal/issues')) return 'issues' if (path.startsWith("/issues/confirm/") || path.startsWith("/portal/issues")) return "issues";
if (path.startsWith('/profile/invites')) return 'invites' if (path.startsWith("/profile/invites")) return "invites";
if (path === '/portal/requests') return 'requests' if (path === "/portal/requests") return "requests";
} }
export function canAccess(user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null, feature?: Feature) { export function canAccess(
if (!feature) return true user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null,
if (!user) return false feature?: Feature,
if (user.role === 'admin') return true ) {
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : feature !== 'ignore_profile_limits') if (!feature) return true;
if (!user) return false;
if (user.role === "admin") return true;
return (
user.features?.[feature] ??
(feature === "invites" ? Boolean(user.invite_management_enabled) : feature !== "ignore_profile_limits")
);
} }
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { loginErrorMessage } from "./login-errors";
const errorResponse = (status: number, payload: unknown) => new Response(JSON.stringify(payload), { status });
describe("login error messages", () => {
it("identifies a site security rejection without blaming the account", async () => {
expect(await loginErrorMessage(errorResponse(403, { detail: "Cross-origin state change rejected" }))).toBe(
"Sign-in was blocked by the site's security configuration. Please contact an administrator.",
);
});
it.each(["User is blocked", "User access has expired", "Unknown upstream error"])(
"keeps a generic account message for %s",
async (detail) => {
expect(await loginErrorMessage(errorResponse(403, { detail }))).toBe(
"This account cannot sign in. Please contact an administrator.",
);
},
);
it.each([
null,
[],
{ detail: ["Cross-origin state change rejected"] },
{ detail: "Cross-origin state change rejected: private upstream detail" },
{ detail: "<script>private upstream detail</script>" },
])("does not render or loosely match unexpected response bodies: %j", async (payload) => {
expect(await loginErrorMessage(errorResponse(403, payload))).toBe(
"This account cannot sign in. Please contact an administrator.",
);
});
it("handles a non-JSON proxy denial safely", async () => {
expect(await loginErrorMessage(new Response("<html>Forbidden</html>", { status: 403 }))).toBe(
"This account cannot sign in. Please contact an administrator.",
);
});
it.each([
[401, "Check your username and password, then try again."],
[400, "Check your username and password, then try again."],
[429, "Too many attempts. Please wait a moment and try again."],
[500, "Sign-in is temporarily unavailable. Please try again shortly."],
[502, "Sign-in is temporarily unavailable. Please try again shortly."],
])("preserves the existing message for HTTP %s", async (status, expected) => {
expect(
await loginErrorMessage(errorResponse(status as number, { detail: "Cross-origin state change rejected" })),
).toBe(expected);
});
});
+17
View File
@@ -0,0 +1,17 @@
export async function loginErrorMessage(response: Response): Promise<string> {
if (response.status === 429) return "Too many attempts. Please wait a moment and try again.";
if (response.status >= 500) return "Sign-in is temporarily unavailable. Please try again shortly.";
if (response.status === 403) {
const payload: unknown = await response.json().catch(() => null);
if (
payload !== null &&
typeof payload === "object" &&
"detail" in payload &&
payload.detail === "Cross-origin state change rejected"
) {
return "Sign-in was blocked by the site's security configuration. Please contact an administrator.";
}
return "This account cannot sign in. Please contact an administrator.";
}
return "Check your username and password, then try again.";
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { normalizeRecentResults, normalizeSearchResults } from "./request-results";
describe("request result normalization", () => {
it("replaces placeholder request titles", () => {
expect(normalizeRecentResults([{ id: 42, title: "Request 42", year: 2024 }])).toEqual([
expect.objectContaining({ id: 42, title: "Request #42", year: 2024 }),
]);
});
it("drops malformed search results", () => {
expect(normalizeSearchResults([null, { title: "" }, { title: "Drive", requestId: 3991 }])).toEqual([
expect.objectContaining({ title: "Drive", requestId: 3991 }),
]);
});
});
+74
View File
@@ -0,0 +1,74 @@
export interface RecentRequest {
id: number;
title: string;
year?: number;
type?: string;
statusLabel?: string;
artwork?: { poster_url?: string; backdrop_url?: string };
createdAt?: string | null;
}
export interface RequestSearchResult {
title: string;
year?: number;
type?: string;
requestId?: number;
statusLabel?: string;
requestedBy?: string | null;
accessible?: boolean;
}
const recordValue = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
const optionalString = (value: unknown) => (typeof value === "string" ? value : undefined);
const optionalNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
export const normalizeRecentResults = (items: unknown): RecentRequest[] => {
if (!Array.isArray(items)) return [];
return items.flatMap((value) => {
const item = recordValue(value);
const id = optionalNumber(item?.id);
if (!item || id === undefined) return [];
const rawTitle = optionalString(item.title);
const placeholder = rawTitle?.trim().toLowerCase() === `request ${id}`;
const rawArtwork = recordValue(item.artwork);
const artwork = rawArtwork
? {
poster_url: optionalString(rawArtwork.poster_url),
backdrop_url: optionalString(rawArtwork.backdrop_url),
}
: undefined;
return [
{
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: optionalNumber(item.year),
type: optionalString(item.type),
statusLabel: optionalString(item.statusLabel),
artwork,
createdAt: item.createdAt === null ? null : optionalString(item.createdAt),
},
];
});
};
export const normalizeSearchResults = (items: unknown): RequestSearchResult[] => {
if (!Array.isArray(items)) return [];
return items.flatMap((value) => {
const item = recordValue(value);
const title = optionalString(item?.title);
if (!item || !title) return [];
return [
{
title,
year: optionalNumber(item.year),
type: optionalString(item.type),
requestId: optionalNumber(item.requestId),
statusLabel: optionalString(item.statusLabel),
requestedBy: item.requestedBy === null ? null : optionalString(item.requestedBy),
accessible: Boolean(item.accessible),
},
];
});
};
+9 -9
View File
@@ -1,15 +1,15 @@
let locks = 0 let locks = 0;
let previous = '' let previous = "";
export function lockBodyScroll() { export function lockBodyScroll() {
if (locks++ === 0) { if (locks++ === 0) {
previous = document.body.style.overflow previous = document.body.style.overflow;
document.body.style.overflow = 'hidden' document.body.style.overflow = "hidden";
} }
let released = false let released = false;
return () => { return () => {
if (released) return if (released) return;
released = true released = true;
if (--locks === 0) document.body.style.overflow = previous if (--locks === 0) document.body.style.overflow = previous;
} };
} }

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