security: harden data auth and deployment
This commit is contained in:
@@ -17,15 +17,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "24"
|
||||
# Gitea cache restore/save stalls here; npm ci takes about 15 seconds.
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
@@ -55,21 +55,20 @@ jobs:
|
||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
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
|
||||
env:
|
||||
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
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
|
||||
run: bash scripts/deploy_ams_dev01.sh
|
||||
|
||||
deploy-beta:
|
||||
@@ -78,7 +77,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
@@ -86,19 +85,18 @@ jobs:
|
||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${PROD_SSH_KNOWN_HOSTS:?PROD_SSH_KNOWN_HOSTS is required}"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
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 beta to AMS-DEV01
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=yes
|
||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.env
|
||||
bootstrap-admin.json
|
||||
.venv/
|
||||
.security-test-venv*/
|
||||
data/
|
||||
!data/branding/
|
||||
!data/branding/**
|
||||
|
||||
+21
-11
@@ -1,4 +1,4 @@
|
||||
FROM node:24-slim AS frontend-builder
|
||||
FROM node:24-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -13,11 +13,12 @@ COPY frontend/app ./app
|
||||
COPY frontend/public ./public
|
||||
COPY frontend/next-env.d.ts ./next-env.d.ts
|
||||
COPY frontend/next.config.js ./next.config.js
|
||||
COPY frontend/proxy.ts ./proxy.ts
|
||||
COPY frontend/tsconfig.json ./tsconfig.json
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -32,22 +33,31 @@ RUN apt-get update \
|
||||
&& apt-get clean \
|
||||
&& 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 .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
COPY data/branding /app/data/branding
|
||||
COPY --chown=magent:magent backend/app ./app
|
||||
COPY --chown=magent:magent data/branding /app/data/branding
|
||||
|
||||
COPY --from=frontend-builder /frontend/.next /app/frontend/.next
|
||||
COPY --from=frontend-builder /frontend/public /app/frontend/public
|
||||
COPY --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
||||
COPY --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
||||
COPY --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 --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/.next /app/frontend/.next
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/public /app/frontend/public
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/node_modules /app/frontend/node_modules
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/package.json /app/frontend/package.json
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/next.config.js /app/frontend/next.config.js
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/proxy.ts /app/frontend/proxy.ts
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/next-env.d.ts /app/frontend/next-env.d.ts
|
||||
COPY --chown=magent:magent --from=frontend-builder /frontend/tsconfig.json /app/frontend/tsconfig.json
|
||||
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/magent.conf
|
||||
|
||||
RUN chown -R magent:magent /app
|
||||
USER magent:magent
|
||||
|
||||
EXPOSE 3000 8000
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/magent.conf"]
|
||||
|
||||
@@ -66,8 +66,9 @@ QBIT_URL="http://localhost:8080"
|
||||
QBIT_USERNAME="..."
|
||||
QBIT_PASSWORD="..."
|
||||
SQLITE_PATH="data/magent.db"
|
||||
JWT_SECRET="replace-with-a-long-random-secret"
|
||||
JWT_EXP_MINUTES="720"
|
||||
JWT_SECRET="replace-with-at-least-32-random-characters"
|
||||
SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
|
||||
JWT_EXP_MINUTES="120"
|
||||
ADMIN_USERNAME="set-a-real-admin-username"
|
||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
@@ -114,8 +115,9 @@ $env:QBIT_URL="http://localhost:8080"
|
||||
$env:QBIT_USERNAME="..."
|
||||
$env:QBIT_PASSWORD="..."
|
||||
$env:SQLITE_PATH="data/magent.db"
|
||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
||||
$env:JWT_EXP_MINUTES="720"
|
||||
$env:JWT_SECRET="replace-with-at-least-32-random-characters"
|
||||
$env:SETTINGS_ENCRYPTION_KEY="replace-with-a-fernet-key"
|
||||
$env:JWT_EXP_MINUTES="120"
|
||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
@@ -161,7 +163,23 @@ Configure these Gitea Actions secrets before enabling the deploy job:
|
||||
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
||||
- `PROD_SSH_USER`: target user, for example `zak`.
|
||||
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
||||
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
||||
- `PROD_SSH_KNOWN_HOSTS`: required pinned `known_hosts` entry. Deployments reject unknown or changed hosts.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ def _load_current_user_from_token(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
|
||||
if _is_expired(user.get("expires_at")):
|
||||
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)
|
||||
from .feature_access import permissions
|
||||
@@ -183,6 +186,7 @@ def _load_current_user_from_token(
|
||||
"is_expired": bool(user.get("is_expired", False)),
|
||||
"password_change_supported": bool(user.get("password_change_supported", False)),
|
||||
"password_provider": user.get("password_provider"),
|
||||
"auth_version": int(user.get("auth_version") or 1),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ class Settings(BaseSettings):
|
||||
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
|
||||
)
|
||||
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"))
|
||||
auth_rate_limit_window_seconds: int = Field(
|
||||
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
|
||||
@@ -53,7 +58,7 @@ class Settings(BaseSettings):
|
||||
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
|
||||
)
|
||||
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(
|
||||
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
|
||||
|
||||
+474
-68
@@ -1,15 +1,18 @@
|
||||
import json
|
||||
import hmac
|
||||
import os
|
||||
import sqlite3
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from hashlib import sha256
|
||||
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 .config import settings
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,7 +33,10 @@ def _db_path() -> str:
|
||||
if not os.path.isabs(path):
|
||||
app_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
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
|
||||
|
||||
|
||||
@@ -53,12 +59,23 @@ def _apply_connection_pragmas(conn: sqlite3.Connection) -> None:
|
||||
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:
|
||||
conn = sqlite3.connect(
|
||||
_db_path(),
|
||||
timeout=SQLITE_BUSY_TIMEOUT_MS / 1000,
|
||||
cached_statements=512,
|
||||
factory=_ClosingConnection,
|
||||
)
|
||||
with suppress(OSError):
|
||||
os.chmod(_db_path(), 0o600)
|
||||
_apply_connection_pragmas(conn)
|
||||
return conn
|
||||
|
||||
@@ -185,6 +202,63 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
|
||||
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:
|
||||
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)")
|
||||
@@ -278,7 +352,8 @@ def init_db() -> None:
|
||||
invited_by_code TEXT,
|
||||
invited_at TEXT,
|
||||
jellyfin_password_hash TEXT,
|
||||
last_jellyfin_auth_at TEXT
|
||||
last_jellyfin_auth_at TEXT,
|
||||
auth_version INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -297,6 +372,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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS signup_invites (
|
||||
@@ -651,10 +738,20 @@ def init_db() -> None:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
|
||||
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 signup_invites ADD COLUMN code_hint TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
_protect_legacy_signup_invite_codes(conn)
|
||||
_encrypt_legacy_sensitive_settings(conn)
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -1142,7 +1239,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
SELECT id, username, email, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||
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
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
ORDER BY id
|
||||
@@ -1171,6 +1268,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
"is_expired": _is_datetime_in_past(row[13]),
|
||||
"jellyfin_password_hash": row[16],
|
||||
"last_jellyfin_auth_at": row[17],
|
||||
"auth_version": int(row[18] or 1),
|
||||
}
|
||||
|
||||
|
||||
@@ -1181,7 +1279,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,
|
||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||
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
|
||||
WHERE jellyseerr_user_id = ?
|
||||
ORDER BY id ASC
|
||||
@@ -1211,6 +1309,7 @@ def get_user_by_jellyseerr_id(jellyseerr_user_id: int) -> Optional[Dict[str, Any
|
||||
"is_expired": _is_datetime_in_past(row[13]),
|
||||
"jellyfin_password_hash": row[16],
|
||||
"last_jellyfin_auth_at": row[17],
|
||||
"auth_version": int(row[18] or 1),
|
||||
}
|
||||
|
||||
|
||||
@@ -1221,7 +1320,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,
|
||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||
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
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -1249,6 +1348,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"is_expired": _is_datetime_in_past(row[13]),
|
||||
"jellyfin_password_hash": row[16],
|
||||
"last_jellyfin_auth_at": row[17],
|
||||
"auth_version": int(row[18] or 1),
|
||||
}
|
||||
|
||||
def get_all_users() -> list[Dict[str, Any]]:
|
||||
@@ -1257,7 +1357,7 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
"""
|
||||
SELECT id, username, email, role, auth_provider, jellyseerr_user_id, created_at,
|
||||
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
|
||||
ORDER BY username COLLATE NOCASE
|
||||
"""
|
||||
@@ -1281,6 +1381,7 @@ def get_all_users() -> list[Dict[str, Any]]:
|
||||
"expires_at": row[12],
|
||||
"invited_by_code": row[13],
|
||||
"invited_at": row[14],
|
||||
"auth_version": int(row[15] or 1),
|
||||
"is_expired": _is_datetime_in_past(row[12]),
|
||||
}
|
||||
)
|
||||
@@ -1375,24 +1476,181 @@ def set_user_blocked(username: str, blocked: bool) -> None:
|
||||
with _connect() as conn:
|
||||
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),
|
||||
)
|
||||
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:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM users WHERE username = ? COLLATE NOCASE
|
||||
""",
|
||||
user = conn.execute(
|
||||
"SELECT id, username, email FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(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),
|
||||
)
|
||||
deleted = cursor.rowcount > 0
|
||||
logger.warning("user delete username=%s deleted=%s", username, deleted)
|
||||
return deleted
|
||||
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"),
|
||||
)
|
||||
for sql, params, label in direct_operations:
|
||||
counts[label] = int(conn.execute(sql, params).rowcount or 0)
|
||||
|
||||
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:
|
||||
@@ -1424,7 +1682,7 @@ def set_user_role(username: str, role: str) -> None:
|
||||
with _connect() as conn:
|
||||
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),
|
||||
)
|
||||
@@ -1635,29 +1893,31 @@ def delete_user_profile(profile_id: int) -> bool:
|
||||
|
||||
|
||||
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
|
||||
max_uses = 1 if row[10] else row[6]
|
||||
use_count = int(row[7] or 0)
|
||||
expires_at = row[9]
|
||||
max_uses = 1 if row[11] else row[7]
|
||||
use_count = int(row[8] or 0)
|
||||
expires_at = row[10]
|
||||
is_expired = _is_datetime_in_past(expires_at)
|
||||
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
|
||||
return {
|
||||
"id": row[0],
|
||||
"code": row[1],
|
||||
"label": row[2],
|
||||
"description": row[3],
|
||||
"profile_id": row[4],
|
||||
"role": row[5],
|
||||
"code": _masked_invite_code(row[2]),
|
||||
"code_hint": row[2],
|
||||
"code_available": False,
|
||||
"label": row[3],
|
||||
"description": row[4],
|
||||
"profile_id": row[5],
|
||||
"role": row[6],
|
||||
"max_uses": max_uses,
|
||||
"use_count": use_count,
|
||||
"enabled": bool(row[8]),
|
||||
"enabled": bool(row[9]),
|
||||
"expires_at": expires_at,
|
||||
"recipient_email": row[10],
|
||||
"created_by": row[11],
|
||||
"created_at": row[12],
|
||||
"updated_at": row[13],
|
||||
"recipient_email": row[11],
|
||||
"created_by": row[12],
|
||||
"created_at": row[13],
|
||||
"updated_at": row[14],
|
||||
"is_expired": is_expired,
|
||||
"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 +1925,7 @@ def list_signup_invites() -> list[Dict[str, Any]]:
|
||||
with _connect() as conn:
|
||||
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
|
||||
FROM signup_invites
|
||||
ORDER BY created_at DESC, id DESC
|
||||
@@ -1678,7 +1938,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _connect() as conn:
|
||||
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
|
||||
FROM signup_invites
|
||||
WHERE id = ?
|
||||
@@ -1694,16 +1954,19 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
|
||||
with _connect() as conn:
|
||||
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
|
||||
FROM signup_invites
|
||||
WHERE code = ? COLLATE NOCASE
|
||||
WHERE code = ?
|
||||
""",
|
||||
(code,),
|
||||
(_hash_signup_invite_code(code),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
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(
|
||||
@@ -1719,6 +1982,9 @@ def create_signup_invite(
|
||||
recipient_email: Optional[str] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
normalized_code = _normalize_invite_secret(code)
|
||||
if not normalized_code:
|
||||
raise ValueError("Invite code is required")
|
||||
if recipient_email:
|
||||
max_uses = 1
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
@@ -1726,13 +1992,14 @@ def create_signup_invite(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
code,
|
||||
_hash_signup_invite_code(normalized_code),
|
||||
_invite_code_hint(normalized_code),
|
||||
label,
|
||||
description,
|
||||
profile_id,
|
||||
@@ -1748,20 +2015,21 @@ def create_signup_invite(
|
||||
)
|
||||
invite_id = int(cursor.lastrowid)
|
||||
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,
|
||||
code,
|
||||
role,
|
||||
profile_id,
|
||||
max_uses,
|
||||
enabled,
|
||||
expires_at,
|
||||
recipient_email,
|
||||
bool(recipient_email),
|
||||
created_by,
|
||||
)
|
||||
invite = get_signup_invite_by_id(invite_id)
|
||||
if not invite:
|
||||
raise RuntimeError("Invite creation failed")
|
||||
invite["code"] = normalized_code
|
||||
invite["code_available"] = True
|
||||
return invite
|
||||
|
||||
|
||||
@@ -1784,31 +2052,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'):
|
||||
raise ValueError('A used email invitation cannot be reassigned.')
|
||||
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 = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
|
||||
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 = ?
|
||||
""",
|
||||
(
|
||||
code,
|
||||
label,
|
||||
description,
|
||||
profile_id,
|
||||
role,
|
||||
max_uses,
|
||||
1 if enabled else 0,
|
||||
expires_at,
|
||||
recipient_email,
|
||||
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:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE signup_invites
|
||||
SET code = ?, code_hint = ?, updated_at = ?
|
||||
WHERE id = ? AND enabled = 1
|
||||
""",
|
||||
(
|
||||
_hash_signup_invite_code(normalized_code),
|
||||
_invite_code_hint(normalized_code),
|
||||
timestamp,
|
||||
invite_id,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount <= 0:
|
||||
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:
|
||||
@@ -1859,7 +2164,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
|
||||
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||
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
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
ORDER BY
|
||||
@@ -1874,8 +2179,15 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
|
||||
provider = str(row[4] or "local").lower()
|
||||
if provider != "local":
|
||||
continue
|
||||
if not verify_password(password, row[2]):
|
||||
verified, updated_hash = verify_and_update_password(password, row[2])
|
||||
if not verified:
|
||||
continue
|
||||
if updated_hash:
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(updated_hash, row[0]),
|
||||
)
|
||||
return {
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
@@ -1895,6 +2207,7 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any
|
||||
"is_expired": _is_datetime_in_past(row[12]),
|
||||
"jellyfin_password_hash": row[15],
|
||||
"last_jellyfin_auth_at": row[16],
|
||||
"auth_version": int(row[17] or 1),
|
||||
}
|
||||
return None
|
||||
|
||||
@@ -1906,7 +2219,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,
|
||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||
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
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
ORDER BY
|
||||
@@ -1938,6 +2251,7 @@ def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
|
||||
"is_expired": _is_datetime_in_past(row[13]),
|
||||
"jellyfin_password_hash": row[16],
|
||||
"last_jellyfin_auth_at": row[17],
|
||||
"auth_version": int(row[18] or 1),
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -1956,7 +2270,7 @@ def set_user_email(username: str, email: Optional[str]) -> bool:
|
||||
)
|
||||
updated = cursor.rowcount > 0
|
||||
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:
|
||||
logger.debug("user email update skipped username=%s", username)
|
||||
return updated
|
||||
@@ -1967,12 +2281,74 @@ def set_user_password(username: str, password: str) -> None:
|
||||
with _connect() as conn:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
if not username or not password:
|
||||
return
|
||||
@@ -2943,11 +3319,12 @@ def get_setting(key: str) -> Optional[str]:
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return row[0]
|
||||
return decrypt_setting_value(key, row[0])
|
||||
|
||||
|
||||
def set_setting(key: str, value: Optional[str]) -> None:
|
||||
updated_at = datetime.now(timezone.utc).isoformat()
|
||||
stored_value = encrypt_setting_value(key, value)
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -2955,7 +3332,7 @@ def set_setting(key: str, value: Optional[str]) -> None:
|
||||
VALUES (?, ?, ?)
|
||||
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 +3358,7 @@ def get_settings_overrides() -> Dict[str, str]:
|
||||
key = row[0]
|
||||
value = row[1]
|
||||
if key:
|
||||
overrides[key] = value
|
||||
overrides[key] = decrypt_setting_value(key, value)
|
||||
return overrides
|
||||
|
||||
|
||||
@@ -3067,12 +3444,10 @@ def create_password_reset_token(
|
||||
),
|
||||
)
|
||||
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,
|
||||
auth_provider,
|
||||
recipient_email,
|
||||
expires_at,
|
||||
requested_by_ip,
|
||||
)
|
||||
return {
|
||||
"username": username,
|
||||
@@ -3114,7 +3489,7 @@ def mark_password_reset_token_used(token_value: str) -> None:
|
||||
""",
|
||||
(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]]:
|
||||
@@ -4020,6 +4395,7 @@ def cleanup_history(days: int) -> Dict[str, int]:
|
||||
if days <= 0:
|
||||
return {"actions": 0, "snapshots": 0}
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
cutoff_epoch = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp()
|
||||
with _connect() as conn:
|
||||
actions = conn.execute(
|
||||
"DELETE FROM actions WHERE created_at < ?",
|
||||
@@ -4029,7 +4405,37 @@ def cleanup_history(days: int) -> Dict[str, int]:
|
||||
"DELETE FROM snapshots WHERE created_at < ?",
|
||||
(cutoff,),
|
||||
).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():
|
||||
|
||||
@@ -2,6 +2,7 @@ import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any, Mapping, Optional
|
||||
from urllib.parse import parse_qs
|
||||
@@ -27,6 +28,9 @@ _SENSITIVE_KEYWORDS = (
|
||||
"token",
|
||||
)
|
||||
_MAX_BODY_BYTES = 4096
|
||||
_SENSITIVE_PATH_PATTERNS = (
|
||||
re.compile(r"(/auth/invites/)[^/]+", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
class RequestContextFilter(logging.Filter):
|
||||
@@ -47,6 +51,13 @@ def current_request_id() -> str:
|
||||
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:
|
||||
lowered = key.strip().lower()
|
||||
return any(marker in lowered for marker in _SENSITIVE_KEYWORDS)
|
||||
@@ -55,10 +66,7 @@ def _is_sensitive_key(key: str) -> bool:
|
||||
def _redact_scalar(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
text = str(value)
|
||||
if len(text) <= 4:
|
||||
return "***"
|
||||
return f"{text[:2]}***{text[-2:]}"
|
||||
return "[REDACTED]"
|
||||
|
||||
|
||||
def sanitize_value(value: Any, *, key_hint: Optional[str] = None, depth: int = 0) -> Any:
|
||||
@@ -161,6 +169,10 @@ def configure_logging(
|
||||
backupCount=max(1, int(log_file_backup_count or 10)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
os.chmod(log_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
handlers.append(file_handler)
|
||||
|
||||
context_filter = RequestContextFilter()
|
||||
|
||||
+41
-20
@@ -7,6 +7,7 @@ from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .config import settings
|
||||
from .db import has_admin_user, init_db
|
||||
@@ -47,11 +48,11 @@ from .logging_config import (
|
||||
configure_logging,
|
||||
reset_request_id,
|
||||
sanitize_headers,
|
||||
sanitize_value,
|
||||
summarize_http_body,
|
||||
sanitize_path,
|
||||
)
|
||||
from .runtime import get_runtime_settings
|
||||
from .metrics import record_api, start_metrics
|
||||
from .secret_storage import validate_secret_storage_configuration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_background_tasks: list[asyncio.Task[None]] = []
|
||||
@@ -82,22 +83,33 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
operation_token = begin_operation(
|
||||
operation_id,
|
||||
label=request.headers.get("X-Magent-Operation-Label"),
|
||||
path=request.url.path,
|
||||
path=sanitize_path(request.url.path),
|
||||
)
|
||||
request.state.request_id = request_id
|
||||
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
origin = str(request.headers.get("origin") or "").rstrip("/")
|
||||
allowed_origin = str(settings.cors_allow_origin or "").rstrip("/")
|
||||
if origin and origin != allowed_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()
|
||||
body = await request.body()
|
||||
body_summary = summarize_http_body(body, request.headers.get("content-type"))
|
||||
|
||||
async def receive() -> dict:
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
request._receive = receive
|
||||
body_summary = {
|
||||
"content_type": (request.headers.get("content-type") or "").split(";", 1)[0],
|
||||
"declared_bytes": request.headers.get("content-length"),
|
||||
}
|
||||
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.url.path,
|
||||
sanitize_value(dict(request.query_params)),
|
||||
sanitize_path(request.url.path),
|
||||
sorted(set(request.query_params.keys())),
|
||||
request.client.host if request.client else "-",
|
||||
sanitize_headers(
|
||||
{
|
||||
@@ -124,7 +136,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
logger.exception(
|
||||
"request failed method=%s path=%s duration_ms=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
sanitize_path(request.url.path),
|
||||
duration_ms,
|
||||
)
|
||||
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("Referrer-Policy", "no-referrer")
|
||||
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.
|
||||
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
|
||||
response.headers.setdefault(
|
||||
@@ -149,7 +162,7 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
|
||||
logger.info(
|
||||
"request completed method=%s path=%s status=%s duration_ms=%s response_headers=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
sanitize_path(request.url.path),
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
sanitize_headers(
|
||||
@@ -203,9 +216,9 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
|
||||
|
||||
def _log_security_configuration_warnings() -> None:
|
||||
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(
|
||||
"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 "")
|
||||
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()
|
||||
if not jwt_secret or jwt_secret == "change-me":
|
||||
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
|
||||
if len(jwt_secret) < 32 or jwt_secret == "change-me":
|
||||
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 "")
|
||||
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
|
||||
raise RuntimeError(
|
||||
@@ -242,6 +262,7 @@ async def startup() -> None:
|
||||
)
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
_enforce_secret_configuration()
|
||||
init_db()
|
||||
_enforce_secure_startup_configuration()
|
||||
runtime = get_runtime_settings()
|
||||
|
||||
@@ -39,8 +39,7 @@ from ..db import (
|
||||
set_user_jellyseerr_id,
|
||||
set_setting,
|
||||
set_user_blocked,
|
||||
delete_user_by_username,
|
||||
delete_user_activity_by_username,
|
||||
delete_user_data_by_username,
|
||||
set_user_auto_search_enabled,
|
||||
set_auto_search_enabled_for_non_admin_users,
|
||||
set_user_email,
|
||||
@@ -49,6 +48,7 @@ from ..db import (
|
||||
set_user_profile_id,
|
||||
set_user_expires_at,
|
||||
set_user_password,
|
||||
increment_user_auth_version,
|
||||
sync_jellyfin_password_state,
|
||||
set_user_role,
|
||||
run_integrity_check,
|
||||
@@ -69,6 +69,7 @@ from ..db import (
|
||||
get_signup_invite_by_id,
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
rotate_signup_invite_code,
|
||||
delete_signup_invite,
|
||||
get_signup_invite_by_code,
|
||||
disable_signup_invites_by_creator,
|
||||
@@ -779,7 +780,7 @@ async def test_email_settings(request: Request) -> Dict[str, Any]:
|
||||
result = await send_test_email(recipient_email=recipient_email)
|
||||
except RuntimeError as 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}
|
||||
|
||||
|
||||
@@ -1307,12 +1308,12 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
|
||||
result["jellyseerr"] = {"status": "error", "detail": _http_error_detail(exc)}
|
||||
|
||||
if action == "remove":
|
||||
deleted = delete_user_by_username(username)
|
||||
activity_deleted = delete_user_activity_by_username(username)
|
||||
deletion = delete_user_data_by_username(username)
|
||||
deleted = bool(deletion.get("deleted"))
|
||||
result["local"] = {
|
||||
"status": "ok" if deleted else "not_found",
|
||||
"deleted": bool(deleted),
|
||||
"activity_deleted": activity_deleted,
|
||||
"data_cleanup": deletion,
|
||||
}
|
||||
|
||||
if any(
|
||||
@@ -1574,6 +1575,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
||||
sync_jellyfin_password_state(username, new_password_clean)
|
||||
increment_user_auth_version(username)
|
||||
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -1917,6 +1919,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')),
|
||||
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:
|
||||
result = await send_templated_email(
|
||||
@@ -1930,9 +1937,8 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
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,
|
||||
result.get("recipient_email"),
|
||||
invite.get("id") if invite else None,
|
||||
user.get("username") if user else None,
|
||||
)
|
||||
@@ -1998,15 +2004,14 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
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("code"),
|
||||
invite.get("label"),
|
||||
invite.get("profile_id"),
|
||||
invite.get("role"),
|
||||
invite.get("max_uses"),
|
||||
invite.get("enabled"),
|
||||
invite.get("recipient_email"),
|
||||
bool(invite.get("recipient_email")),
|
||||
send_email,
|
||||
)
|
||||
return {
|
||||
@@ -2029,7 +2034,11 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
existing = get_signup_invite_by_id(invite_id)
|
||||
if not existing:
|
||||
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"))
|
||||
enabled = payload.get("enabled")
|
||||
if not isinstance(enabled, bool):
|
||||
@@ -2063,6 +2072,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
email_error = None
|
||||
if send_email:
|
||||
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(
|
||||
"invited",
|
||||
invite=invite,
|
||||
@@ -2072,15 +2085,14 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
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("code"),
|
||||
invite.get("label"),
|
||||
invite.get("profile_id"),
|
||||
invite.get("role"),
|
||||
invite.get("max_uses"),
|
||||
invite.get("enabled"),
|
||||
invite.get("recipient_email"),
|
||||
bool(invite.get("recipient_email")),
|
||||
send_email,
|
||||
)
|
||||
return {
|
||||
@@ -2096,6 +2108,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}")
|
||||
async def remove_invite(invite_id: int) -> Dict[str, Any]:
|
||||
deleted = delete_signup_invite(invite_id)
|
||||
|
||||
+83
-108
@@ -1,11 +1,8 @@
|
||||
from ..feature_guards import require_invites
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import defaultdict, deque
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
|
||||
@@ -28,6 +25,7 @@ from ..db import (
|
||||
list_signup_invites,
|
||||
create_signup_invite,
|
||||
update_signup_invite,
|
||||
rotate_signup_invite_code,
|
||||
delete_signup_invite,
|
||||
reserve_signup_invite_use,
|
||||
release_signup_invite_use,
|
||||
@@ -39,6 +37,10 @@ from ..db import (
|
||||
get_global_request_total,
|
||||
get_setting,
|
||||
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 ..clients.jellyfin import JellyfinClient
|
||||
@@ -87,14 +89,6 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
|
||||
"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:
|
||||
normalized = normalize_delivery_email(value)
|
||||
if normalized:
|
||||
@@ -145,12 +139,6 @@ def _password_reset_rate_key_identifier(identifier: str) -> str:
|
||||
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:
|
||||
if not users:
|
||||
return None
|
||||
@@ -172,56 +160,33 @@ def _pick_preferred_ci_user_match(users: list[dict], requested_username: str) ->
|
||||
|
||||
|
||||
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)
|
||||
user_key = _login_rate_key_user(username)
|
||||
with _LOGIN_RATE_LOCK:
|
||||
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
|
||||
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
|
||||
_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)
|
||||
record_rate_limit_event("login-ip", ip_key)
|
||||
record_rate_limit_event("login-user", user_key)
|
||||
logger.warning("login failure recorded")
|
||||
|
||||
|
||||
def _clear_login_failures(request: Request, username: str) -> None:
|
||||
ip_key = _auth_client_ip(request)
|
||||
user_key = _login_rate_key_user(username)
|
||||
with _LOGIN_RATE_LOCK:
|
||||
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
|
||||
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
|
||||
clear_rate_limit_events("login-ip", ip_key)
|
||||
clear_rate_limit_events("login-user", user_key)
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
ip_key = _auth_client_ip(request)
|
||||
user_key = _login_rate_key_user(username)
|
||||
with _LOGIN_RATE_LOCK:
|
||||
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
|
||||
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
|
||||
_prune_attempts(ip_bucket, now, window)
|
||||
_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)
|
||||
ip_exceeded, ip_retry = get_rate_limit_status("login-ip", ip_key, window, max_ip)
|
||||
user_exceeded, user_retry = get_rate_limit_status("login-user", user_key, window, max_user)
|
||||
exceeded = ip_exceeded or user_exceeded
|
||||
retry_after = max(ip_retry if ip_exceeded else 1, user_retry if user_exceeded else 1)
|
||||
if exceeded:
|
||||
logger.warning(
|
||||
"login rate limit exceeded username=%s client=%s retry_after=%s",
|
||||
user_key,
|
||||
ip_key,
|
||||
retry_after,
|
||||
"login rate limit exceeded retry_after=%s", retry_after,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
@@ -231,48 +196,28 @@ def _enforce_login_rate_limit(request: Request, username: 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)
|
||||
identifier_key = _password_reset_rate_key_identifier(identifier)
|
||||
with _RESET_RATE_LOCK:
|
||||
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
|
||||
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
|
||||
_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)
|
||||
record_rate_limit_event("reset-ip", ip_key)
|
||||
record_rate_limit_event("reset-identifier", identifier_key)
|
||||
logger.info("password reset rate event recorded")
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
ip_key = _auth_client_ip(request)
|
||||
identifier_key = _password_reset_rate_key_identifier(identifier)
|
||||
with _RESET_RATE_LOCK:
|
||||
ip_bucket = _RESET_ATTEMPTS_BY_IP[ip_key]
|
||||
identifier_bucket = _RESET_ATTEMPTS_BY_IDENTIFIER[identifier_key]
|
||||
_prune_attempts(ip_bucket, now, window)
|
||||
_prune_attempts(identifier_bucket, now, window)
|
||||
exceeded = len(ip_bucket) >= max_ip or len(identifier_bucket) >= max_identifier
|
||||
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)
|
||||
ip_exceeded, ip_retry = get_rate_limit_status("reset-ip", ip_key, window, max_ip)
|
||||
identifier_exceeded, identifier_retry = get_rate_limit_status(
|
||||
"reset-identifier", identifier_key, window, max_identifier
|
||||
)
|
||||
exceeded = ip_exceeded or identifier_exceeded
|
||||
retry_after = max(ip_retry if ip_exceeded else 1, identifier_retry if identifier_exceeded else 1)
|
||||
if exceeded:
|
||||
logger.warning(
|
||||
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
|
||||
identifier_key,
|
||||
ip_key,
|
||||
retry_after,
|
||||
"password reset rate limit exceeded retry_after=%s", retry_after,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
@@ -400,6 +345,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) -
|
||||
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
|
||||
return {
|
||||
"code": invite.get("code"),
|
||||
"code_available": bool(invite.get("code_available")),
|
||||
"email_bound": bool(invite.get("recipient_email")),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
@@ -493,6 +439,7 @@ def _serialize_self_invite(invite: dict) -> dict:
|
||||
return {
|
||||
"id": invite.get("id"),
|
||||
"code": invite.get("code"),
|
||||
"code_available": bool(invite.get("code_available")),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"profile_id": invite.get("profile_id"),
|
||||
@@ -576,6 +523,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
|
||||
return {
|
||||
"id": invite.get("id"),
|
||||
"code": invite.get("code"),
|
||||
"code_available": bool(invite.get("code_available")),
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"profile_id": invite.get("profile_id"),
|
||||
@@ -664,7 +612,9 @@ async def login(
|
||||
detail="This account uses external sign-in. Use the external sign-in option.",
|
||||
)
|
||||
_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)
|
||||
set_last_login(user["username"])
|
||||
logger.info(
|
||||
@@ -708,7 +658,9 @@ async def jellyfin_login(
|
||||
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
|
||||
_assert_user_can_login(user)
|
||||
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)
|
||||
set_last_login(canonical_username)
|
||||
logger.info(
|
||||
@@ -775,7 +727,10 @@ async def jellyfin_login(
|
||||
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
||||
if matched_id is not None:
|
||||
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)
|
||||
set_last_login(canonical_username)
|
||||
logger.info(
|
||||
@@ -851,7 +806,10 @@ async def jellyseerr_login(
|
||||
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id)
|
||||
if 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)
|
||||
set_last_login(canonical_username)
|
||||
logger.info(
|
||||
@@ -873,7 +831,10 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
|
||||
|
||||
@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)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -884,6 +845,7 @@ async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
current_user["username"],
|
||||
current_user["role"],
|
||||
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
|
||||
auth_version=int(current_user.get("auth_version") or 1),
|
||||
)
|
||||
return {
|
||||
"stream_token": token,
|
||||
@@ -923,11 +885,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
if get_user_by_username(username):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
|
||||
logger.info(
|
||||
"signup attempt username=%s invite_code=%s",
|
||||
username,
|
||||
invite_code,
|
||||
)
|
||||
logger.info("signup attempt username=%s", username)
|
||||
|
||||
invite = get_signup_invite_by_code(invite_code)
|
||||
if not invite:
|
||||
@@ -1039,7 +997,7 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
auto_search_enabled=auto_search_enabled,
|
||||
profile_id=int(profile_id) if profile_id is not None else None,
|
||||
expires_at=expires_at,
|
||||
invited_by_code=invite.get("code"),
|
||||
invited_by_code=f"invite:{invite.get('id')}",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
@@ -1066,15 +1024,18 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
# Welcome email delivery is best-effort and must not break signup.
|
||||
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
||||
_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)
|
||||
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,
|
||||
role,
|
||||
created_user.get("auth_provider") if created_user else auth_provider,
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
invite.get("id"),
|
||||
)
|
||||
return _auth_success_response(
|
||||
response,
|
||||
@@ -1110,8 +1071,7 @@ async def forgot_password(payload: dict, request: Request) -> dict:
|
||||
)
|
||||
|
||||
client_ip = _auth_client_ip(request)
|
||||
safe_identifier = identifier.strip().lower()[:256]
|
||||
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
|
||||
logger.info("password reset requested")
|
||||
try:
|
||||
reset_result = await request_password_reset(
|
||||
identifier,
|
||||
@@ -1120,24 +1080,17 @@ async def forgot_password(payload: dict, request: Request) -> dict:
|
||||
)
|
||||
if reset_result.get("issued"):
|
||||
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("auth_provider"),
|
||||
reset_result.get("recipient_email"),
|
||||
client_ip,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"password reset request completed with no eligible account identifier=%s client=%s",
|
||||
safe_identifier,
|
||||
client_ip,
|
||||
"password reset request completed with no eligible account",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"password reset email dispatch failed identifier=%s client=%s detail=%s",
|
||||
safe_identifier,
|
||||
client_ip,
|
||||
str(exc),
|
||||
"password reset email dispatch failed detail=%s", type(exc).__name__,
|
||||
)
|
||||
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
|
||||
|
||||
@@ -1371,8 +1324,13 @@ async def update_profile_invite(
|
||||
_require_self_service_invite_access(current_user)
|
||||
existing = _get_owned_invite(invite_id, current_user)
|
||||
|
||||
requested_code = payload.get("code", existing.get("code"))
|
||||
if isinstance(requested_code, str) and requested_code.strip():
|
||||
requested_code = payload.get("code")
|
||||
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)
|
||||
else:
|
||||
code = str(existing.get("code") or "").strip()
|
||||
@@ -1427,6 +1385,10 @@ async def update_profile_invite(
|
||||
email_error = None
|
||||
if send_email:
|
||||
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(
|
||||
"invited",
|
||||
invite=invite,
|
||||
@@ -1450,6 +1412,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}")
|
||||
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
_require_self_service_invite_access(current_user)
|
||||
@@ -1531,6 +1505,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.
|
||||
sync_jellyfin_password_state(username, new_password_clean)
|
||||
increment_user_auth_version(username)
|
||||
logger.info("password change completed username=%s provider=jellyfin", username)
|
||||
return {"status": "ok", "provider": "jellyfin"}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import warnings
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -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_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
|
||||
_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:
|
||||
@@ -110,14 +115,27 @@ async def branding_favicon() -> FileResponse:
|
||||
|
||||
|
||||
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="Please upload an image file.")
|
||||
content = await file.read()
|
||||
content_type = str(file.content_type or "").lower()
|
||||
extension = os.path.splitext(str(file.filename or ""))[1].lower()
|
||||
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:
|
||||
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:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
candidate = Image.open(BytesIO(content))
|
||||
if candidate.format not in {"PNG", "JPEG", "WEBP"}:
|
||||
raise ValueError("Unsupported image format")
|
||||
if candidate.width * candidate.height > _MAX_IMAGE_PIXELS:
|
||||
raise Image.DecompressionBombError("Image pixel limit exceeded")
|
||||
candidate.verify()
|
||||
image = Image.open(BytesIO(content))
|
||||
except OSError as exc:
|
||||
image.load()
|
||||
except (OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning) as exc:
|
||||
raise HTTPException(status_code=400, detail="Image file could not be read.") from exc
|
||||
|
||||
_ensure_branding_dir()
|
||||
|
||||
@@ -1593,11 +1593,54 @@ def get_requests_sync_state() -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def _ensure_request_access(
|
||||
client: JellyseerrClient, request_id: int, user: Dict[str, str]
|
||||
) -> None:
|
||||
if user.get("role") == "admin" or user.get("username"):
|
||||
return
|
||||
client: JellyseerrClient,
|
||||
request_id: int,
|
||||
user: Dict[str, Any],
|
||||
*,
|
||||
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]]:
|
||||
@@ -1948,9 +1991,6 @@ async def issue_target_options(
|
||||
if not request_id.isdigit():
|
||||
raise HTTPException(status_code=400, detail="Invalid request id")
|
||||
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)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict):
|
||||
@@ -2136,9 +2176,7 @@ async def action_replace_media(
|
||||
)
|
||||
|
||||
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)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict):
|
||||
@@ -2360,6 +2398,7 @@ async def action_search_missing_media(
|
||||
payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
|
||||
)
|
||||
runtime = get_runtime_settings()
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
|
||||
@@ -2497,9 +2536,7 @@ async def action_add_seasons(
|
||||
raise HTTPException(status_code=400, detail="Choose at least one season")
|
||||
|
||||
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)
|
||||
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")
|
||||
@@ -2627,6 +2664,7 @@ async def action_repair_subtitles(
|
||||
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
|
||||
forced = payload.get("forced") is True
|
||||
runtime = get_runtime_settings()
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
if not bazarr.configured() or not runtime.bazarr_api_key:
|
||||
raise HTTPException(status_code=400, detail="Bazarr is not configured")
|
||||
@@ -2772,8 +2810,11 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not seerr.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
|
||||
)
|
||||
|
||||
if fresh_request is None:
|
||||
try:
|
||||
fresh_request = await seerr.get_request(request_id)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
@@ -3444,10 +3485,13 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
|
||||
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()
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
request = await _ensure_request_access(
|
||||
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':
|
||||
return runtime, None, None
|
||||
@@ -3479,7 +3523,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.')
|
||||
if payload.get('acceptOriginalLanguage') is not True:
|
||||
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:
|
||||
raise HTTPException(409, 'This request has no verified non-English original language.')
|
||||
if payload.get('languageCode') != language['code']:
|
||||
@@ -3502,9 +3548,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
||||
total_missing = 0
|
||||
next_offset = None
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
|
||||
@@ -3612,9 +3656,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
if not _user_can_use_search_auto(user):
|
||||
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict):
|
||||
@@ -3664,9 +3706,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
@router.post("/{request_id}/actions/qbit/resume")
|
||||
async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
queue = snapshot.raw.get("arr", {}).get("queue")
|
||||
download_ids = _download_ids(_queue_records(queue))
|
||||
@@ -3711,9 +3751,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr
|
||||
@router.post("/{request_id}/actions/readd")
|
||||
async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
jelly = snapshot.raw.get("jellyseerr") or {}
|
||||
media = jelly.get("media") or {}
|
||||
@@ -3870,9 +3908,7 @@ async def action_grab(
|
||||
request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)
|
||||
) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
guid = payload.get("guid")
|
||||
indexer_id = payload.get("indexerId")
|
||||
|
||||
@@ -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
|
||||
+50
-7
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from passlib.context import CryptContext
|
||||
@@ -7,9 +8,15 @@ from jwt import InvalidTokenError
|
||||
|
||||
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"
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
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:
|
||||
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:
|
||||
@@ -34,32 +51,58 @@ def _create_token(
|
||||
*,
|
||||
expires_at: datetime,
|
||||
token_type: str = "access",
|
||||
auth_version: int = 1,
|
||||
) -> str:
|
||||
issued_at = datetime.now(timezone.utc)
|
||||
payload: Dict[str, Any] = {
|
||||
"sub": subject,
|
||||
"role": role,
|
||||
"typ": token_type,
|
||||
"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)
|
||||
|
||||
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:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
minutes = expires_minutes or settings.jwt_exp_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)))
|
||||
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]:
|
||||
if not settings.jwt_secret:
|
||||
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):
|
||||
|
||||
@@ -1025,16 +1025,12 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
||||
raise RuntimeError("SMTP email settings are incomplete.")
|
||||
local_hostname = _derive_mail_hostname(from_address=from_address)
|
||||
logger.info(
|
||||
"smtp send started recipient=%s from=%s host=%s port=%s tls=%s ssl=%s auth=%s subject=%s ehlo=%s",
|
||||
recipient_email,
|
||||
from_address,
|
||||
"smtp send started host=%s port=%s tls=%s ssl=%s auth=%s",
|
||||
host,
|
||||
port,
|
||||
use_tls,
|
||||
use_ssl,
|
||||
bool(username and password),
|
||||
subject,
|
||||
local_hostname,
|
||||
)
|
||||
if 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,
|
||||
)
|
||||
logger.info(
|
||||
"smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s",
|
||||
recipient_email,
|
||||
host,
|
||||
receipt.get("provider_message_id"),
|
||||
receipt.get("provider_internal_id"),
|
||||
"smtp send accepted host=%s mode=ssl", host,
|
||||
)
|
||||
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)
|
||||
if username and 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(
|
||||
smtp,
|
||||
from_address=from_address,
|
||||
@@ -1108,11 +1100,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
||||
message=message,
|
||||
)
|
||||
logger.info(
|
||||
"smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s",
|
||||
recipient_email,
|
||||
host,
|
||||
receipt.get("provider_message_id"),
|
||||
receipt.get("provider_internal_id"),
|
||||
"smtp send accepted host=%s mode=plain", host,
|
||||
)
|
||||
return receipt
|
||||
|
||||
@@ -1153,7 +1141,7 @@ async def send_templated_email(
|
||||
body_text=rendered["body_text"],
|
||||
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 {
|
||||
"recipient_email": resolved_email,
|
||||
"subject": rendered["subject"],
|
||||
@@ -1185,7 +1173,7 @@ async def send_generic_email(
|
||||
body_text=body_text.strip(),
|
||||
body_html=body_html.strip(),
|
||||
)
|
||||
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
|
||||
logger.info("Generic email sent")
|
||||
return {
|
||||
"recipient_email": resolved_email,
|
||||
"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_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.update(
|
||||
{
|
||||
@@ -1383,9 +1371,8 @@ async def send_password_reset_email(
|
||||
body_html=body_html,
|
||||
)
|
||||
logger.info(
|
||||
"Password reset email sent: username=%s recipient=%s provider=%s",
|
||||
"Password reset email sent: username=%s provider=%s",
|
||||
username,
|
||||
resolved_email,
|
||||
auth_provider,
|
||||
)
|
||||
result = {
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..db import (
|
||||
mark_password_reset_token_used,
|
||||
set_user_auth_provider,
|
||||
set_user_password,
|
||||
increment_user_auth_version,
|
||||
sync_jellyfin_password_state,
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
@@ -243,7 +244,7 @@ async def request_password_reset(
|
||||
delete_expired_password_reset_tokens()
|
||||
target = await _resolve_reset_target(identifier)
|
||||
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}
|
||||
|
||||
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.")
|
||||
await client.set_user_password(user_id, 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":
|
||||
set_user_auth_provider(username, "jellyfin")
|
||||
mark_password_reset_token_used(token)
|
||||
|
||||
@@ -5,6 +5,8 @@ pydantic==2.12.5
|
||||
pydantic-settings==2.14.2
|
||||
PyJWT==2.13.0
|
||||
passlib==1.7.4
|
||||
argon2-cffi==25.1.0
|
||||
cryptography==50.0.1
|
||||
python-multipart==0.0.31
|
||||
Pillow==12.3.0
|
||||
prometheus-client==0.22.1
|
||||
|
||||
@@ -6,23 +6,25 @@ from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from passlib.context import CryptContext
|
||||
from starlette.requests import Request
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.base import _operation_error_message, _operation_result_message
|
||||
from backend.app.clients.jellyfin import _availability_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.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
||||
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||
from backend.app.routers import auth as auth_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 requests as requests_router
|
||||
from backend.app.routers import site as site_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 issue_resolution
|
||||
from backend.app.services.operation_progress import (
|
||||
@@ -71,21 +73,16 @@ class TempDatabaseMixin:
|
||||
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self._original_sqlite_path = settings.sqlite_path
|
||||
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_journal_mode = "DELETE"
|
||||
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
|
||||
settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
|
||||
db.init_db()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
settings.sqlite_path = self._original_sqlite_path
|
||||
settings.sqlite_journal_mode = self._original_journal_mode
|
||||
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
|
||||
settings.settings_encryption_key = self._original_settings_encryption_key
|
||||
self._tempdir.cleanup()
|
||||
super_method = getattr(super(), "tearDown", None)
|
||||
if callable(super_method):
|
||||
@@ -98,7 +95,204 @@ class PasswordPolicyTests(unittest.TestCase):
|
||||
validate_password_policy("short")
|
||||
|
||||
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):
|
||||
@@ -1208,6 +1402,13 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
access = patch.object(
|
||||
requests_router,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
|
||||
def selection(self, payload, request_id, source):
|
||||
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
|
||||
@@ -1628,6 +1829,16 @@ class AuthFlowTests(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:
|
||||
issue = {"id": 12, "status": "in_progress"}
|
||||
with (
|
||||
@@ -2072,6 +2283,11 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
||||
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, "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, "get_portal_item", return_value={
|
||||
"id": 12,
|
||||
@@ -2184,28 +2400,28 @@ class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTes
|
||||
|
||||
async def test_invite_list_reports_automatic_operational_states(self) -> None:
|
||||
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")
|
||||
db.increment_signup_invite_use(int(used["id"]))
|
||||
db.create_signup_invite(
|
||||
expired = db.create_signup_invite(
|
||||
code="EXPIRED",
|
||||
expires_at="2000-01-01T00:00:00+00:00",
|
||||
recipient_email="expired@example.com",
|
||||
)
|
||||
db.create_signup_invite(
|
||||
no_profile = db.create_signup_invite(
|
||||
code="NO-PROFILE",
|
||||
profile_id=999,
|
||||
recipient_email="profile@example.com",
|
||||
)
|
||||
|
||||
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["DISABLED"], "disabled")
|
||||
self.assertEqual(states["USED"], "exhausted")
|
||||
self.assertEqual(states["EXPIRED"], "expired")
|
||||
self.assertEqual(states["NO-PROFILE"], "profile_unavailable")
|
||||
self.assertEqual(states[ready["id"]], "ready")
|
||||
self.assertEqual(states[disabled["id"]], "disabled")
|
||||
self.assertEqual(states[used["id"]], "exhausted")
|
||||
self.assertEqual(states[expired["id"]], "expired")
|
||||
self.assertEqual(states[no_profile["id"]], "profile_unavailable")
|
||||
self.assertEqual(payload["summary"]["total"], 5)
|
||||
self.assertEqual(payload["summary"]["ready"], 1)
|
||||
self.assertEqual(payload["summary"]["attention"], 4)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.app.config import settings
|
||||
from fastapi import FastAPI
|
||||
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.start()
|
||||
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-admin', 'Example-password123!', role='admin')
|
||||
self.user = db.get_user_by_username('feature-viewer')
|
||||
|
||||
@@ -61,6 +61,15 @@ class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||
|
||||
|
||||
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
access = patch.object(
|
||||
requests,
|
||||
'_ensure_request_mutation_access',
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
|
||||
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
AUTH_COOKIE_NAME: magent_beta_auth
|
||||
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
||||
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
|
||||
AUTH_COOKIE_SECURE: "true"
|
||||
AUTH_COOKIE_SAMESITE: strict
|
||||
SQLITE_PATH: /app/data/magent.db
|
||||
LOG_FILE: /app/data/magent.log
|
||||
SITE_BANNER_ENABLED: "true"
|
||||
@@ -26,3 +28,10 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
init: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
|
||||
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
|
||||
|
||||
@@ -5,6 +5,13 @@ services:
|
||||
- ./.env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "8000:8000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
init: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
|
||||
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
|
||||
|
||||
@@ -5,9 +5,19 @@ services:
|
||||
build: .
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
AUTH_COOKIE_SECURE: "true"
|
||||
AUTH_COOKIE_SAMESITE: strict
|
||||
ports:
|
||||
- "10.30.1.32:3200:3000"
|
||||
- "127.0.0.1:8200:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
init: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
|
||||
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
|
||||
|
||||
+8
-1
@@ -7,6 +7,13 @@ services:
|
||||
- ./.env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "8000:8000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
init: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m,uid=1000,gid=1000
|
||||
- /app/frontend/.next/cache:rw,noexec,nosuid,size=128m,uid=1000,gid=1000
|
||||
|
||||
@@ -36,6 +36,7 @@ type Profile = {
|
||||
type Invite = {
|
||||
id: number
|
||||
code: string
|
||||
code_available?: boolean
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
profile_id?: number | null
|
||||
@@ -501,17 +502,30 @@ export default function AdminInviteManagementPage() {
|
||||
}
|
||||
|
||||
const copyInviteLink = async (invite: Invite) => {
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||
try {
|
||||
let usableInvite = invite
|
||||
if (!invite.code_available) {
|
||||
const response = await authFetch(`${getApiBase()}/admin/invites/${invite.id}/rotate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (handleAuthResponse(response)) return
|
||||
throw new Error((await response.text()) || 'Could not generate a replacement link.')
|
||||
}
|
||||
const data = await response.json()
|
||||
usableInvite = data.invite as Invite
|
||||
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item))
|
||||
}
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setStatus(`Copied invite link for ${invite.code}.`)
|
||||
setStatus(`Copied the invite link. Keep it safe; Magent will not display it again after this page reloads.`)
|
||||
} else {
|
||||
window.prompt('Copy invite link', url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
window.prompt('Copy invite link', url)
|
||||
setError(err instanceof Error ? err.message : 'Could not generate or copy the invite link.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1666,7 +1680,7 @@ export default function AdminInviteManagementPage() {
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
|
||||
Copy link
|
||||
{invite.code_available ? 'Copy link' : 'Generate replacement link'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean }
|
||||
type OwnedInvite = {
|
||||
id: number; code: string; label?: string | null; description?: string | null
|
||||
code_available?: boolean
|
||||
recipient_email?: string | null; max_uses?: number | null; use_count: number
|
||||
remaining_uses?: number | null; enabled: boolean; expires_at?: string | null
|
||||
is_usable?: boolean; created_at?: string | null
|
||||
@@ -212,12 +213,22 @@ export default function ProfileInvitesPage() {
|
||||
}
|
||||
|
||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||
try {
|
||||
let usableInvite = invite
|
||||
if (!invite.code_available) {
|
||||
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error((await response.text()) || 'Could not generate a replacement link.')
|
||||
const data = await response.json()
|
||||
usableInvite = data.invite as OwnedInvite
|
||||
setInvites((current) => current.map((item) => item.id === invite.id ? usableInvite : item))
|
||||
}
|
||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
setStatus(`Copied the link for ${invite.label || invite.code}.`)
|
||||
setStatus(`Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`)
|
||||
} catch {
|
||||
window.prompt('Copy invite link', url)
|
||||
setError('Could not generate or copy the invite link.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +308,7 @@ export default function ProfileInvitesPage() {
|
||||
|
||||
<div className="profile-invites-list">
|
||||
<div className="invite-flow-heading"><div><span className="eyebrow">Your invites</span><h2>Created invites</h2><p className="lede">Copy, edit, disable, or remove invitations you have made.</p></div></div>
|
||||
{invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>Copy link</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
||||
{invites.length === 0 ? <div className="status-banner">You have not created any invites yet.</div> : <div className="admin-list">{invites.map((invite) => <div key={invite.id} className="admin-list-item"><div className="admin-list-item-main"><div className="admin-list-item-title-row"><strong>{invite.label || 'Unnamed invite'}</strong><code className="invite-code">{invite.code}</code><span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>{invite.is_usable ? 'Ready' : 'Unavailable'}</span></div>{invite.description && <p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>}<div className="admin-meta-row"><span>Delivery: {invite.recipient_email || 'Manual link'}</span><span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span><span>Expires: {formatDate(invite.expires_at)}</span><span>Created: {formatDate(invite.created_at)}</span></div></div><div className="admin-inline-actions"><button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>{invite.code_available ? 'Copy link' : 'Generate replacement link'}</button><button type="button" className="ghost-button" onClick={() => editInvite(invite)}>Edit</button><button type="button" onClick={() => void deleteInvite(invite)}>Delete</button></div></div>)}</div>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,27 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
poweredByHeader: false,
|
||||
compress: true,
|
||||
experimental: { proxyTimeout: 180000 },
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'no-referrer' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/login',
|
||||
headers: [{ key: 'Cache-Control', value: 'private, no-store, max-age=0' }],
|
||||
},
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
|
||||
const developmentEval = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${developmentEval}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
"upgrade-insecure-requests",
|
||||
].join('; ')
|
||||
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
requestHeaders.set('x-nonce', nonce)
|
||||
requestHeaders.set('Content-Security-Policy', csp)
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
||||
response.headers.set('Content-Security-Policy', csp)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
{
|
||||
source: '/((?!api|_next/static|_next/image|favicon.ico|branding/).*)',
|
||||
missing: [
|
||||
{ type: 'header', key: 'next-router-prefetch' },
|
||||
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -7,7 +7,7 @@ cd "$repo_root"
|
||||
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||
deploy_user="${DEPLOY_USER:-zak}"
|
||||
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
remote="${deploy_user}@${deploy_host}"
|
||||
@@ -16,9 +16,12 @@ echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
|
||||
|
||||
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
umask 077
|
||||
mkdir -p '${deploy_path}'
|
||||
chmod 700 '${deploy_path}'
|
||||
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
chmod 700 \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||
if [ -e \"\$path\" ]; then
|
||||
@@ -26,7 +29,20 @@ git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
fi
|
||||
done
|
||||
tar -xf - -C '${deploy_path}'
|
||||
docker compose up -d --build
|
||||
if [ -f '${deploy_path}/.env' ]; then
|
||||
chmod 600 '${deploy_path}/.env'
|
||||
fi
|
||||
mkdir -p '${deploy_path}/data'
|
||||
chmod 700 '${deploy_path}/data'
|
||||
docker compose build
|
||||
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
|
||||
settings_key=\"\$(docker compose run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
|
||||
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
|
||||
chmod 600 .env
|
||||
fi
|
||||
docker compose run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
|
||||
docker compose run --rm --user 0 magent chown -R 1000:1000 /app/data
|
||||
docker compose up -d
|
||||
"
|
||||
|
||||
echo "Running remote smoke checks"
|
||||
|
||||
@@ -9,7 +9,7 @@ deploy_user="${DEPLOY_USER:-zak}"
|
||||
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
|
||||
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=yes"}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
remote="${deploy_user}@${deploy_host}"
|
||||
@@ -18,9 +18,12 @@ echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
|
||||
|
||||
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
umask 077
|
||||
mkdir -p '${deploy_path}'
|
||||
chmod 700 '${deploy_path}'
|
||||
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
chmod 700 \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||
if [ -e \"\$path\" ]; then
|
||||
@@ -32,14 +35,25 @@ git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
|
||||
cp '${prod_path}/.env' '${deploy_path}/.env'
|
||||
fi
|
||||
if [ -f '${deploy_path}/.env' ]; then
|
||||
chmod 600 '${deploy_path}/.env'
|
||||
fi
|
||||
|
||||
mkdir -p '${deploy_path}/data'
|
||||
chmod 700 '${deploy_path}/data'
|
||||
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
|
||||
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
|
||||
fi
|
||||
|
||||
cd '${deploy_path}'
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml build
|
||||
if ! grep -Eq '^[[:space:]]*SETTINGS_ENCRYPTION_KEY=' .env; then
|
||||
settings_key=\"\$(docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')\"
|
||||
printf '\nSETTINGS_ENCRYPTION_KEY=%s\n' \"\${settings_key}\" >> .env
|
||||
chmod 600 .env
|
||||
fi
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --no-deps --entrypoint python magent -c \"from app.config import settings; from app.secret_storage import validate_secret_storage_configuration; assert len(str(settings.jwt_secret or '').strip()) >= 32, 'JWT_SECRET must contain at least 32 characters'; validate_secret_storage_configuration()\"
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml run --rm --user 0 magent chown -R 1000:1000 /app/data
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml up -d
|
||||
"
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.runtime import get_runtime_settings
|
||||
|
||||
|
||||
@@ -30,6 +32,7 @@ def prepare(destination: Path) -> None:
|
||||
password = secrets.token_urlsafe(30)
|
||||
values.update(
|
||||
APP_NAME='Magent', JWT_SECRET=secrets.token_urlsafe(48),
|
||||
SETTINGS_ENCRYPTION_KEY=Fernet.generate_key().decode('ascii'),
|
||||
ADMIN_USERNAME='admin', ADMIN_PASSWORD=password,
|
||||
AUTH_COOKIE_SECURE=True, AUTH_COOKIE_DOMAIN='magent.grizzlyflix.co.nz',
|
||||
AUTH_COOKIE_NAME='magent_auth', AUTH_STATE_COOKIE_NAME='magent_logged_in',
|
||||
|
||||
Reference in New Issue
Block a user