Compare commits

..

28 Commits

Author SHA1 Message Date
Rephl3x 87971d1ff0 Expose issue portal navigation
Magent CI/CD / verify (push) Successful in 11m11s
Magent CI/CD / deploy-prod (push) Failing after 2m22s
2026-06-18 21:45:03 +12:00
Rephl3x 8f03e315b8 Fix request detail load failures
Magent CI/CD / verify (push) Failing after 11m3s
Magent CI/CD / deploy-prod (push) Has been skipped
2026-06-18 21:10:56 +12:00
Rephl3x a8aa8e38e2 Add Gitea CI/CD pipeline for beta and prod
Magent CI/CD / verify (push) Has been cancelled
Magent CI/CD / deploy-prod (push) Has been cancelled
2026-05-24 17:07:27 +12:00
Rephl3x 329884b789 Clarify qBittorrent status and fix status pill contrast 2026-05-24 17:03:45 +12:00
Rephl3x 0700d37469 Fix requests cache migration on legacy databases 2026-05-24 16:55:18 +12:00
Rephl3x 2d28047ad7 Merge latest beta with verified auth hardening 2026-05-23 21:14:03 +12:00
Rephl3x cbac743026 Merge security hardening from dev-1.4 2026-05-23 21:12:59 +12:00
Rephl3x 1ce01ec348 Harden auth and outbound admin surfaces 2026-05-23 21:12:45 +12:00
Rephl3x cc26ed9b2c hardening 2026-05-16 10:44:20 +00:00
Rephl3x d9ac54a2ff Process 1 build 0803262237 2026-03-08 22:38:31 +13:00
Rephl3x 3609f44607 Process 1 build 0803262229 2026-03-08 22:30:49 +13:00
Rephl3x f830fc1296 Process 1 build 0803262216 2026-03-08 22:17:33 +13:00
Rephl3x 3989e90a9a Process 1 build 0803262038 2026-03-08 20:40:18 +13:00
Rephl3x 4e2b902760 Process 1 build 0703261729 2026-03-07 17:30:58 +13:00
Rephl3x 494b79ed26 Process 1 build 0403261902 2026-03-04 19:03:52 +13:00
Rephl3x d30a2473ce Improve email deliverability headers and SMTP identity 2026-03-04 17:37:51 +13:00
Rephl3x 4e64f79e64 Fix admin user email visibility 2026-03-04 13:22:26 +13:00
Rephl3x c6bc31f27e Harden auth flows and add backend quality gate 2026-03-04 12:57:42 +13:00
Rephl3x 1ad4823830 Fix email branding with inline logo and reliable MIME transport 2026-03-03 18:42:08 +13:00
Rephl3x caa6aa76d6 Fix email template rendering for Outlook-safe branded content 2026-03-03 17:20:19 +13:00
Rephl3x d80b1e5e4f Update all email templates with uniform branded graphics 2026-03-03 17:02:38 +13:00
Rephl3x 1ff54690fc Add branded HTML email templates 2026-03-03 16:30:02 +13:00
Rephl3x 4f2b5e0922 Add SMTP receipt logging for Exchange relay tracing 2026-03-03 16:12:13 +13:00
Rephl3x 96333c0d85 Fix shared request access and Jellyfin-ready pipeline status 2026-03-03 16:01:36 +13:00
Rephl3x bac96c7db3 Process 1 build 0303261507 2026-03-03 15:07:35 +13:00
Rephl3x dda17a20a5 Improve SQLite batching and diagnostics visibility 2026-03-03 15:03:23 +13:00
Rephl3x e582ff4ef7 Add login page visibility controls 2026-03-03 14:13:39 +13:00
Rephl3x 42d4caa474 Hotfix: expand landing-page search to all requests 2026-03-03 13:24:25 +13:00
59 changed files with 7559 additions and 655 deletions
+1 -1
View File
@@ -1 +1 @@
0203262044 0803262237
+73
View File
@@ -0,0 +1,73 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- prod
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Build frontend
working-directory: frontend
run: npm run build
deploy-prod:
if: github.ref_name == 'prod'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
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
run: bash scripts/deploy_ams_dev01.sh
+26 -6
View File
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..." QBIT_USERNAME="..."
QBIT_PASSWORD="..." QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db" SQLITE_PATH="data/magent.db"
JWT_SECRET="change-me" JWT_SECRET="replace-with-a-long-random-secret"
JWT_EXP_MINUTES="720" JWT_EXP_MINUTES="720"
ADMIN_USERNAME="admin" ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="adminadmin" ADMIN_PASSWORD="set-a-long-unique-admin-password"
``` ```
## Screenshots ## Screenshots
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..." $env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..." $env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db" $env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="change-me" $env:JWT_SECRET="replace-with-a-long-random-secret"
$env:JWT_EXP_MINUTES="720" $env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="admin" $env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="adminadmin" $env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
``` ```
### Frontend (Next.js) ### Frontend (Next.js)
@@ -141,6 +141,26 @@ The frontend proxies `/api/*` to the backend container. Set:
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured. If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
## Gitea CI/CD
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
- `http://127.0.0.1:8000/health`
- `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job:
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
## History endpoints ## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots - `GET /requests/{id}/history?limit=10` recent snapshots
+89 -23
View File
@@ -1,13 +1,15 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any, Optional from typing import Any, Dict, Optional
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .security import safe_decode_token, TokenError, verify_password from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
def _is_expired(expires_at: str | None) -> bool: def _is_expired(expires_at: str | None) -> bool:
@@ -24,7 +26,10 @@ def _is_expired(expires_at: str | None) -> bool:
parsed = parsed.replace(tzinfo=timezone.utc) parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc) return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str: def _extract_client_ip(request: Request) -> str:
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()] parts = [part.strip() for part in forwarded.split(",") if part.strip()]
@@ -33,11 +38,67 @@ def _extract_client_ip(request: Request) -> str:
real_ip = request.headers.get("x-real-ip") real_ip = request.headers.get("x-real-ip")
if real_ip: if real_ip:
return real_ip.strip() return real_ip.strip()
if request.client and request.client.host: if direct_host:
return request.client.host return direct_host
return "unknown" return "unknown"
def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
return {
"secure": bool(settings.auth_cookie_secure),
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
"path": "/",
}
def _state_cookie_settings() -> dict[str, Any]:
cookie = _cookie_settings()
cookie["httponly"] = False
return cookie
def set_auth_cookies(response: Response, token: str) -> None:
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
response.set_cookie(
settings.auth_cookie_name,
token,
max_age=max_age,
**_cookie_settings(),
)
response.set_cookie(
settings.auth_state_cookie_name,
"1",
max_age=max_age,
**_state_cookie_settings(),
)
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
response.delete_cookie(
settings.auth_state_cookie_name,
path="/",
domain=settings.auth_cookie_domain or None,
)
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header.split(" ", 1)[1].strip()
if oauth_token:
return oauth_token
cookie_token = request.cookies.get(settings.auth_cookie_name)
if isinstance(cookie_token, str) and cookie_token.strip():
return cookie_token.strip()
return None
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str: def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict): if not isinstance(user, dict):
return "local" return "local"
@@ -108,6 +169,7 @@ def _load_current_user_from_token(
return { return {
"username": user["username"], "username": user["username"],
"email": user.get("email"),
"role": user["role"], "role": user["role"],
"auth_provider": user.get("auth_provider", "local"), "auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"), "jellyseerr_user_id": user.get("jellyseerr_user_id"),
@@ -121,24 +183,28 @@ def _load_current_user_from_token(
} }
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]: def get_current_user(
return _load_current_user_from_token(token, request) request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
def get_current_user_event_stream(request: Request) -> Dict[str, Any]: resolved_token = _extract_access_token(request, token)
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query.""" if not resolved_token:
token = None raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
stream_query_token = None return _load_current_user_from_token(resolved_token, request)
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip() def get_current_user_event_stream(
if not token: request: Request,
stream_query_token = request.query_params.get("stream_token") token: Optional[str] = Depends(oauth2_scheme),
if not token and not stream_query_token: ) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
resolved_token = _extract_access_token(request, token)
stream_query_token = request.query_params.get("stream_token")
if resolved_token:
# Allow standard bearer tokens for non-browser EventSource clients.
return _load_current_user_from_token(resolved_token, None)
if not stream_query_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
if token:
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
return _load_current_user_from_token(token, None)
return _load_current_user_from_token( return _load_current_user_from_token(
str(stream_query_token), str(stream_query_token),
None, None,
File diff suppressed because one or more lines are too long
+18
View File
@@ -34,6 +34,24 @@ class JellyseerrClient(ApiClient):
}, },
) )
async def create_request(
self,
*,
media_type: str,
media_id: int,
seasons: Optional[list[int]] = None,
is_4k: Optional[bool] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {
"mediaType": media_type,
"mediaId": media_id,
}
if isinstance(seasons, list) and seasons:
payload["seasons"] = seasons
if isinstance(is_4k, bool):
payload["is4k"] = is_4k
return await self.post("/api/v1/request", payload=payload)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]: async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get( return await self.get(
"/api/v1/user", "/api/v1/user",
+11
View File
@@ -52,6 +52,17 @@ class QBittorrentClient(ApiClient):
response = await client.post(f"{self.base_url}{path}", data=data) response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status() response.raise_for_status()
async def is_webui_reachable(self) -> bool:
if not self.base_url:
return False
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(self.base_url)
response.raise_for_status()
return True
except httpx.HTTPError:
return False
async def get_torrents(self) -> Optional[Any]: async def get_torrents(self) -> Optional[Any]:
return await self._get("/api/v2/torrents/info") return await self._get("/api/v2/torrents/info")
+50 -3
View File
@@ -9,7 +9,10 @@ class Settings(BaseSettings):
app_name: str = "Magent" app_name: str = "Magent"
cors_allow_origin: str = "http://localhost:3000" cors_allow_origin: str = "http://localhost:3000"
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH")) sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET")) sqlite_journal_mode: str = Field(
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=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED")) api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field( auth_rate_limit_window_seconds: int = Field(
@@ -21,8 +24,32 @@ class Settings(BaseSettings):
auth_rate_limit_max_attempts_user: int = Field( auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER") default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
) )
password_reset_rate_limit_window_seconds: int = Field(
default=300, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_WINDOW_SECONDS")
)
password_reset_rate_limit_max_attempts_ip: int = Field(
default=6, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
password_reset_rate_limit_max_attempts_identifier: int = Field(
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
auth_cookie_secure: bool = Field(
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
)
auth_cookie_samesite: str = Field(
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
)
auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
)
auth_state_cookie_name: str = Field(
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE")) log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field( log_file_max_bytes: int = Field(
@@ -71,6 +98,18 @@ class Settings(BaseSettings):
site_banner_tone: str = Field( site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE") default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
) )
site_login_show_jellyfin_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
)
site_login_show_local_login: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_LOCAL_LOGIN")
)
site_login_show_forgot_password: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_FORGOT_PASSWORD")
)
site_login_show_signup_link: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
)
site_changelog: Optional[str] = Field(default=CHANGELOG) site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field( magent_application_url: Optional[str] = Field(
@@ -97,6 +136,10 @@ class Settings(BaseSettings):
magent_proxy_trust_forwarded_headers: bool = Field( magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS") default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
) )
magent_proxy_trusted_proxies: str = Field(
default="127.0.0.1,::1",
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
)
magent_proxy_forwarded_prefix: Optional[str] = Field( magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX") default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
) )
@@ -192,6 +235,10 @@ class Settings(BaseSettings):
magent_notify_webhook_url: Optional[str] = Field( magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL") default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
) )
magent_allow_private_notification_targets: bool = Field(
default=False,
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
)
jellyseerr_base_url: Optional[str] = Field( jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -264,7 +311,7 @@ class Settings(BaseSettings):
) )
discord_webhook_url: Optional[str] = Field( discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt", default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"), validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
) )
+1190 -185
View File
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import settings from .config import settings
from .db import init_db from .db import has_admin_user, init_db
from .routers.requests import ( from .routers.requests import (
router as requests_router, router as requests_router,
startup_warmup_requests_cache, startup_warmup_requests_cache,
@@ -24,6 +24,7 @@ from .routers.status import router as status_router
from .routers.feedback import router as feedback_router from .routers.feedback import router as feedback_router
from .routers.site import router as site_router from .routers.site import router as site_router
from .routers.events import router as events_router from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import ( from .logging_config import (
bind_request_id, bind_request_id,
@@ -163,6 +164,34 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
_background_tasks.append(task) _background_tasks.append(task)
def _log_security_configuration_warnings() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is unset or still set to the default value"
)
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
"security configuration warning: API docs are enabled; disable API_DOCS_ENABLED outside controlled environments"
)
def _enforce_secure_startup_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.")
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError(
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
)
@app.on_event("startup") @app.on_event("startup")
async def startup() -> None: async def startup() -> None:
configure_logging( configure_logging(
@@ -174,7 +203,9 @@ async def startup() -> None:
log_background_sync_level=settings.log_background_sync_level, log_background_sync_level=settings.log_background_sync_level,
) )
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
init_db() init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings() runtime = get_runtime_settings()
configure_logging( configure_logging(
runtime.log_level, runtime.log_level,
@@ -212,3 +243,4 @@ app.include_router(status_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(site_router) app.include_router(site_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(portal_router)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import ipaddress
import socket
from functools import lru_cache
from typing import Iterable
from urllib.parse import urlparse
from .config import settings
_METADATA_HOSTS = {
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.internal",
}
def _normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _split_csv(value: object) -> list[str]:
raw = _normalize_text(value)
if not raw:
return []
return [part.strip() for part in raw.split(",") if part.strip()]
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
return bool(
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_multicast
or ip_obj.is_unspecified
or ip_obj.is_reserved
or ip_obj.is_private
)
@lru_cache(maxsize=256)
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
resolved: list[ipaddress._BaseAddress] = []
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
if family == socket.AF_INET:
resolved.append(ipaddress.ip_address(sockaddr[0]))
elif family == socket.AF_INET6:
resolved.append(ipaddress.ip_address(sockaddr[0]))
return tuple(resolved)
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
candidate = _normalize_text(host)
if not candidate:
return False
try:
host_ip = ipaddress.ip_address(candidate)
except ValueError:
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
for entry in trusted_proxies:
raw = _normalize_text(entry)
if not raw:
continue
try:
if "/" in raw:
if host_ip in ipaddress.ip_network(raw, strict=False):
return True
elif host_ip == ipaddress.ip_address(raw):
return True
except ValueError:
continue
return False
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
return False
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
if not trusted:
return False
return _is_trusted_proxy_host(client_host or "", trusted)
def validate_notification_target_url(
url: str,
*,
allow_private: bool | None = None,
) -> str:
raw = _normalize_text(url)
if not raw:
raise ValueError("URL cannot be empty.")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if parsed.username or parsed.password:
raise ValueError("URL must not embed credentials.")
hostname = _normalize_text(parsed.hostname).lower()
if not hostname:
raise ValueError("URL must include a valid host.")
allow_private_targets = (
settings.magent_allow_private_notification_targets
if allow_private is None
else bool(allow_private)
)
if hostname in _METADATA_HOSTS:
raise ValueError("Metadata service targets are not allowed.")
if hostname == "localhost" and not allow_private_targets:
raise ValueError("Local notification targets are not allowed.")
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
if host_ip is not None:
if _ip_is_sensitive(host_ip) and not allow_private_targets:
raise ValueError("Private or local notification targets are not allowed.")
return raw
try:
resolved_ips = _resolve_host_ips(hostname)
except socket.gaierror as exc:
raise ValueError("Host could not be resolved.") from exc
if not resolved_ips:
raise ValueError("Host could not be resolved.")
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
raise ValueError("Private or local notification targets are not allowed.")
return raw
+52 -7
View File
@@ -20,6 +20,7 @@ from ..auth import (
resolve_user_auth_provider, resolve_user_auth_provider,
) )
from ..config import settings as env_settings from ..config import settings as env_settings
from ..network_security import validate_notification_target_url
from ..db import ( from ..db import (
delete_setting, delete_setting,
get_all_users, get_all_users,
@@ -41,6 +42,7 @@ from ..db import (
delete_user_activity_by_username, delete_user_activity_by_username,
set_user_auto_search_enabled, set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users, set_auto_search_enabled_for_non_admin_users,
set_user_email,
set_user_invite_management_enabled, set_user_invite_management_enabled,
set_invite_management_enabled_for_non_admin_users, set_invite_management_enabled_for_non_admin_users,
set_user_profile_id, set_user_profile_id,
@@ -78,6 +80,8 @@ from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import ( from ..services.user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users, get_cached_jellyfin_users,
get_cached_jellyseerr_users, get_cached_jellyseerr_users,
match_jellyseerr_user_id, match_jellyseerr_user_id,
@@ -85,9 +89,11 @@ from ..services.user_cache import (
save_jellyseerr_users_cache, save_jellyseerr_users_cache,
clear_user_import_caches, clear_user_import_caches,
) )
from ..security import validate_password_policy
from ..services.invite_email import ( from ..services.invite_email import (
TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS, TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
get_invite_email_templates, get_invite_email_templates,
normalize_delivery_email,
reset_invite_email_template, reset_invite_email_template,
save_invite_email_template, save_invite_email_template,
send_test_email, send_test_email,
@@ -106,6 +112,16 @@ events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
def _require_recipient_email(value: object) -> str:
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(
status_code=400,
detail="recipient_email is required and must be a valid email address",
)
SENSITIVE_KEYS = { SENSITIVE_KEYS = {
"magent_ssl_certificate_pem", "magent_ssl_certificate_pem",
"magent_ssl_private_key_pem", "magent_ssl_private_key_pem",
@@ -138,6 +154,12 @@ URL_SETTING_KEYS = {
"qbittorrent_base_url", "qbittorrent_base_url",
} }
NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_discord_webhook_url",
"magent_notify_push_base_url",
"magent_notify_webhook_url",
}
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"magent_application_url", "magent_application_url",
"magent_application_port", "magent_application_port",
@@ -215,6 +237,10 @@ SETTING_KEYS: List[str] = [
"site_banner_enabled", "site_banner_enabled",
"site_banner_message", "site_banner_message",
"site_banner_tone", "site_banner_tone",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
] ]
@@ -640,6 +666,12 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
except ValueError as exc: except ValueError as exc:
friendly_key = key.replace("_", " ") friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
try:
value_to_store = validate_notification_target_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store) set_setting(key, value_to_store)
updates += 1 updates += 1
changed_keys.append(key) changed_keys.append(key)
@@ -816,8 +848,12 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
continue continue
username = user.get("username") or "" username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id) matched_id = match_jellyseerr_user_id(username, candidate_to_id)
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
if matched_id is not None: if matched_id is not None:
set_user_jellyseerr_id(username, matched_id) set_user_jellyseerr_id(username, matched_id)
if matched_email:
set_user_email(username, matched_email)
updated += 1 updated += 1
else: else:
skipped += 1 skipped += 1
@@ -854,10 +890,12 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
username = _pick_jellyseerr_username(user) username = _pick_jellyseerr_username(user)
if not username: if not username:
continue continue
email = extract_jellyseerr_user_email(user)
created = create_user_if_missing( created = create_user_if_missing(
username, username,
"jellyseerr-user", "jellyseerr-user",
role="user", role="user",
email=email,
auth_provider="jellyseerr", auth_provider="jellyseerr",
jellyseerr_user_id=user_id, jellyseerr_user_id=user_id,
) )
@@ -865,6 +903,8 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
imported += 1 imported += 1
else: else:
set_user_jellyseerr_id(username, user_id) set_user_jellyseerr_id(username, user_id)
if email:
set_user_email(username, email)
return {"status": "ok", "imported": imported, "cleared": cleared} return {"status": "ok", "imported": imported, "cleared": cleared}
@router.post("/requests/sync") @router.post("/requests/sync")
@@ -1012,6 +1052,7 @@ async def requests_all(
take: int = 50, take: int = 50,
skip: int = 0, skip: int = 0,
days: Optional[int] = None, days: Optional[int] = None,
stage: str = "all",
user: Dict[str, str] = Depends(get_current_user), user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if user.get("role") != "admin": if user.get("role") != "admin":
@@ -1021,8 +1062,9 @@ async def requests_all(
since_iso = None since_iso = None
if days is not None and int(days) > 0: if days is not None and int(days) > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat() since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso) status_codes = requests_router.request_stage_filter_codes(stage)
total = get_cached_requests_count(since_iso=since_iso) rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso, status_codes=status_codes)
total = get_cached_requests_count(since_iso=since_iso, status_codes=status_codes)
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")
@@ -1452,12 +1494,15 @@ async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
@router.post("/users/{username}/password") @router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None new_password = payload.get("password") if isinstance(payload, dict) else None
if not isinstance(new_password, str) or len(new_password.strip()) < 8: if not isinstance(new_password, str):
raise HTTPException(status_code=400, detail="Password must be at least 8 characters.") raise HTTPException(status_code=400, detail="Invalid payload")
try:
new_password_clean = validate_password_policy(new_password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = get_user_by_username(username) user = get_user_by_username(username)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
new_password_clean = new_password.strip()
user = normalize_user_auth_provider(user) user = normalize_user_auth_provider(user)
auth_provider = resolve_user_auth_provider(user) auth_provider = resolve_user_auth_provider(user)
if auth_provider == "local": if auth_provider == "local":
@@ -1769,7 +1814,7 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
if invite is None: if invite is None:
invite = _resolve_user_invite(user) invite = _resolve_user_invite(user)
recipient_email = _normalize_optional_text(payload.get("recipient_email")) recipient_email = _require_recipient_email(payload.get("recipient_email"))
message = _normalize_optional_text(payload.get("message")) message = _normalize_optional_text(payload.get("message"))
reason = _normalize_optional_text(payload.get("reason")) reason = _normalize_optional_text(payload.get("reason"))
@@ -1819,7 +1864,7 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
role = _normalize_role_or_none(payload.get("role")) role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at")) expires_at = _parse_optional_expires_at(payload.get("expires_at"))
recipient_email = _normalize_optional_text(payload.get("recipient_email")) recipient_email = _require_recipient_email(payload.get("recipient_email"))
send_email = bool(payload.get("send_email")) send_email = bool(payload.get("send_email"))
delivery_message = _normalize_optional_text(payload.get("message")) delivery_message = _normalize_optional_text(payload.get("message"))
try: try:
+204 -62
View File
@@ -7,7 +7,7 @@ import time
from threading import Lock from threading import Lock
import httpx import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from ..db import ( from ..db import (
@@ -19,6 +19,7 @@ from ..db import (
get_users_by_username_ci, get_users_by_username_ci,
set_user_password, set_user_password,
set_user_jellyseerr_id, set_user_jellyseerr_id,
set_user_email,
set_user_auth_provider, set_user_auth_provider,
get_signup_invite_by_code, get_signup_invite_by_code,
get_signup_invite_by_id, get_signup_invite_by_id,
@@ -39,17 +40,35 @@ from ..db import (
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password from ..security import (
PASSWORD_POLICY_MESSAGE,
create_access_token,
validate_password_policy,
verify_password,
)
from ..security import create_stream_token from ..security import create_stream_token
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider from ..auth import (
clear_auth_cookies,
get_current_user,
normalize_user_auth_provider,
resolve_user_auth_provider,
set_auth_cookies,
)
from ..config import settings from ..config import settings
from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import ( from ..services.user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users, get_cached_jellyseerr_users,
match_jellyseerr_user_id, match_jellyseerr_user_id,
save_jellyfin_users_cache, save_jellyfin_users_cache,
) )
from ..services.invite_email import send_templated_email, smtp_email_config_ready from ..services.invite_email import (
normalize_delivery_email,
send_templated_email,
smtp_email_config_ready,
)
from ..services.password_reset import ( from ..services.password_reset import (
PasswordResetUnavailableError, PasswordResetUnavailableError,
apply_password_reset, apply_password_reset,
@@ -68,9 +87,24 @@ PASSWORD_RESET_GENERIC_MESSAGE = (
_LOGIN_RATE_LOCK = Lock() _LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque) _LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: 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:
return normalized
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="recipient_email is required and must be a valid email address.",
)
def _auth_client_ip(request: Request) -> str: def _auth_client_ip(request: Request) -> str:
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip(): if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip() return forwarded.split(",", 1)[0].strip()
@@ -86,6 +120,10 @@ def _login_rate_key_user(username: str) -> str:
return (username or "").strip().lower()[:256] or "<empty>" return (username or "").strip().lower()[:256] or "<empty>"
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: def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds cutoff = now - window_seconds
while bucket and bucket[0] < cutoff: while bucket and bucket[0] < cutoff:
@@ -171,6 +209,57 @@ 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)
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)
if exceeded:
logger.warning(
"password reset rate limit exceeded identifier=%s client=%s retry_after=%s",
identifier_key,
ip_key,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many password reset attempts. Try again shortly.",
headers={"Retry-After": str(retry_after)},
)
def _normalize_username(value: str) -> str: def _normalize_username(value: str) -> str:
normalized = value.strip().lower() normalized = value.strip().lower()
if "@" in normalized: if "@" in normalized:
@@ -219,6 +308,13 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None return None
def _extract_jellyseerr_response_email(response: dict) -> str | None:
if not isinstance(response, dict):
return None
user_payload = response.get("user") if isinstance(response.get("user"), dict) else response
return extract_jellyseerr_user_email(user_payload)
def _extract_http_error_detail(exc: Exception) -> str: def _extract_http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError): if isinstance(exc, httpx.HTTPStatusError):
response = exc.response response = exc.response
@@ -271,6 +367,15 @@ def _assert_user_can_login(user: dict | None) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
set_auth_cookies(response, token)
return {
"authenticated": True,
"token_type": "cookie",
"user": user_payload,
}
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return { return {
"code": invite.get("code"), "code": invite.get("code"),
@@ -493,7 +598,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login") @router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username) _enforce_login_rate_limit(request, form_data.username)
logger.info( logger.info(
"login attempt provider=local username=%s client=%s", "login attempt provider=local username=%s client=%s",
@@ -542,15 +651,19 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
user["role"], user["role"],
_auth_client_ip(request), _auth_client_ip(request),
) )
return { return _auth_success_response(
"access_token": token, response,
"token_type": "bearer", token,
"user": {"username": user["username"], "role": user["role"]}, {"username": user["username"], "role": user["role"]},
} )
@router.post("/jellyfin/login") @router.post("/jellyfin/login")
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def jellyfin_login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username) _enforce_login_rate_limit(request, form_data.username)
logger.info( logger.info(
"login attempt provider=jellyfin username=%s client=%s", "login attempt provider=jellyfin username=%s client=%s",
@@ -569,6 +682,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
preferred_match = _pick_preferred_ci_user_match(ci_matches, username) preferred_match = _pick_preferred_ci_user_match(ci_matches, username)
canonical_username = str(preferred_match.get("username") or username) if preferred_match else username canonical_username = str(preferred_match.get("username") or username) if preferred_match else username
user = preferred_match or get_user_by_username(username) user = preferred_match or get_user_by_username(username)
matched_seerr_user = find_matching_jellyseerr_user(canonical_username, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
_assert_user_can_login(user) _assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password): if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(canonical_username, "user") token = create_access_token(canonical_username, "user")
@@ -579,13 +694,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
canonical_username, canonical_username,
_auth_client_ip(request), _auth_client_ip(request),
) )
return { return _auth_success_response(
"access_token": token, response,
"token_type": "bearer", token,
"user": {"username": canonical_username, "role": "user"}, {"username": canonical_username, "role": "user"},
} )
try: try:
response = await client.authenticate_by_name(username, password) auth_response = await client.authenticate_by_name(username, password)
except Exception as exc: except Exception as exc:
logger.exception( logger.exception(
"login upstream error provider=jellyfin username=%s client=%s", "login upstream error provider=jellyfin username=%s client=%s",
@@ -593,11 +708,17 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
_auth_client_ip(request), _auth_client_ip(request),
) )
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"): if not isinstance(auth_response, dict) or not auth_response.get("User"):
_record_login_failure(request, username) _record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
if not preferred_match: if not preferred_match:
create_user_if_missing(canonical_username, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(
canonical_username,
"jellyfin-user",
role="user",
email=matched_email,
auth_provider="jellyfin",
)
elif ( elif (
user user
and str(user.get("role") or "user").strip().lower() != "admin" and str(user.get("role") or "user").strip().lower() != "admin"
@@ -605,6 +726,8 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
): ):
set_user_auth_provider(canonical_username, "jellyfin") set_user_auth_provider(canonical_username, "jellyfin")
user = get_user_by_username(canonical_username) user = get_user_by_username(canonical_username)
if matched_email:
set_user_email(canonical_username, matched_email)
user = get_user_by_username(canonical_username) user = get_user_by_username(canonical_username)
_assert_user_can_login(user) _assert_user_can_login(user)
try: try:
@@ -627,16 +750,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None, get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
_auth_client_ip(request), _auth_client_ip(request),
) )
return { return _auth_success_response(
"access_token": token, response,
"token_type": "bearer", token,
"user": {"username": canonical_username, "role": "user"}, {"username": canonical_username, "role": "user"},
} )
@router.post("/seerr/login") @router.post("/seerr/login")
@router.post("/jellyseerr/login") @router.post("/jellyseerr/login")
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def jellyseerr_login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username) _enforce_login_rate_limit(request, form_data.username)
logger.info( logger.info(
"login attempt provider=seerr username=%s client=%s", "login attempt provider=seerr username=%s client=%s",
@@ -648,7 +775,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
if not client.configured(): if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
try: try:
response = await client.login_local(form_data.username, form_data.password) auth_response = await client.login_local(form_data.username, form_data.password)
except Exception as exc: except Exception as exc:
logger.exception( logger.exception(
"login upstream error provider=seerr username=%s client=%s", "login upstream error provider=seerr username=%s client=%s",
@@ -656,10 +783,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
_auth_client_ip(request), _auth_client_ip(request),
) )
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict): if not isinstance(auth_response, dict):
_record_login_failure(request, form_data.username) _record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response) jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
ci_matches = get_users_by_username_ci(form_data.username) ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username) preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
@@ -668,13 +796,22 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
canonical_username, canonical_username,
"jellyseerr-user", "jellyseerr-user",
role="user", role="user",
email=jellyseerr_email,
auth_provider="jellyseerr", auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id, jellyseerr_user_id=jellyseerr_user_id,
) )
elif (
preferred_match
and str(preferred_match.get("role") or "user").strip().lower() != "admin"
and str(preferred_match.get("auth_provider") or "local").strip().lower() not in {"jellyfin", "jellyseerr"}
):
set_user_auth_provider(canonical_username, "jellyseerr")
user = get_user_by_username(canonical_username) user = get_user_by_username(canonical_username)
_assert_user_can_login(user) _assert_user_can_login(user)
if jellyseerr_user_id is not None: if jellyseerr_user_id is not None:
set_user_jellyseerr_id(canonical_username, jellyseerr_user_id) 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") token = create_access_token(canonical_username, "user")
_clear_login_failures(request, form_data.username) _clear_login_failures(request, form_data.username)
set_last_login(canonical_username) set_last_login(canonical_username)
@@ -684,11 +821,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
jellyseerr_user_id, jellyseerr_user_id,
_auth_client_ip(request), _auth_client_ip(request),
) )
return { return _auth_success_response(
"access_token": token, response,
"token_type": "bearer", token,
"user": {"username": canonical_username, "role": "user"}, {"username": canonical_username, "role": "user"},
} )
@router.get("/me") @router.get("/me")
@@ -696,6 +833,12 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user return current_user
@router.post("/logout")
async def logout(response: Response) -> dict:
clear_auth_cookies(response)
return {"status": "ok"}
@router.get("/stream-token") @router.get("/stream-token")
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict: async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
token = create_stream_token( token = create_stream_token(
@@ -725,7 +868,7 @@ async def invite_details(code: str) -> dict:
@router.post("/signup") @router.post("/signup")
async def signup(payload: dict) -> dict: async def signup(payload: dict, response: Response) -> dict:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip() invite_code = str(payload.get("invite_code") or "").strip()
@@ -735,11 +878,10 @@ async def signup(payload: dict) -> dict:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username: if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8: try:
raise HTTPException( password_value = validate_password_policy(password)
status_code=status.HTTP_400_BAD_REQUEST, except ValueError as exc:
detail="Password must be at least 8 characters.", raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
)
if get_user_by_username(username): if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info( logger.info(
@@ -786,7 +928,6 @@ async def signup(payload: dict) -> dict:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
runtime = get_runtime_settings() runtime = get_runtime_settings()
password_value = password.strip()
auth_provider = "local" auth_provider = "local"
local_password_value = password_value local_password_value = password_value
matched_jellyseerr_user_id: int | None = None matched_jellyseerr_user_id: int | None = None
@@ -803,14 +944,14 @@ async def signup(payload: dict) -> dict:
duplicate_like = status_code in {400, 409} duplicate_like = status_code in {400, 409}
if duplicate_like: if duplicate_like:
try: try:
response = await jellyfin_client.authenticate_by_name(username, password_value) auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc: except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc) detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}", detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc ) from exc
if not isinstance(response, dict) or not response.get("User"): if not isinstance(auth_response, dict) or not auth_response.get("User"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.", detail="Jellyfin account already exists for that username.",
@@ -839,6 +980,7 @@ async def signup(payload: dict) -> dict:
username, username,
local_password_value, local_password_value,
role=role, role=role,
email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None,
auth_provider=auth_provider, auth_provider=auth_provider,
jellyseerr_user_id=matched_jellyseerr_user_id, jellyseerr_user_id=matched_jellyseerr_user_id,
auto_search_enabled=auto_search_enabled, auto_search_enabled=auto_search_enabled,
@@ -881,17 +1023,17 @@ async def signup(payload: dict) -> dict:
created_user.get("profile_id") if created_user else None, created_user.get("profile_id") if created_user else None,
invite.get("code"), invite.get("code"),
) )
return { return _auth_success_response(
"access_token": token, response,
"token_type": "bearer", token,
"user": { {
"username": username, "username": username,
"role": role, "role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider, "auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None, "profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None, "expires_at": created_user.get("expires_at") if created_user else None,
}, },
} )
@router.post("/password/forgot") @router.post("/password/forgot")
@@ -901,6 +1043,8 @@ async def forgot_password(payload: dict, request: Request) -> dict:
identifier = payload.get("identifier") or payload.get("username") or payload.get("email") identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
if not isinstance(identifier, str) or not identifier.strip(): if not isinstance(identifier, str) or not identifier.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
_enforce_password_reset_rate_limit(request, identifier)
_record_password_reset_attempt(request, identifier)
ready, detail = smtp_email_config_ready() ready, detail = smtp_email_config_ready()
if not ready: if not ready:
@@ -960,14 +1104,15 @@ async def password_reset(payload: dict) -> dict:
new_password = payload.get("new_password") new_password = payload.get("new_password")
if not isinstance(token, str) or not token.strip(): if not isinstance(token, str) or not token.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
if not isinstance(new_password, str) or len(new_password.strip()) < 8: if not isinstance(new_password, str):
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=PASSWORD_POLICY_MESSAGE)
status_code=status.HTTP_400_BAD_REQUEST, try:
detail="Password must be at least 8 characters.", new_password_clean = validate_password_policy(new_password)
) except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
try: try:
result = await apply_password_reset(token.strip(), new_password.strip()) result = await apply_password_reset(token.strip(), new_password_clean)
except PasswordResetUnavailableError as exc: except PasswordResetUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
@@ -1065,8 +1210,7 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
label = str(label).strip() or None label = str(label).strip() or None
if description is not None: if description is not None:
description = str(description).strip() or None description = str(description).strip() or None
if recipient_email is not None: recipient_email = _require_recipient_email(recipient_email)
recipient_email = str(recipient_email).strip() or None
send_email = bool(payload.get("send_email")) send_email = bool(payload.get("send_email"))
delivery_message = str(payload.get("message") or "").strip() or None delivery_message = str(payload.get("message") or "").strip() or None
@@ -1156,8 +1300,7 @@ async def update_profile_invite(
label = str(label).strip() or None label = str(label).strip() or None
if description is not None: if description is not None:
description = str(description).strip() or None description = str(description).strip() or None
if recipient_email is not None: recipient_email = _require_recipient_email(recipient_email)
recipient_email = str(recipient_email).strip() or None
send_email = bool(payload.get("send_email")) send_email = bool(payload.get("send_email"))
delivery_message = str(payload.get("message") or "").strip() or None delivery_message = str(payload.get("message") or "").strip() or None
@@ -1232,14 +1375,13 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
new_password = payload.get("new_password") if isinstance(payload, dict) else None new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str): if not isinstance(current_password, str) or not isinstance(new_password, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
if len(new_password.strip()) < 8: try:
raise HTTPException( new_password_clean = validate_password_policy(new_password)
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters." except ValueError as exc:
) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
username = str(current_user.get("username") or "").strip() username = str(current_user.get("username") or "").strip()
if not username: if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
new_password_clean = new_password.strip()
stored_user = normalize_user_auth_provider(get_user_by_username(username)) stored_user = normalize_user_auth_provider(get_user_by_username(username))
auth_provider = resolve_user_auth_provider(stored_user or current_user) auth_provider = resolve_user_auth_provider(stored_user or current_user)
logger.info("password change requested username=%s provider=%s", username, auth_provider) logger.info("password change requested username=%s provider=%s", username, auth_provider)
+4
View File
@@ -76,6 +76,7 @@ def _request_actions_brief(entries: Any) -> list[dict[str, Any]]:
async def events_stream( async def events_stream(
request: Request, request: Request,
recent_days: int = 90, recent_days: int = 90,
recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream), user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse: ) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650)) recent_days = max(0, min(int(recent_days or 90), 3650))
@@ -103,6 +104,7 @@ async def events_stream(
take=recent_take, take=recent_take,
skip=0, skip=0,
days=recent_days, days=recent_days,
stage=recent_stage,
user=user, user=user,
) )
results = recent_payload.get("results") if isinstance(recent_payload, dict) else [] results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
@@ -110,6 +112,7 @@ async def events_stream(
"type": "home_recent", "type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(), "ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days, "days": recent_days,
"stage": recent_stage,
"results": results if isinstance(results, list) else [], "results": results if isinstance(results, list) else [],
} }
except Exception as exc: except Exception as exc:
@@ -117,6 +120,7 @@ async def events_stream(
"type": "home_recent", "type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(), "ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days, "days": recent_days,
"stage": recent_stage,
"error": str(exc), "error": str(exc),
} }
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str) signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
+5
View File
@@ -3,6 +3,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user from ..auth import get_current_user
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@@ -17,6 +18,10 @@ async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(
) )
if not webhook_url: if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured") raise HTTPException(status_code=400, detail="Discord webhook not configured")
try:
webhook_url = validate_notification_target_url(webhook_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
feedback_type = str(payload.get("type") or "").strip().lower() feedback_type = str(payload.get("type") or "").strip().lower()
if feedback_type not in {"bug", "feature"}: if feedback_type not in {"bug", "feature"}:
File diff suppressed because it is too large Load Diff
+368 -125
View File
@@ -26,7 +26,7 @@ from ..db import (
get_cached_requests, get_cached_requests,
get_cached_requests_since, get_cached_requests_since,
get_cached_request_by_media_id, get_cached_request_by_media_id,
get_request_cache_by_id, get_request_cache_lookup,
get_request_cache_payload, get_request_cache_payload,
get_request_cache_last_updated, get_request_cache_last_updated,
get_request_cache_count, get_request_cache_count,
@@ -35,7 +35,9 @@ from ..db import (
repair_request_cache_titles, repair_request_cache_titles,
prune_duplicate_requests_cache, prune_duplicate_requests_cache,
upsert_request_cache, upsert_request_cache,
upsert_request_cache_many,
upsert_artwork_cache_status, upsert_artwork_cache_status,
upsert_artwork_cache_status_many,
get_artwork_cache_missing_count, get_artwork_cache_missing_count,
get_artwork_cache_status_count, get_artwork_cache_status_count,
get_setting, get_setting,
@@ -47,7 +49,7 @@ from ..db import (
clear_seerr_media_failure, clear_seerr_media_failure,
) )
from ..models import Snapshot, TriageResult, RequestType from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import build_snapshot from ..services.snapshot import build_snapshot, jellyfin_item_matches_request
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
@@ -91,6 +93,17 @@ STATUS_LABELS = {
6: "Partially ready", 6: "Partially ready",
} }
REQUEST_STAGE_CODES = {
"all": None,
"pending": [1],
"approved": [2],
"declined": [3],
"ready": [4],
"working": [5],
"partial": [6],
"in_progress": [2, 5, 6],
}
def _cache_get(key: str) -> Optional[Dict[str, Any]]: def _cache_get(key: str) -> Optional[Dict[str, Any]]:
cached = _detail_cache.get(key) cached = _detail_cache.get(key)
@@ -108,6 +121,57 @@ def _cache_set(key: str, payload: Dict[str, Any]) -> None:
_failed_detail_cache.pop(key, None) _failed_detail_cache.pop(key, None)
def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
if not jellyfin_available:
return _status_label(current_status)
try:
status_code = int(current_status)
except (TypeError, ValueError):
status_code = None
if status_code == 6:
return STATUS_LABELS[6]
return STATUS_LABELS[4]
async def _request_is_available_in_jellyfin(
jellyfin: JellyfinClient,
title: Optional[str],
year: Optional[int],
media_type: Optional[str],
request_payload: Optional[Dict[str, Any]],
availability_cache: Dict[str, bool],
) -> bool:
if not jellyfin.configured() or not title:
return False
cache_key = f"{media_type or ''}:{title.lower()}:{year or ''}:{request_payload.get('id') if isinstance(request_payload, dict) else ''}"
cached_value = availability_cache.get(cache_key)
if cached_value is not None:
return cached_value
types = ["Movie"] if media_type == "movie" else ["Series"]
try:
search = await jellyfin.search_items(title, types, limit=50)
except Exception:
availability_cache[cache_key] = False
return False
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
request_type = RequestType.movie if media_type == "movie" else RequestType.tv
for item in items:
if not isinstance(item, dict):
continue
if jellyfin_item_matches_request(
item,
title=title,
year=year,
request_type=request_type,
request_payload=request_payload,
):
availability_cache[cache_key] = True
return True
availability_cache[cache_key] = False
return False
def _failure_cache_has(key: str) -> bool: def _failure_cache_has(key: str) -> bool:
expires_at = _failed_detail_cache.get(key) expires_at = _failed_detail_cache.get(key)
if not expires_at: if not expires_at:
@@ -152,6 +216,23 @@ def _status_label(value: Any) -> str:
return "Unknown" return "Unknown"
def normalize_request_stage_filter(value: Optional[str]) -> str:
if not isinstance(value, str):
return "all"
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
if not normalized:
return "all"
if normalized in {"processing", "inprogress"}:
normalized = "in_progress"
return normalized if normalized in REQUEST_STAGE_CODES else "all"
def request_stage_filter_codes(value: Optional[str]) -> Optional[list[int]]:
normalized = normalize_request_stage_filter(value)
codes = REQUEST_STAGE_CODES.get(normalized)
return list(codes) if codes else None
def _normalize_username(value: Any) -> Optional[str]: def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str): if not isinstance(value, str):
return None return None
@@ -340,6 +421,34 @@ def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Option
return tmdb_id, media_type return tmdb_id, media_type
def _normalize_media_type(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip().lower()
if normalized in {"movie", "tv"}:
return normalized
return None
def _normalize_seasons(value: Any) -> list[int]:
if value is None:
return []
if not isinstance(value, list):
raise HTTPException(status_code=400, detail="seasons must be an array of positive integers")
normalized: list[int] = []
for raw in value:
try:
season = int(raw)
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=400, detail="seasons must contain only positive integers"
) from exc
if season <= 0:
raise HTTPException(status_code=400, detail="seasons must contain only positive integers")
normalized.append(season)
return sorted(set(normalized))
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool: def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
poster_path, backdrop_path = _extract_artwork_paths(payload) poster_path, backdrop_path = _extract_artwork_paths(payload)
tmdb_id, media_type = _extract_tmdb_lookup(payload) tmdb_id, media_type = _extract_tmdb_lookup(payload)
@@ -383,26 +492,55 @@ def _upsert_artwork_status(
poster_cached: Optional[bool] = None, poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None, backdrop_cached: Optional[bool] = None,
) -> None: ) -> None:
record = _build_artwork_status_record(payload, cache_mode, poster_cached, backdrop_cached)
if not record:
return
upsert_artwork_cache_status(**record)
def _build_request_cache_record(payload: Dict[str, Any], request_payload: Dict[str, Any]) -> Dict[str, Any]:
return {
"request_id": payload.get("request_id"),
"media_id": payload.get("media_id"),
"media_type": payload.get("media_type"),
"status": payload.get("status"),
"title": payload.get("title"),
"year": payload.get("year"),
"requested_by": payload.get("requested_by"),
"requested_by_norm": payload.get("requested_by_norm"),
"requested_by_id": payload.get("requested_by_id"),
"created_at": payload.get("created_at"),
"updated_at": payload.get("updated_at"),
"payload_json": json.dumps(request_payload, ensure_ascii=True),
}
def _build_artwork_status_record(
payload: Dict[str, Any],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> Optional[Dict[str, Any]]:
parsed = _parse_request_payload(payload) parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id") request_id = parsed.get("request_id")
if not isinstance(request_id, int): if not isinstance(request_id, int):
return return None
tmdb_id, media_type = _extract_tmdb_lookup(payload) tmdb_id, media_type = _extract_tmdb_lookup(payload)
poster_path, backdrop_path = _extract_artwork_paths(payload) poster_path, backdrop_path = _extract_artwork_paths(payload)
has_tmdb = bool(tmdb_id and media_type) has_tmdb = bool(tmdb_id and media_type)
poster_cached_flag, backdrop_cached_flag = _compute_cached_flags( poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
) )
upsert_artwork_cache_status( return {
request_id=request_id, "request_id": request_id,
tmdb_id=tmdb_id, "tmdb_id": tmdb_id,
media_type=media_type, "media_type": media_type,
poster_path=poster_path, "poster_path": poster_path,
backdrop_path=backdrop_path, "backdrop_path": backdrop_path,
has_tmdb=has_tmdb, "has_tmdb": has_tmdb,
poster_cached=poster_cached_flag, "poster_cached": poster_cached_flag,
backdrop_cached=backdrop_cached_flag, "backdrop_cached": backdrop_cached_flag,
) }
def _collect_artwork_cache_disk_stats() -> tuple[int, int]: def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
@@ -603,6 +741,16 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
if not isinstance(items, list) or not items: if not isinstance(items, list) or not items:
logger.info("Seerr sync completed: no more results at skip=%s", skip) logger.info("Seerr sync completed: no more results at skip=%s", skip)
break break
page_request_ids = [
payload.get("request_id")
for item in items
if isinstance(item, dict)
for payload in [_parse_request_payload(item)]
if isinstance(payload.get("request_id"), int)
]
cached_by_request_id = get_request_cache_lookup(page_request_ids)
page_cache_records: list[Dict[str, Any]] = []
page_artwork_records: list[Dict[str, Any]] = []
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
@@ -610,9 +758,8 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
request_id = payload.get("request_id") request_id = payload.get("request_id")
cached_title = None cached_title = None
if isinstance(request_id, int): if isinstance(request_id, int):
if not payload.get("title"): cached = cached_by_request_id.get(request_id)
cached = get_request_cache_by_id(request_id) if not payload.get("title") and cached and cached.get("title"):
if cached and cached.get("title"):
cached_title = cached.get("title") cached_title = cached.get("title")
needs_details = ( needs_details = (
not payload.get("title") not payload.get("title")
@@ -644,25 +791,17 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
payload["title"] = cached_title payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int): if not isinstance(payload.get("request_id"), int):
continue continue
payload_json = json.dumps(item, ensure_ascii=True) page_cache_records.append(_build_request_cache_record(payload, item))
upsert_request_cache(
request_id=payload.get("request_id"),
media_id=payload.get("media_id"),
media_type=payload.get("media_type"),
status=payload.get("status"),
title=payload.get("title"),
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
)
if isinstance(item, dict): if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode) artwork_record = _build_artwork_status_record(item, cache_mode)
if artwork_record:
page_artwork_records.append(artwork_record)
stored += 1 stored += 1
_sync_state["stored"] = stored _sync_state["stored"] = stored
if page_cache_records:
upsert_request_cache_many(page_cache_records)
if page_artwork_records:
upsert_artwork_cache_status_many(page_artwork_records)
if len(items) < take: if len(items) < take:
logger.info("Seerr sync completed: stored=%s", stored) logger.info("Seerr sync completed: stored=%s", stored)
break break
@@ -721,6 +860,16 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
if not isinstance(items, list) or not items: if not isinstance(items, list) or not items:
logger.info("Seerr delta sync completed: no more results at skip=%s", skip) logger.info("Seerr delta sync completed: no more results at skip=%s", skip)
break break
page_request_ids = [
payload.get("request_id")
for item in items
if isinstance(item, dict)
for payload in [_parse_request_payload(item)]
if isinstance(payload.get("request_id"), int)
]
cached_by_request_id = get_request_cache_lookup(page_request_ids)
page_cache_records: list[Dict[str, Any]] = []
page_artwork_records: list[Dict[str, Any]] = []
page_changed = False page_changed = False
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -728,7 +877,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload = _parse_request_payload(item) payload = _parse_request_payload(item)
request_id = payload.get("request_id") request_id = payload.get("request_id")
if isinstance(request_id, int): if isinstance(request_id, int):
cached = get_request_cache_by_id(request_id) cached = cached_by_request_id.get(request_id)
incoming_updated = payload.get("updated_at") incoming_updated = payload.get("updated_at")
cached_title = cached.get("title") if cached else None cached_title = cached.get("title") if cached else None
if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"): if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"):
@@ -762,26 +911,18 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
payload["title"] = cached_title payload["title"] = cached_title
if not isinstance(payload.get("request_id"), int): if not isinstance(payload.get("request_id"), int):
continue continue
payload_json = json.dumps(item, ensure_ascii=True) page_cache_records.append(_build_request_cache_record(payload, item))
upsert_request_cache(
request_id=payload.get("request_id"),
media_id=payload.get("media_id"),
media_type=payload.get("media_type"),
status=payload.get("status"),
title=payload.get("title"),
year=payload.get("year"),
requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"),
payload_json=payload_json,
)
if isinstance(item, dict): if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode) artwork_record = _build_artwork_status_record(item, cache_mode)
if artwork_record:
page_artwork_records.append(artwork_record)
stored += 1 stored += 1
page_changed = True page_changed = True
_sync_state["stored"] = stored _sync_state["stored"] = stored
if page_cache_records:
upsert_request_cache_many(page_cache_records)
if page_artwork_records:
upsert_artwork_cache_status_many(page_artwork_records)
if not page_changed: if not page_changed:
unchanged_pages += 1 unchanged_pages += 1
else: else:
@@ -866,6 +1007,8 @@ async def _prefetch_artwork_cache(
batch = get_request_cache_payloads(limit=limit, offset=offset) batch = get_request_cache_payloads(limit=limit, offset=offset)
if not batch: if not batch:
break break
page_cache_records: list[Dict[str, Any]] = []
page_artwork_records: list[Dict[str, Any]] = []
for row in batch: for row in batch:
payload = row.get("payload") payload = row.get("payload")
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -893,20 +1036,7 @@ async def _prefetch_artwork_cache(
parsed = _parse_request_payload(payload) parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id") request_id = parsed.get("request_id")
if isinstance(request_id, int): if isinstance(request_id, int):
upsert_request_cache( page_cache_records.append(_build_request_cache_record(parsed, payload))
request_id=request_id,
media_id=parsed.get("media_id"),
media_type=parsed.get("media_type"),
status=parsed.get("status"),
title=parsed.get("title"),
year=parsed.get("year"),
requested_by=parsed.get("requested_by"),
requested_by_norm=parsed.get("requested_by_norm"),
requested_by_id=parsed.get("requested_by_id"),
created_at=parsed.get("created_at"),
updated_at=parsed.get("updated_at"),
payload_json=json.dumps(payload, ensure_ascii=True),
)
poster_cached_flag = False poster_cached_flag = False
backdrop_cached_flag = False backdrop_cached_flag = False
if poster_path: if poster_path:
@@ -921,17 +1051,23 @@ async def _prefetch_artwork_cache(
backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780")) backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
except httpx.HTTPError: except httpx.HTTPError:
backdrop_cached_flag = False backdrop_cached_flag = False
_upsert_artwork_status( artwork_record = _build_artwork_status_record(
payload, payload,
cache_mode, cache_mode,
poster_cached=poster_cached_flag if poster_path else None, poster_cached=poster_cached_flag if poster_path else None,
backdrop_cached=backdrop_cached_flag if backdrop_path else None, backdrop_cached=backdrop_cached_flag if backdrop_path else None,
) )
if artwork_record:
page_artwork_records.append(artwork_record)
processed += 1 processed += 1
if processed % 25 == 0: if processed % 25 == 0:
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{"processed": processed, "message": f"Cached artwork for {processed} requests"} {"processed": processed, "message": f"Cached artwork for {processed} requests"}
) )
if page_cache_records:
upsert_request_cache_many(page_cache_records)
if page_artwork_records:
upsert_artwork_cache_status_many(page_artwork_records)
offset += limit offset += limit
total_requests = get_request_cache_count() total_requests = get_request_cache_count()
@@ -1063,6 +1199,7 @@ def _get_recent_from_cache(
limit: int, limit: int,
offset: int, offset: int,
since_iso: Optional[str], since_iso: Optional[str],
status_codes: Optional[list[int]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or [] items = _recent_cache.get("items") or []
results = [] results = []
@@ -1078,6 +1215,8 @@ def _get_recent_from_cache(
item_dt = _parse_iso_datetime(candidate) item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt: if not item_dt or item_dt < since_dt:
continue continue
if status_codes and item.get("status") not in status_codes:
continue
results.append(item) results.append(item)
return results[offset : offset + limit] return results[offset : offset + limit]
@@ -1235,22 +1374,8 @@ def get_requests_sync_state() -> Dict[str, Any]:
async def _ensure_request_access( async def _ensure_request_access(
client: JellyseerrClient, request_id: int, user: Dict[str, str] client: JellyseerrClient, request_id: int, user: Dict[str, str]
) -> None: ) -> None:
if user.get("role") == "admin": if user.get("role") == "admin" or user.get("username"):
return return
runtime = get_runtime_settings()
mode = (runtime.requests_data_source or "prefer_cache").lower()
cached = get_request_cache_payload(request_id)
if mode != "always_js":
if cached is None:
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
raise HTTPException(status_code=404, detail="Request not found in cache")
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
if _request_matches_user(cached, user.get("username", "")):
return
raise HTTPException(status_code=403, detail="Request not accessible for this user")
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
details = await _get_request_details(client, request_id)
if details is None or not _request_matches_user(details, user.get("username", "")):
raise HTTPException(status_code=403, detail="Request not accessible for this user") raise HTTPException(status_code=403, detail="Request not accessible for this user")
@@ -1521,6 +1646,7 @@ async def recent_requests(
take: int = 6, take: int = 6,
skip: int = 0, skip: int = 0,
days: int = 90, days: int = 90,
stage: str = "all",
user: Dict[str, str] = Depends(get_current_user), user: Dict[str, str] = Depends(get_current_user),
) -> dict: ) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -1542,44 +1668,22 @@ async def recent_requests(
since_iso = None since_iso = None
if days > 0: if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
status_codes = request_stage_filter_codes(stage)
if _recent_cache_stale(): if _recent_cache_stale():
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, requested_by_id, take, skip, since_iso) rows = _get_recent_from_cache(
requested_by,
requested_by_id,
take,
skip,
since_iso,
status_codes=status_codes,
)
cache_mode = (runtime.artwork_cache_mode or "remote").lower() cache_mode = (runtime.artwork_cache_mode or "remote").lower()
allow_title_hydrate = False allow_title_hydrate = False
allow_artwork_hydrate = client.configured() allow_artwork_hydrate = client.configured()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {} jellyfin_cache: Dict[str, bool] = {}
async def _jellyfin_available(
title_value: Optional[str], year_value: Optional[int], media_type_value: Optional[str]
) -> bool:
if not jellyfin.configured() or not title_value:
return False
cache_key = f"{media_type_value or ''}:{title_value.lower()}:{year_value or ''}"
cached_value = jellyfin_cache.get(cache_key)
if cached_value is not None:
return cached_value
types = ["Movie"] if media_type_value == "movie" else ["Series"]
try:
search = await jellyfin.search_items(title_value, types)
except Exception:
jellyfin_cache[cache_key] = False
return False
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
for item in items:
if not isinstance(item, dict):
continue
name = item.get("Name") or item.get("title")
year = item.get("ProductionYear") or item.get("Year")
if name and name.strip().lower() == title_value.strip().lower():
if year_value and year and int(year) != int(year_value):
continue
jellyfin_cache[cache_key] = True
return True
jellyfin_cache[cache_key] = False
return False
results = [] results = []
for row in rows: for row in rows:
status = row.get("status") status = row.get("status")
@@ -1674,10 +1778,16 @@ async def recent_requests(
payload_json=json.dumps(details, ensure_ascii=True), payload_json=json.dumps(details, ensure_ascii=True),
) )
status_label = _status_label(status) status_label = _status_label(status)
if status_label == "Working on it": if status_label in {"Working on it", "Ready to watch", "Partially ready"}:
is_available = await _jellyfin_available(title, year, row.get("media_type")) is_available = await _request_is_available_in_jellyfin(
if is_available: jellyfin,
status_label = "Available" title,
year,
row.get("media_type"),
details if isinstance(details, dict) else None,
jellyfin_cache,
)
status_label = _status_label_with_jellyfin(status, is_available)
results.append( results.append(
{ {
"id": row.get("request_id"), "id": row.get("request_id"),
@@ -1721,6 +1831,8 @@ async def search_requests(
pass pass
results = [] results = []
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
for item in response.get("results", []): for item in response.get("results", []):
media_type = item.get("mediaType") media_type = item.get("mediaType")
title = item.get("title") or item.get("name") title = item.get("title") or item.get("name")
@@ -1733,6 +1845,8 @@ async def search_requests(
request_id = None request_id = None
status = None status = None
status_label = None status_label = None
requested_by = None
accessible = False
media_info = item.get("mediaInfo") or {} media_info = item.get("mediaInfo") or {}
media_info_id = media_info.get("id") media_info_id = media_info.get("id")
requests = media_info.get("requests") requests = media_info.get("requests")
@@ -1741,27 +1855,31 @@ async def search_requests(
status = requests[0].get("status") status = requests[0].get("status")
status_label = _status_label(status) status_label = _status_label(status)
elif isinstance(media_info_id, int): elif isinstance(media_info_id, int):
username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm
requested_by_id = None if user.get("role") == "admin" else requested_by_id
cached = get_cached_request_by_media_id( cached = get_cached_request_by_media_id(
media_info_id, media_info_id,
requested_by_norm=requested_by,
requested_by_id=requested_by_id,
) )
if cached: if cached:
request_id = cached.get("request_id") request_id = cached.get("request_id")
status = cached.get("status") status = cached.get("status")
status_label = _status_label(status) status_label = _status_label(status)
if user.get("role") != "admin":
if isinstance(request_id, int): if isinstance(request_id, int):
details = get_request_cache_payload(request_id)
if not isinstance(details, dict):
details = await _get_request_details(client, request_id) details = await _get_request_details(client, request_id)
if not _request_matches_user(details, user.get("username", "")): if user.get("role") == "admin":
continue requested_by = _request_display_name(details)
else: accessible = True
continue if status is not None:
is_available = await _request_is_available_in_jellyfin(
jellyfin,
title,
year,
media_type,
details if isinstance(details, dict) else None,
jellyfin_cache,
)
status_label = _status_label_with_jellyfin(status, is_available)
results.append( results.append(
{ {
@@ -1772,12 +1890,137 @@ async def search_requests(
"requestId": request_id, "requestId": request_id,
"status": status, "status": status,
"statusLabel": status_label, "statusLabel": status_label,
"requestedBy": requested_by,
"accessible": accessible,
"posterPath": item.get("posterPath") or item.get("poster_path"),
"backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
} }
) )
return {"results": results} return {"results": results}
@router.post("/create")
async def create_request(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
) -> Dict[str, Any]:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Seerr not configured")
media_type = _normalize_media_type(
payload.get("mediaType") or payload.get("type") or payload.get("media_type")
)
if media_type is None:
raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'")
raw_tmdb_id = payload.get("tmdbId")
if raw_tmdb_id is None:
raw_tmdb_id = payload.get("mediaId")
if raw_tmdb_id is None:
raw_tmdb_id = payload.get("id")
try:
tmdb_id = int(raw_tmdb_id)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc
if tmdb_id <= 0:
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
raw_is_4k = payload.get("is4k")
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
raise HTTPException(status_code=400, detail="is4k must be true or false")
is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None
try:
details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id))
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
if not isinstance(details, dict):
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
requests_list = media_info.get("requests")
existing_request: Optional[Dict[str, Any]] = None
if isinstance(requests_list, list) and requests_list:
first_request = requests_list[0]
if isinstance(first_request, dict):
existing_request = first_request
title = details.get("title") or details.get("name")
year: Optional[int] = None
date_value = details.get("releaseDate") or details.get("firstAirDate")
if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit():
year = int(date_value[:4])
if isinstance(existing_request, dict):
existing_request_id = _quality_profile_id(existing_request.get("id"))
existing_status = existing_request.get("status")
if existing_request_id is not None:
request_payload = await _get_request_details(client, existing_request_id)
if isinstance(request_payload, dict):
parsed_payload = _parse_request_payload(request_payload)
upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload))
_cache_set(f"request:{existing_request_id}", request_payload)
title = parsed_payload.get("title") or title
year = parsed_payload.get("year") or year
return {
"status": "exists",
"requestId": existing_request_id,
"type": media_type,
"tmdbId": tmdb_id,
"title": title,
"year": year,
"statusCode": existing_status,
"statusLabel": _status_label(existing_status),
}
try:
created = await client.create_request(
media_type=media_type,
media_id=tmdb_id,
seasons=seasons if media_type == "tv" else None,
is_4k=is_4k,
)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
if not isinstance(created, dict):
raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
parsed = _parse_request_payload(created)
request_id = _quality_profile_id(parsed.get("request_id"))
status_code = parsed.get("status")
title = parsed.get("title") or title
year = parsed.get("year") or year
if request_id is not None:
upsert_request_cache(**_build_request_cache_record(parsed, created))
_cache_set(f"request:{request_id}", created)
_recent_cache["updated_at"] = None
await asyncio.to_thread(
save_action,
str(request_id),
"request_created",
"Create request",
"ok",
f"{media_type} request created from discovery by {user.get('username')}.",
)
return {
"status": "created",
"requestId": request_id,
"type": media_type,
"tmdbId": tmdb_id,
"title": title,
"year": year,
"statusCode": status_code,
"statusLabel": _status_label(status_code),
}
@router.post("/{request_id}/ai/triage", response_model=TriageResult) @router.post("/{request_id}/ai/triage", response_model=TriageResult)
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult: async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
runtime = get_runtime_settings() runtime = get_runtime_settings()
+6
View File
@@ -24,6 +24,12 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
"message": banner_message, "message": banner_message,
"tone": tone, "tone": tone,
}, },
"login": {
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
"showLocalLogin": bool(runtime.site_login_show_local_login),
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
"showSignupLink": bool(runtime.site_login_show_signup_link),
},
} }
if include_changelog: if include_changelog:
info["changelog"] = (CHANGELOG or "").strip() info["changelog"] = (CHANGELOG or "").strip()
+33 -8
View File
@@ -26,6 +26,35 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
return {"name": name, "status": "down", "message": str(exc)} return {"name": name, "status": "down", "message": str(exc)}
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
if not qbittorrent.base_url:
return {"name": "qBittorrent", "status": "not_configured"}
if not qbittorrent.username or not qbittorrent.password:
reachable = await qbittorrent.is_webui_reachable()
return {
"name": "qBittorrent",
"status": "degraded" if reachable else "not_configured",
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
}
try:
result = await qbittorrent.get_app_version()
return {"name": "qBittorrent", "status": "up", "detail": result}
except RuntimeError as exc:
if "login failed" in str(exc).lower():
reachable = await qbittorrent.is_webui_reachable()
if reachable:
return {
"name": "qBittorrent",
"status": "degraded",
"message": "qBittorrent is reachable but the saved credentials were rejected",
}
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except httpx.HTTPError as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except Exception as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
@router.get("/services") @router.get("/services")
async def services_status() -> Dict[str, Any]: async def services_status() -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -71,13 +100,7 @@ async def services_status() -> Dict[str, Any]:
prowlarr_status["status"] = "degraded" prowlarr_status["status"] = "degraded"
prowlarr_status["message"] = "Health warnings" prowlarr_status["message"] = "Health warnings"
services.append(prowlarr_status) services.append(prowlarr_status)
services.append( services.append(await _check_qbittorrent(qbittorrent))
await _check(
"qBittorrent",
qbittorrent.configured(),
qbittorrent.get_app_version,
)
)
services.append( services.append(
await _check( await _check(
"Jellyfin", "Jellyfin",
@@ -122,10 +145,12 @@ async def test_service(service: str) -> Dict[str, Any]:
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status), "sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status), "radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health), "prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info), "jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
} }
if service_key == "qbittorrent":
return await _check_qbittorrent(qbittorrent)
if service_key not in checks: if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service") raise HTTPException(status_code=404, detail="Unknown service")
+10
View File
@@ -4,6 +4,12 @@ from .db import get_settings_overrides
_INT_FIELDS = { _INT_FIELDS = {
"magent_application_port", "magent_application_port",
"magent_api_port", "magent_api_port",
"auth_rate_limit_window_seconds",
"auth_rate_limit_max_attempts_ip",
"auth_rate_limit_max_attempts_user",
"password_reset_rate_limit_window_seconds",
"password_reset_rate_limit_max_attempts_ip",
"password_reset_rate_limit_max_attempts_identifier",
"sonarr_quality_profile_id", "sonarr_quality_profile_id",
"radarr_quality_profile_id", "radarr_quality_profile_id",
"jwt_exp_minutes", "jwt_exp_minutes",
@@ -29,6 +35,10 @@ _BOOL_FIELDS = {
"magent_notify_webhook_enabled", "magent_notify_webhook_enabled",
"jellyfin_sync_to_arr", "jellyfin_sync_to_arr",
"site_banner_enabled", "site_banner_enabled",
"site_login_show_jellyfin_login",
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
} }
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"} _SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
+16 -2
View File
@@ -1,13 +1,16 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
import jwt
from jwt import InvalidTokenError
from .config import settings from .config import settings
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") _pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
_ALGORITHM = "HS256" _ALGORITHM = "HS256"
MIN_PASSWORD_LENGTH = 8
PASSWORD_POLICY_MESSAGE = f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@@ -18,6 +21,13 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password) return _pwd_context.verify(plain_password, hashed_password)
def validate_password_policy(password: str) -> str:
candidate = password.strip()
if len(candidate) < MIN_PASSWORD_LENGTH:
raise ValueError(PASSWORD_POLICY_MESSAGE)
return candidate
def _create_token( def _create_token(
subject: str, subject: str,
role: str, role: str,
@@ -34,6 +44,8 @@ def _create_token(
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM) return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
minutes = expires_minutes or settings.jwt_exp_minutes minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return _create_token(subject, role, expires_at=expires, token_type="access") return _create_token(subject, role, expires_at=expires, token_type="access")
@@ -45,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
def decode_token(token: str) -> Dict[str, Any]: 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])
@@ -55,5 +69,5 @@ class TokenError(Exception):
def safe_decode_token(token: str) -> Dict[str, Any]: def safe_decode_token(token: str) -> Dict[str, Any]:
try: try:
return decode_token(token) return decode_token(token)
except JWTError as exc: except InvalidTokenError as exc:
raise TokenError("Invalid token") from exc raise TokenError("Invalid token") from exc
+40 -9
View File
@@ -16,7 +16,8 @@ from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings from ..config import settings as env_settings
from ..db import run_integrity_check from ..db import get_database_diagnostics
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
@@ -97,7 +98,12 @@ def _config_status(detail: str) -> str:
def _discord_config_ready(runtime) -> tuple[bool, str]: def _discord_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled: if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
return False, "Discord notifications are disabled." return False, "Discord notifications are disabled."
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url): webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok" return True, "ok"
return False, "Discord webhook URL is required." return False, "Discord webhook URL is required."
@@ -113,7 +119,12 @@ def _telegram_config_ready(runtime) -> tuple[bool, str]:
def _webhook_config_ready(runtime) -> tuple[bool, str]: def _webhook_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled: if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
return False, "Generic webhook notifications are disabled." return False, "Generic webhook notifications are disabled."
if _clean_text(runtime.magent_notify_webhook_url): webhook_url = _clean_text(runtime.magent_notify_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok" return True, "ok"
return False, "Generic webhook URL is required." return False, "Generic webhook URL is required."
@@ -123,11 +134,21 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
return False, "Push notifications are disabled." return False, "Push notifications are disabled."
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower() provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
if provider == "ntfy": if provider == "ntfy":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic): push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_topic):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok" return True, "ok"
return False, "ntfy requires a base URL and topic." return False, "ntfy requires a base URL and topic."
if provider == "gotify": if provider == "gotify":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token): push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_token):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok" return True, "ok"
return False, "Gotify requires a base URL and app token." return False, "Gotify requires a base URL and app token."
if provider == "pushover": if provider == "pushover":
@@ -135,7 +156,12 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
return True, "ok" return True, "ok"
return False, "Pushover requires an application token and user key." return False, "Pushover requires an application token and user key."
if provider == "webhook": if provider == "webhook":
if _clean_text(runtime.magent_notify_push_base_url): push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url:
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok" return True, "ok"
return False, "Webhook relay requires a target URL." return False, "Webhook relay requires a target URL."
if provider == "telegram": if provider == "telegram":
@@ -190,6 +216,7 @@ async def _run_http_post(
params: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers) response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
response.raise_for_status() response.raise_for_status()
@@ -205,12 +232,16 @@ async def _run_http_post(
async def _run_database_check() -> Dict[str, Any]: async def _run_database_check() -> Dict[str, Any]:
integrity = await asyncio.to_thread(run_integrity_check) detail = await asyncio.to_thread(get_database_diagnostics)
integrity = _clean_text(detail.get("integrity_check"), "unknown")
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
status = "up" if integrity == "ok" else "degraded" status = "up" if integrity == "ok" else "degraded"
return { return {
"status": status, "status": status,
"message": f"SQLite integrity_check returned {integrity}", "message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
"detail": integrity, "detail": detail,
} }
File diff suppressed because it is too large Load Diff
+8
View File
@@ -6,12 +6,15 @@ from ..clients.jellyfin import JellyfinClient
from ..db import ( from ..db import (
create_user_if_missing, create_user_if_missing,
get_user_by_username, get_user_by_username,
set_user_email,
set_user_auth_provider, set_user_auth_provider,
set_user_jellyseerr_id, set_user_jellyseerr_id,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .user_cache import ( from .user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users, get_cached_jellyseerr_users,
match_jellyseerr_user_id, match_jellyseerr_user_id,
save_jellyfin_users_cache, save_jellyfin_users_cache,
@@ -41,10 +44,13 @@ async def sync_jellyfin_users() -> int:
if not name: if not name:
continue continue
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
created = create_user_if_missing( created = create_user_if_missing(
name, name,
"jellyfin-user", "jellyfin-user",
role="user", role="user",
email=matched_email,
auth_provider="jellyfin", auth_provider="jellyfin",
jellyseerr_user_id=matched_id, jellyseerr_user_id=matched_id,
) )
@@ -60,6 +66,8 @@ async def sync_jellyfin_users() -> int:
set_user_auth_provider(name, "jellyfin") set_user_auth_provider(name, "jellyfin")
if matched_id is not None: if matched_id is not None:
set_user_jellyseerr_id(name, matched_id) set_user_jellyseerr_id(name, matched_id)
if matched_email:
set_user_email(name, matched_email)
return imported return imported
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
logger = logging.getLogger(__name__)
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _split_emails(value: str) -> list[str]:
if not value:
return []
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
return [entry for entry in parts if entry and "@" in entry]
def _resolve_app_url() -> str:
runtime = get_runtime_settings()
for candidate in (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
):
normalized = _clean_text(candidate)
if normalized:
return normalized.rstrip("/")
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
return f"http://localhost:{port}"
def _portal_item_url(item_id: int) -> str:
return f"{_resolve_app_url()}/portal?item={item_id}"
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = response.text
return {"status_code": response.status_code, "body": body}
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
runtime.discord_webhook_url
)
if not webhook:
return {"status": "skipped", "detail": "Discord webhook not configured."}
data = {
"content": f"**{title}**\n{message}",
"embeds": [
{
"title": title,
"description": message,
"fields": [
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
],
"url": _clean_text(payload.get("item_url")),
}
],
}
result = await _http_post_json(webhook, data)
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
if not bot_token or not chat_id:
return {"status": "skipped", "detail": "Telegram is not configured."}
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
result = await _http_post_json(url, payload)
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_webhook_url)
if not webhook:
return {"status": "skipped", "detail": "Generic webhook is not configured."}
result = await _http_post_json(webhook, payload)
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
topic = _clean_text(runtime.magent_notify_push_topic)
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
response.raise_for_status()
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
if provider == "pushover":
user_key = _clean_text(runtime.magent_notify_push_user_key)
if not token or not user_key:
return {"status": "skipped", "detail": "Pushover needs token and user key."}
form = {"token": token, "user": user_key, "title": title, "message": message}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
response.raise_for_status()
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
if provider == "discord":
return await _send_discord(title, message, payload)
if provider == "telegram":
return await _send_telegram(title, message)
if provider == "webhook":
return await _send_webhook(payload)
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
fallback = _clean_text(runtime.magent_notify_email_from_address)
if fallback and fallback not in recipients:
recipients.append(fallback)
if not recipients:
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
body_text = (
f"{title}\n\n"
f"{message}\n\n"
f"Kind: {_clean_text(payload.get('kind'))}\n"
f"Status: {_clean_text(payload.get('status'))}\n"
f"Priority: {_clean_text(payload.get('priority'))}\n"
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
f"Open: {_clean_text(payload.get('item_url'))}\n"
)
body_html = (
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
"</table>"
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
"</div>"
)
deliveries: list[Dict[str, Any]] = []
for recipient in recipients:
try:
result = await send_generic_email(
recipient_email=recipient,
subject=title,
body_text=body_text,
body_html=body_html,
)
deliveries.append({"recipient": recipient, "status": "ok", **result})
except Exception as exc:
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
if successful:
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
async def send_portal_notification(
*,
event_type: str,
item: Dict[str, Any],
actor_username: str,
actor_role: str,
note: Optional[str] = None,
) -> Dict[str, Any]:
runtime = get_runtime_settings()
if not runtime.magent_notify_enabled:
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
item_id = int(item.get("id") or 0)
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
message_lines = [
f"Event: {event_type}",
f"Actor: {actor_username} ({actor_role})",
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
]
if note:
message_lines.append(f"Note: {note}")
message_lines.append(f"Open: {_portal_item_url(item_id)}")
message = "\n".join(message_lines)
payload = {
"type": "portal.notification",
"event": event_type,
"item_id": item_id,
"item_url": _portal_item_url(item_id),
"kind": _clean_text(item.get("kind")),
"status": _clean_text(item.get("status")),
"priority": _clean_text(item.get("priority")),
"requested_by": _clean_text(item.get("created_by_username")),
"actor_username": actor_username,
"actor_role": actor_role,
"note": note or "",
}
channels: Dict[str, Dict[str, Any]] = {}
if runtime.magent_notify_discord_enabled:
try:
channels["discord"] = await _send_discord(title, message, payload)
except Exception as exc:
channels["discord"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_telegram_enabled:
try:
channels["telegram"] = await _send_telegram(title, message)
except Exception as exc:
channels["telegram"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_webhook_enabled:
try:
channels["webhook"] = await _send_webhook(payload)
except Exception as exc:
channels["webhook"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_push_enabled:
try:
channels["push"] = await _send_push(title, message, payload)
except Exception as exc:
channels["push"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_email_enabled:
try:
channels["email"] = await _send_email(title, message, payload)
except Exception as exc:
channels["email"] = {"status": "error", "detail": str(exc)}
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
failed = [name for name, value in channels.items() if value.get("status") == "error"]
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
logger.info(
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
event_type,
item_id,
successful,
failed,
skipped,
)
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
if not channels:
overall = "skipped"
return {"status": overall, "channels": channels}
+3
View File
@@ -112,6 +112,9 @@ async def _fetch_all_seerr_users() -> list[dict]:
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]: def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
if isinstance(local_user, dict): if isinstance(local_user, dict):
stored_email = str(local_user.get("email") or "").strip()
if "@" in stored_email:
return stored_email
username = str(local_user.get("username") or "").strip() username = str(local_user.get("username") or "").strip()
if "@" in username: if "@" in username:
return username return username
+117 -10
View File
@@ -1,6 +1,7 @@
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import asyncio import asyncio
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import quote from urllib.parse import quote
import httpx import httpx
@@ -57,6 +58,100 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None return None
def _normalize_media_title(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
return normalized or None
def _canonical_provider_key(value: str) -> str:
normalized = value.strip().lower()
if normalized.endswith("id"):
normalized = normalized[:-2]
return normalized
def extract_request_provider_ids(payload: Any) -> Dict[str, str]:
provider_ids: Dict[str, str] = {}
candidates: List[Any] = []
if isinstance(payload, dict):
candidates.append(payload)
media = payload.get("media")
if isinstance(media, dict):
candidates.append(media)
for candidate in candidates:
if not isinstance(candidate, dict):
continue
embedded = candidate.get("ProviderIds") or candidate.get("providerIds")
if isinstance(embedded, dict):
for key, value in embedded.items():
if value is None:
continue
text = str(value).strip()
if text:
provider_ids[_canonical_provider_key(str(key))] = text
for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"):
value = candidate.get(key)
if value is None:
continue
text = str(value).strip()
if text:
provider_ids[_canonical_provider_key(key)] = text
return provider_ids
def jellyfin_item_matches_request(
item: Dict[str, Any],
*,
title: Optional[str],
year: Optional[int],
request_type: RequestType,
request_payload: Optional[Dict[str, Any]] = None,
) -> bool:
request_provider_ids = extract_request_provider_ids(request_payload or {})
item_provider_ids = extract_request_provider_ids(item)
provider_priority = ("tmdb", "tvdb", "imdb")
for key in provider_priority:
request_id = request_provider_ids.get(key)
item_id = item_provider_ids.get(key)
if request_id and item_id and request_id == item_id:
return True
request_title = _normalize_media_title(title)
if not request_title:
return False
item_titles = [
_normalize_media_title(item.get("Name")),
_normalize_media_title(item.get("OriginalTitle")),
_normalize_media_title(item.get("SortName")),
_normalize_media_title(item.get("SeriesName")),
_normalize_media_title(item.get("title")),
]
item_titles = [candidate for candidate in item_titles if candidate]
item_year = item.get("ProductionYear") or item.get("Year")
try:
item_year_value = int(item_year) if item_year is not None else None
except (TypeError, ValueError):
item_year_value = None
if year and item_year_value and int(year) != item_year_value:
return False
if request_title in item_titles:
return True
if request_type == RequestType.tv:
for candidate in item_titles:
if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
return True
return False
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]: def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
response = exc.response response = exc.response
if response is None: if response is None:
@@ -513,7 +608,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
if jellyfin.configured() and snapshot.title: if jellyfin.configured() and snapshot.title:
types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"] types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"]
try: try:
search = await jellyfin.search_items(snapshot.title, types) search = await jellyfin.search_items(snapshot.title, types, limit=50)
except Exception: except Exception:
search = None search = None
if isinstance(search, dict): if isinstance(search, dict):
@@ -521,11 +616,13 @@ async def build_snapshot(request_id: str) -> Snapshot:
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
name = item.get("Name") or item.get("title") if jellyfin_item_matches_request(
year = item.get("ProductionYear") or item.get("Year") item,
if name and name.strip().lower() == (snapshot.title or "").strip().lower(): title=snapshot.title,
if snapshot.year and year and int(year) != int(snapshot.year): year=snapshot.year,
continue request_type=snapshot.request_type,
request_payload=jelly_request,
):
jellyfin_available = True jellyfin_available = True
jellyfin_item = item jellyfin_item = item
break break
@@ -646,12 +743,22 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state = NormalizedState.added_to_arr snapshot.state = NormalizedState.added_to_arr
snapshot.state_reason = "Item is present in Sonarr/Radarr" snapshot.state_reason = "Item is present in Sonarr/Radarr"
if jellyfin_available and snapshot.state not in { if jellyfin_available:
NormalizedState.downloading, missing_episodes = arr_details.get("missingEpisodes")
NormalizedState.importing, if snapshot.request_type == RequestType.tv and isinstance(missing_episodes, dict) and missing_episodes:
}: snapshot.state = NormalizedState.importing
snapshot.state_reason = "Some episodes are available in Jellyfin, but the request is still incomplete."
for hop in timeline:
if hop.service == "Seerr":
hop.status = "Partially ready"
else:
snapshot.state = NormalizedState.completed snapshot.state = NormalizedState.completed
snapshot.state_reason = "Ready to watch in Jellyfin." snapshot.state_reason = "Ready to watch in Jellyfin."
for hop in timeline:
if hop.service == "Seerr":
hop.status = "Available"
elif hop.service == "Sonarr/Radarr" and hop.status not in {"error"}:
hop.status = "available"
snapshot.timeline = timeline snapshot.timeline = timeline
actions: List[ActionOption] = [] actions: List[ActionOption] = []
+27
View File
@@ -89,6 +89,33 @@ def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int
return candidate_to_id return candidate_to_id
def find_matching_jellyseerr_user(
identifier: str, users: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
target_handles = set(_normalized_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email", "displayName", "name"):
if target_handles.intersection(_normalized_handles(user.get(key))):
return user
return None
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(user, dict):
return None
value = user.get("email")
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def match_jellyseerr_user_id( def match_jellyseerr_user_id(
username: str, candidate_map: Dict[str, int] username: str, candidate_map: Dict[str, int]
) -> Optional[int]: ) -> Optional[int]:
+1 -1
View File
@@ -3,7 +3,7 @@ uvicorn==0.41.0
httpx==0.28.1 httpx==0.28.1
pydantic==2.12.5 pydantic==2.12.5
pydantic-settings==2.13.1 pydantic-settings==2.13.1
python-jose[cryptography]==3.5.0 PyJWT==2.11.0
passlib==1.7.4 passlib==1.7.4
python-multipart==0.0.22 python-multipart==0.0.22
Pillow==12.1.1 Pillow==12.1.1
+263
View File
@@ -0,0 +1,263 @@
import os
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from starlette.requests import Request
from backend.app import db
from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.routers import auth as auth_router
from backend.app.routers import portal as portal_router
from backend.app.routers import requests as requests_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset
def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/auth/password/forgot",
"raw_path": b"/auth/password/forgot",
"query_string": b"",
"headers": [(b"user-agent", user_agent.encode("utf-8"))],
"client": (ip, 12345),
"server": ("testserver", 8000),
}
async def receive() -> dict:
return {"type": "http.request", "body": b"", "more_body": False}
return Request(scope, receive)
class TempDatabaseMixin:
def setUp(self) -> None:
super_method = getattr(super(), "setUp", None)
if callable(super_method):
super_method()
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")
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()
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()
self._tempdir.cleanup()
super_method = getattr(super(), "tearDown", None)
if callable(super_method):
super_method()
class PasswordPolicyTests(unittest.TestCase):
def test_validate_password_policy_rejects_short_passwords(self) -> None:
with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE):
validate_password_policy("short")
def test_validate_password_policy_trims_whitespace(self) -> None:
self.assertEqual(validate_password_policy(" password123 "), "password123")
class NetworkSecurityTests(unittest.TestCase):
def test_notification_targets_reject_loopback(self) -> None:
with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
validate_notification_target_url("http://127.0.0.1:8080/webhook")
def test_forwarded_headers_require_trusted_proxy(self) -> None:
original_enabled = settings.magent_proxy_enabled
original_trust = settings.magent_proxy_trust_forwarded_headers
original_proxies = settings.magent_proxy_trusted_proxies
settings.magent_proxy_enabled = True
settings.magent_proxy_trust_forwarded_headers = True
settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
try:
self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
finally:
settings.magent_proxy_enabled = original_enabled
settings.magent_proxy_trust_forwarded_headers = original_trust
settings.magent_proxy_trusted_proxies = original_proxies
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
with patch.object(
client,
"get_app_version",
new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None:
requests_router._detail_cache.clear()
requests_router._failed_detail_cache.clear()
def test_successful_detail_cache_write_clears_prior_failure(self) -> None:
key = "request:123"
requests_router._failure_cache_set(key)
self.assertTrue(requests_router._failure_cache_has(key))
requests_router._cache_set(key, {"id": 123})
self.assertFalse(requests_router._failure_cache_has(key))
self.assertEqual(requests_router._cache_get(key), {"id": 123})
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing(
"MixedCaseUser",
"password123",
email=None,
auth_provider="local",
)
self.assertTrue(created)
updated = db.set_user_email("mixedcaseuser", "mixed@example.com")
self.assertTrue(updated)
stored = db.get_user_by_username("MIXEDCASEUSER")
self.assertIsNotNone(stored)
self.assertEqual(stored.get("email"), "mixed@example.com")
class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_forgot_password_is_rate_limited(self) -> None:
request = _build_request(ip="10.1.2.3")
payload = {"identifier": "resetuser@example.com"}
with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object(
auth_router,
"request_password_reset",
new=AsyncMock(return_value={"status": "ok", "issued": False}),
):
for _ in range(3):
result = await auth_router.forgot_password(payload, request)
self.assertEqual(result["status"], "ok")
with self.assertRaises(HTTPException) as context:
await auth_router.forgot_password(payload, request)
self.assertEqual(context.exception.status_code, 429)
self.assertEqual(
context.exception.detail,
"Too many password reset attempts. Try again shortly.",
)
async def test_request_password_reset_prefers_local_user_email(self) -> None:
db.create_user_if_missing(
"ResetUser",
"password123",
email="local@example.com",
auth_provider="local",
)
with patch.object(
password_reset,
"send_password_reset_email",
new=AsyncMock(return_value={"status": "ok"}),
) as send_email:
result = await password_reset.request_password_reset("ResetUser")
self.assertTrue(result["issued"])
self.assertEqual(result["recipient_email"], "local@example.com")
send_email.assert_awaited_once()
self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com")
async def test_profile_invite_requires_recipient_email(self) -> None:
current_user = {
"username": "invite-owner",
"role": "user",
"invite_management_enabled": True,
"profile_id": None,
}
with self.assertRaises(HTTPException) as context:
await auth_router.create_profile_invite({"label": "Missing email"}, current_user)
self.assertEqual(context.exception.status_code, 400)
self.assertEqual(
context.exception.detail,
"recipient_email is required and must be a valid email address.",
)
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
def test_legacy_request_status_maps_to_workflow(self) -> None:
item = {"kind": "request", "status": "in_progress"}
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
workflow = serialized.get("workflow") or {}
self.assertEqual(workflow.get("request_status"), "approved")
self.assertEqual(workflow.get("media_status"), "processing")
def test_invalid_pipeline_transition_is_rejected(self) -> None:
with self.assertRaises(HTTPException) as context:
portal_router._validate_pipeline_transition(
"approved",
"processing",
"pending",
"pending",
)
self.assertEqual(context.exception.status_code, 400)
def test_portal_workflow_filters(self) -> None:
db.create_portal_item(
kind="request",
title="Request A",
description="A",
created_by_username="alpha",
created_by_id=None,
status="processing",
workflow_request_status="approved",
workflow_media_status="processing",
)
db.create_portal_item(
kind="request",
title="Request B",
description="B",
created_by_username="bravo",
created_by_id=None,
status="pending",
workflow_request_status="pending",
workflow_media_status="pending",
)
processing = db.list_portal_items(
kind="request",
workflow_request_status="approved",
workflow_media_status="processing",
limit=10,
offset=0,
)
pending_count = db.count_portal_items(
kind="request",
workflow_request_status="pending",
workflow_media_status="pending",
)
self.assertEqual(len(processing), 1)
self.assertEqual(pending_count, 1)
+78 -11
View File
@@ -40,6 +40,10 @@ const SECTION_LABELS: Record<string, string> = {
const BOOL_SETTINGS = new Set([ const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr', 'jellyfin_sync_to_arr',
'site_banner_enabled', 'site_banner_enabled',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
'magent_proxy_enabled', 'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers', 'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled', 'magent_ssl_bind_enabled',
@@ -104,7 +108,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Control how often requests are refreshed and cleaned up.', requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
site: 'Sitewide banner and version details. The changelog is generated from git history during release builds.', site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -239,6 +243,31 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
]), ]),
} }
const SITE_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'site-banner',
title: 'Site Banner',
description: 'Control the sitewide banner message, tone, and visibility.',
keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'],
},
{
key: 'site-login',
title: 'Login Page Behaviour',
description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
keys: [
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
],
},
]
const SETTING_LABEL_OVERRIDES: Record<string, string> = { const SETTING_LABEL_OVERRIDES: Record<string, string> = {
jellyseerr_base_url: 'Seerr base URL', jellyseerr_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key', jellyseerr_api_key: 'Seerr API key',
@@ -280,6 +309,10 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_push_device: 'Device / target', magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled', magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL', magent_notify_webhook_url: 'Generic webhook URL',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
site_login_show_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link',
log_file_max_bytes: 'Log file max size (bytes)', log_file_max_bytes: 'Log file max size (bytes)',
log_file_backup_count: 'Rotated log files to keep', log_file_backup_count: 'Rotated log files to keep',
log_http_client_level: 'Service HTTP log level', log_http_client_level: 'Service HTTP log level',
@@ -551,6 +584,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications' const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
const isSiteGroupedSection = section === 'site'
const visibleSections = settingsSection ? [settingsSection] : [] const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache' const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source']) const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
@@ -564,6 +598,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
'requests_cleanup_time', 'requests_cleanup_time',
'requests_cleanup_days', 'requests_cleanup_days',
] ]
const siteSettingOrder = [
'site_banner_enabled',
'site_banner_message',
'site_banner_tone',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
]
const sortByOrder = (items: AdminSetting[], order: string[]) => { const sortByOrder = (items: AdminSetting[], order: string[]) => {
const position = new Map(order.map((key, index) => [key, index])) const position = new Map(order.map((key, index) => [key, index]))
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
@@ -603,6 +646,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}) })
return groups return groups
})() })()
: isSiteGroupedSection
? (() => {
const siteItems = groupedSettings.site ?? []
const byKey = new Map(siteItems.map((item) => [item.key, item]))
return SITE_SECTION_GROUPS.map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
})()
: visibleSections.map((sectionKey) => ({ : visibleSections.map((sectionKey) => ({
key: sectionKey, key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey,
@@ -615,6 +674,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (sectionKey === 'requests') { if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder) return sortByOrder(filtered, requestSettingOrder)
} }
if (sectionKey === 'site') {
return sortByOrder(filtered, siteSettingOrder)
}
return filtered return filtered
})(), })(),
})) }))
@@ -748,6 +810,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_banner_enabled: 'Enable a sitewide banner for announcements.', site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.', site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.', site_banner_tone: 'Visual tone for the banner.',
site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
site_login_show_local_login: 'Show the local Magent login button on the login page.',
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
site_login_show_signup_link: 'Show the invite signup link on the login page.',
site_changelog: 'One update per line for the public changelog.', site_changelog: 'One update per line for the public changelog.',
} }
@@ -1672,7 +1738,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)} )}
</div> </div>
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
(!settingsSection || isMagentGroupedSection) && ( (!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
<p className="section-subtitle"> <p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]} {sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p> </p>
@@ -2148,11 +2214,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const isPemField = const isPemField =
setting.key === 'magent_ssl_certificate_pem' || setting.key === 'magent_ssl_certificate_pem' ||
setting.key === 'magent_ssl_private_key_pem' setting.key === 'magent_ssl_private_key_pem'
const shouldSpanFull = isPemField || setting.key === 'site_banner_message'
return ( return (
<label <label
key={setting.key} key={setting.key}
data-helper={helperText || undefined} data-helper={helperText || undefined}
className={isPemField ? 'field-span-full' : undefined} className={shouldSpanFull ? 'field-span-full' : undefined}
> >
<span className="label-row"> <span className="label-row">
<span>{labelFromKey(setting.key)}</span> <span>{labelFromKey(setting.key)}</span>
@@ -2229,14 +2296,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
/> />
</label> </label>
) : null} ) : null}
<button
type="button"
className="settings-action-button"
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
</button>
{getSectionTestLabel(sectionGroup.key) ? ( {getSectionTestLabel(sectionGroup.key) ? (
<button <button
type="button" type="button"
@@ -2249,6 +2308,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
: getSectionTestLabel(sectionGroup.key)} : getSectionTestLabel(sectionGroup.key)}
</button> </button>
) : null} ) : null}
<button
type="button"
className="settings-action-button"
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
</button>
</div> </div>
</section> </section>
))} ))}
+18 -4
View File
@@ -156,6 +156,8 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
const isInviteTraceRowInvited = (row: InviteTraceRow) => const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim()) Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
@@ -349,6 +351,17 @@ export default function AdminInviteManagementPage() {
const saveInvite = async (event: React.FormEvent) => { const saveInvite = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setError('Recipient email is required.')
setStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
setError('Recipient email must be valid.')
setStatus(null)
return
}
setInviteSaving(true) setInviteSaving(true)
setError(null) setError(null)
setStatus(null) setStatus(null)
@@ -363,7 +376,7 @@ export default function AdminInviteManagementPage() {
max_uses: inviteForm.max_uses || null, max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled, enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null, expires_at: inviteForm.expires_at || null,
recipient_email: inviteForm.recipient_email || null, recipient_email: recipientEmail,
send_email: inviteForm.send_email, send_email: inviteForm.send_email,
message: inviteForm.message || null, message: inviteForm.message || null,
} }
@@ -1607,18 +1620,19 @@ export default function AdminInviteManagementPage() {
<div className="invite-form-row"> <div className="invite-form-row">
<div className="invite-form-row-label"> <div className="invite-form-row-label">
<span>Delivery</span> <span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small> <small>Recipient email is required. You can optionally send the invite immediately after saving.</small>
</div> </div>
<div className="invite-form-row-control invite-form-row-control--stacked"> <div className="invite-form-row-control invite-form-row-control--stacked">
<label> <label>
<span>Recipient email</span> <span>Recipient email (required)</span>
<input <input
type="email" type="email"
required
value={inviteForm.recipient_email} value={inviteForm.recipient_email}
onChange={(e) => onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value })) setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
} }
placeholder="person@example.com" placeholder="Required recipient email"
/> />
</label> </label>
<label> <label>
+35 -2
View File
@@ -15,6 +15,17 @@ type RequestRow = {
createdAt?: string | null createdAt?: string | null
} }
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting for approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working on it' },
{ value: 'partial', label: 'Partially ready' },
{ value: 'ready', label: 'Ready to watch' },
{ value: 'declined', label: 'Declined' },
]
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown' if (!value) return 'Unknown'
const date = new Date(value) const date = new Date(value)
@@ -30,6 +41,7 @@ export default function AdminRequestsAllPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pageSize, setPageSize] = useState(50) const [pageSize, setPageSize] = useState(50)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [stage, setStage] = useState('all')
const pageCount = useMemo(() => { const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1 if (!total || pageSize <= 0) return 1
@@ -46,8 +58,15 @@ export default function AdminRequestsAllPage() {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const skip = (page - 1) * pageSize const skip = (page - 1) * pageSize
const params = new URLSearchParams({
take: String(pageSize),
skip: String(skip),
})
if (stage !== 'all') {
params.set('stage', stage)
}
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/requests/all?take=${pageSize}&skip=${skip}` `${baseUrl}/admin/requests/all?${params.toString()}`
) )
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
@@ -74,7 +93,7 @@ export default function AdminRequestsAllPage() {
useEffect(() => { useEffect(() => {
void load() void load()
}, [page, pageSize]) }, [page, pageSize, stage])
useEffect(() => { useEffect(() => {
if (page > pageCount) { if (page > pageCount) {
@@ -82,6 +101,10 @@ export default function AdminRequestsAllPage() {
} }
}, [pageCount, page]) }, [pageCount, page])
useEffect(() => {
setPage(1)
}, [stage])
return ( return (
<AdminShell <AdminShell
title="All requests" title="All requests"
@@ -98,6 +121,16 @@ export default function AdminRequestsAllPage() {
<span>{total.toLocaleString()} total</span> <span>{total.toLocaleString()} total</span>
</div> </div>
<div className="admin-toolbar-actions"> <div className="admin-toolbar-actions">
<label className="admin-select">
<span>Stage</span>
<select value={stage} onChange={(e) => setStage(e.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-select"> <label className="admin-select">
<span>Per page</span> <span>Per page</span>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}> <select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
+12 -11
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
type Profile = { type Profile = {
username?: string username?: string
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
const load = async () => { const load = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`) const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) { if (!response.ok) {
clearToken() throw new Error('Could not load profile.')
router.push('/login')
return
} }
const data = await response.json() const data = await response.json()
setProfile({ username: data?.username }) setProfile({ username: data?.username })
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error) console.error(error)
} }
} }
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
setSubmitting(true) setSubmitting(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, { const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -58,17 +60,16 @@ export default function FeedbackPage() {
}), }),
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text() const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`)
} }
setMessage('') setMessage('')
setStatus('Thanks! Your message has been sent.') setStatus('Thanks! Your message has been sent.')
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error) console.error(error)
setStatus('That did not send. Please try again.') setStatus('That did not send. Please try again.')
} finally { } finally {
+402
View File
@@ -1527,6 +1527,13 @@ button span {
color: var(--ink-muted); color: var(--ink-muted);
} }
.recent-filter-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.recent-filter select { .recent-filter select {
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
@@ -3558,12 +3565,14 @@ button:disabled {
.user-grid-pill.is-blocked { .user-grid-pill.is-blocked {
background: rgba(244, 114, 114, 0.14); background: rgba(244, 114, 114, 0.14);
border-color: rgba(244, 114, 114, 0.24); border-color: rgba(244, 114, 114, 0.24);
color: #ffd5d5;
} }
.system-pill-degraded, .system-pill-degraded,
.user-grid-pill.is-disabled { .user-grid-pill.is-disabled {
background: rgba(208, 166, 92, 0.14); background: rgba(208, 166, 92, 0.14);
border-color: rgba(208, 166, 92, 0.22); border-color: rgba(208, 166, 92, 0.22);
color: #ffe3a6;
} }
.system-dot { .system-dot {
@@ -6068,6 +6077,52 @@ textarea {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.diagnostic-detail-panel {
display: grid;
gap: 0.9rem;
}
.diagnostic-detail-group {
display: grid;
gap: 0.6rem;
}
.diagnostic-detail-group h4 {
margin: 0;
font-size: 0.86rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
}
.diagnostic-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.7rem;
}
.diagnostic-detail-item {
display: grid;
gap: 0.2rem;
min-width: 0;
padding: 0.75rem;
border-radius: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
}
.diagnostic-detail-item span {
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.diagnostic-detail-item strong {
line-height: 1.35;
overflow-wrap: anywhere;
}
.diagnostics-rail-metrics { .diagnostics-rail-metrics {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -6505,3 +6560,350 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
/* Portal */
.portal-page {
display: grid;
gap: 16px;
}
.portal-workspace-switch {
display: inline-flex;
gap: 8px;
align-items: center;
}
.portal-workspace-switch button {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
color: var(--text);
padding: 8px 12px;
font-weight: 600;
}
.portal-workspace-switch button.is-active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
background: rgba(107, 146, 255, 0.12);
}
.portal-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.portal-overview-card {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
padding: 12px 14px;
display: grid;
gap: 4px;
}
.portal-overview-card span {
color: var(--muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.portal-overview-card strong {
font-size: 1.25rem;
color: var(--text);
}
.portal-create-panel {
display: grid;
gap: 12px;
}
.portal-discovery-panel {
display: grid;
gap: 12px;
}
.portal-discovery-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
gap: 10px;
}
.portal-discovery-form input {
width: 100%;
}
.portal-discovery-results {
display: grid;
gap: 10px;
}
.portal-discovery-item {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
padding: 10px;
}
.portal-discovery-media {
width: 56px;
height: 84px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
}
.portal-discovery-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.portal-discovery-main {
display: grid;
gap: 6px;
}
.portal-discovery-title-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.portal-discovery-main p {
margin: 0;
font-size: 0.84rem;
color: var(--muted);
}
.portal-discovery-actions {
display: flex;
align-items: center;
}
.poster-fallback {
display: grid;
place-items: center;
width: 100%;
height: 100%;
color: var(--muted);
font-size: 0.66rem;
text-align: center;
padding: 4px;
}
.portal-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.portal-field-span-2 {
grid-column: span 2;
}
.portal-toolbar {
display: grid;
grid-template-columns: 180px minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
.portal-toolbar label span {
display: block;
margin-bottom: 6px;
font-size: 0.78rem;
color: var(--muted);
}
.portal-search-filter input {
width: 100%;
}
.portal-mine-toggle {
align-self: center;
margin-top: 20px;
}
.portal-workspace {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 12px;
}
.portal-list-panel,
.portal-detail-panel {
display: grid;
gap: 12px;
align-content: start;
}
.portal-item-list {
display: grid;
gap: 10px;
max-height: 900px;
overflow: auto;
padding-right: 2px;
}
.portal-item-row {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
padding: 12px;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.portal-item-row.is-active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
}
.portal-item-row-title {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.portal-item-row p {
margin: 8px 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.portal-item-row-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
color: var(--muted);
font-size: 0.78rem;
}
.portal-comments-block {
border-top: 1px solid var(--line);
padding-top: 12px;
display: grid;
gap: 10px;
}
.portal-comment-list {
display: grid;
gap: 8px;
max-height: 420px;
overflow: auto;
padding-right: 2px;
}
.portal-comment-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
background: var(--panel-soft);
}
.portal-comment-card header {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
color: var(--muted);
font-size: 0.78rem;
}
.portal-comment-card p {
margin: 0;
color: var(--text);
white-space: pre-wrap;
line-height: 1.45;
}
.portal-comment-form {
display: grid;
gap: 10px;
}
@media (max-width: 1200px) {
.portal-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.portal-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.portal-search-filter,
.portal-mine-toggle {
grid-column: span 2;
}
.portal-mine-toggle {
margin-top: 0;
}
.portal-workspace {
grid-template-columns: 1fr;
}
.portal-item-list {
max-height: 460px;
}
.portal-discovery-item {
grid-template-columns: 56px minmax(0, 1fr);
}
.portal-discovery-actions {
grid-column: span 2;
justify-content: flex-end;
}
}
@media (max-width: 760px) {
.portal-form-grid {
grid-template-columns: 1fr;
}
.portal-field-span-2 {
grid-column: span 1;
}
.portal-overview-grid,
.portal-toolbar {
grid-template-columns: 1fr;
}
.portal-search-filter,
.portal-mine-toggle {
grid-column: span 1;
}
.portal-discovery-form {
grid-template-columns: 1fr;
}
.portal-discovery-item {
grid-template-columns: 1fr;
}
.portal-discovery-media {
width: 72px;
height: 108px;
}
.portal-discovery-actions {
grid-column: span 1;
justify-content: flex-start;
}
}
+72 -12
View File
@@ -1,27 +1,53 @@
const AUTH_STATE_COOKIE = 'magent_logged_in'
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api' export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
export const getToken = () => { const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof window === 'undefined') return null if (typeof document === 'undefined') return
return window.localStorage.getItem('magent_token') document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
} }
export const setToken = (token: string) => { const clearCookie = (name: string) => {
if (typeof window === 'undefined') return if (typeof document === 'undefined') return
window.localStorage.setItem('magent_token', token) document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
}
export const getToken = () => {
if (typeof document === 'undefined') return null
const cookies = document.cookie.split(';').map((entry) => entry.trim())
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
if (!marker) return null
const [, value] = marker.split('=', 2)
return value || null
}
export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
} }
export const clearToken = () => { export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE)
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
window.localStorage.removeItem('magent_token') const baseUrl = getApiBase()
void fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
keepalive: true,
}).catch(() => undefined)
}
export const logout = async () => {
const baseUrl = getApiBase()
clearCookie(AUTH_STATE_COOKIE)
await fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
} }
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => { export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const token = getToken()
const headers = new Headers(init?.headers || {}) const headers = new Headers(init?.headers || {})
if (token) { return fetch(input, { ...init, headers, credentials: 'include' })
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
} }
export const getEventStreamToken = async () => { export const getEventStreamToken = async () => {
@@ -38,3 +64,37 @@ export const getEventStreamToken = async () => {
} }
return token return token
} }
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized')
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends Error {
constructor() {
super('Forbidden')
this.name = 'ForbiddenError'
}
}
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init)
if (response.status === 401) {
clearToken()
throw new UnauthorizedError()
}
if (response.status === 403) {
throw new ForbiddenError()
}
return response
}
export const readResponseText = async (response: Response) => {
try {
return (await response.text()).trim()
} catch {
return ''
}
}
+85 -5
View File
@@ -1,19 +1,36 @@
'use client' 'use client'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { getApiBase, setToken, clearToken } from '../lib/auth' import { getApiBase, setToken, clearToken } from '../lib/auth'
import BrandingLogo from '../ui/BrandingLogo' import BrandingLogo from '../ui/BrandingLogo'
const DEFAULT_LOGIN_OPTIONS = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter() const router = useRouter()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loginOptions, setLoginOptions] = useState(DEFAULT_LOGIN_OPTIONS)
const primaryMode: 'jellyfin' | 'local' | null = loginOptions.showJellyfinLogin
? 'jellyfin'
: loginOptions.showLocalLogin
? 'local'
: null
const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => { const submit = async (event: React.FormEvent, mode: 'local' | 'jellyfin') => {
event.preventDefault() event.preventDefault()
if (!primaryMode) {
setError('Login is currently disabled. Contact an administrator.')
return
}
setError(null) setError(null)
setLoading(true) setLoading(true)
try { try {
@@ -25,13 +42,14 @@ export default function LoginPage() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body, body,
credentials: 'include',
}) })
if (!response.ok) { if (!response.ok) {
throw new Error('Login failed') throw new Error('Login failed')
} }
const data = await response.json() const data = await response.json()
if (data?.access_token) { if (data?.authenticated) {
setToken(data.access_token) setToken('cookie')
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/' window.location.href = '/'
return return
@@ -48,12 +66,63 @@ export default function LoginPage() {
} }
} }
useEffect(() => {
let active = true
const loadLoginOptions = async () => {
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/site/public`)
if (!response.ok) {
return
}
const data = await response.json()
const login = data?.login ?? {}
if (!active) return
setLoginOptions({
showJellyfinLogin: login.showJellyfinLogin !== false,
showLocalLogin: login.showLocalLogin !== false,
showForgotPassword: login.showForgotPassword !== false,
showSignupLink: login.showSignupLink !== false,
})
} catch (err) {
console.error(err)
}
}
void loadLoginOptions()
return () => {
active = false
}
}, [])
const loginHelpText = (() => {
if (loginOptions.showJellyfinLogin && loginOptions.showLocalLogin) {
return 'Use your Jellyfin account, or sign in with a local Magent admin account.'
}
if (loginOptions.showJellyfinLogin) {
return 'Use your Jellyfin account to sign in.'
}
if (loginOptions.showLocalLogin) {
return 'Use your local Magent admin account to sign in.'
}
return 'No sign-in methods are currently available. Contact an administrator.'
})()
return ( return (
<main className="card auth-card"> <main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" /> <BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1> <h1>Sign in</h1>
<p className="lede">Use your Jellyfin account, or sign in with a local Magent admin account.</p> <p className="lede">{loginHelpText}</p>
<form onSubmit={(event) => submit(event, 'jellyfin')} className="auth-form"> <form
onSubmit={(event) => {
if (!primaryMode) {
event.preventDefault()
setError('Login is currently disabled. Contact an administrator.')
return
}
void submit(event, primaryMode)
}}
className="auth-form"
>
<label> <label>
Username Username
<input <input
@@ -73,10 +142,13 @@ export default function LoginPage() {
</label> </label>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
<div className="auth-actions"> <div className="auth-actions">
{loginOptions.showJellyfinLogin ? (
<button type="submit" disabled={loading}> <button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Login with Jellyfin account'} {loading ? 'Signing in...' : 'Login with Jellyfin account'}
</button> </button>
) : null}
</div> </div>
{loginOptions.showLocalLogin ? (
<button <button
type="button" type="button"
className="ghost-button" className="ghost-button"
@@ -85,12 +157,20 @@ export default function LoginPage() {
> >
Sign in with Magent account Sign in with Magent account
</button> </button>
) : null}
{loginOptions.showForgotPassword ? (
<a className="ghost-button" href="/forgot-password"> <a className="ghost-button" href="/forgot-password">
Forgot password? Forgot password?
</a> </a>
) : null}
{loginOptions.showSignupLink ? (
<a className="ghost-button" href="/signup"> <a className="ghost-button" href="/signup">
Have an invite? Create your account (Jellyfin + Magent) Have an invite? Create your account (Jellyfin + Magent)
</a> </a>
) : null}
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
) : null}
</form> </form>
</main> </main>
) )
+63 -11
View File
@@ -22,6 +22,17 @@ const normalizeRecentResults = (items: any[]) =>
} }
}) })
const REQUEST_STAGE_OPTIONS = [
{ value: 'all', label: 'All stages' },
{ value: 'pending', label: 'Waiting' },
{ value: 'approved', label: 'Approved' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'working', label: 'Working' },
{ value: 'partial', label: 'Partial' },
{ value: 'ready', label: 'Ready' },
{ value: 'declined', label: 'Declined' },
]
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -38,11 +49,20 @@ export default function HomePage() {
const [recentError, setRecentError] = useState<string | null>(null) const [recentError, setRecentError] = useState<string | null>(null)
const [recentLoading, setRecentLoading] = useState(false) const [recentLoading, setRecentLoading] = useState(false)
const [searchResults, setSearchResults] = useState< const [searchResults, setSearchResults] = useState<
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[] {
title: string
year?: number
type?: string
requestId?: number
statusLabel?: string
requestedBy?: string | null
accessible?: boolean
}[]
>([]) >([])
const [searchError, setSearchError] = useState<string | null>(null) const [searchError, setSearchError] = useState<string | null>(null)
const [role, setRole] = useState<string | null>(null) const [role, setRole] = useState<string | null>(null)
const [recentDays, setRecentDays] = useState(90) const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState< const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null { overall: string; services: { name: string; status: string; message?: string }[] } | null
@@ -143,9 +163,14 @@ export default function HomePage() {
setRole(userRole) setRole(userRole)
setAuthReady(true) setAuthReady(true)
const take = userRole === 'admin' ? 50 : 6 const take = userRole === 'admin' ? 50 : 6
const response = await authFetch( const params = new URLSearchParams({
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}` take: String(take),
) days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('stage', recentStage)
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken()
@@ -167,7 +192,7 @@ export default function HomePage() {
} }
load() load()
}, [recentDays]) }, [recentDays, recentStage])
useEffect(() => { useEffect(() => {
if (!authReady) { if (!authReady) {
@@ -222,7 +247,14 @@ export default function HomePage() {
try { try {
const streamToken = await getEventStreamToken() const streamToken = await getEventStreamToken()
if (closed) return if (closed) return
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}` const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
})
if (recentStage !== 'all') {
params.set('recent_stage', recentStage)
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
source = new EventSource(streamUrl) source = new EventSource(streamUrl)
source.onopen = () => { source.onopen = () => {
@@ -282,7 +314,7 @@ export default function HomePage() {
setLiveStreamConnected(false) setLiveStreamConnected(false)
source?.close() source?.close()
} }
}, [authReady, recentDays]) }, [authReady, recentDays, recentStage])
const runSearch = async (term: string) => { const runSearch = async (term: string) => {
try { try {
@@ -305,6 +337,8 @@ export default function HomePage() {
type: item.type, type: item.type,
requestId: item.requestId, requestId: item.requestId,
statusLabel: item.statusLabel, statusLabel: item.statusLabel,
requestedBy: item.requestedBy ?? null,
accessible: Boolean(item.accessible),
})) }))
) )
setSearchError(null) setSearchError(null)
@@ -403,6 +437,7 @@ export default function HomePage() {
<div className="recent-header"> <div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2> <h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && ( {authReady && (
<div className="recent-filter-group">
<label className="recent-filter"> <label className="recent-filter">
<span>Show</span> <span>Show</span>
<select <select
@@ -416,6 +451,20 @@ export default function HomePage() {
<option value={180}>180 days</option> <option value={180}>180 days</option>
</select> </select>
</label> </label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
)} )}
</div> </div>
<div className="recent-grid"> <div className="recent-grid">
@@ -467,9 +516,10 @@ export default function HomePage() {
<aside className="side-panel"> <aside className="side-panel">
<section className="main-panel find-panel"> <section className="main-panel find-panel">
<div className="find-header"> <div className="find-header">
<h1>Find my request</h1> <h1>Search all requests</h1>
<p className="lede"> <p className="lede">
Search by title + year, paste a request number, or pick from your recent requests. Search any request by title + year or request number and see whether it already
exists in the system.
</p> </p>
</div> </div>
<div className="find-controls"> <div className="find-controls">
@@ -518,14 +568,16 @@ export default function HomePage() {
key={`${item.title || 'Untitled'}-${index}`} key={`${item.title || 'Untitled'}-${index}`}
type="button" type="button"
disabled={!item.requestId} disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)} onClick={() =>
item.requestId && router.push(`/requests/${item.requestId}`)
}
> >
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '} {item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId {!item.requestId
? '- not requested' ? '- not requested'
: item.statusLabel : item.statusLabel
? `- ${item.statusLabel}` ? `- ${item.statusLabel}`
: ''} : '- already requested'}
</button> </button>
)) ))
)} )}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function IssuePortalPage() {
return <PortalClient workspace="issue" />
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function PortalIndexPage() {
redirect('/portal/requests')
}
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function RequestPortalPage() {
return <PortalClient workspace="request" />
}
+18 -4
View File
@@ -82,6 +82,8 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
export default function ProfileInvitesPage() { export default function ProfileInvitesPage() {
const router = useRouter() const router = useRouter()
const [profile, setProfile] = useState<ProfileInfo | null>(null) const [profile, setProfile] = useState<ProfileInfo | null>(null)
@@ -192,6 +194,17 @@ export default function ProfileInvitesPage() {
const saveInvite = async (event: React.FormEvent) => { const saveInvite = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setInviteError('Recipient email is required.')
setInviteStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
setInviteError('Recipient email must be valid.')
setInviteStatus(null)
return
}
setInviteSaving(true) setInviteSaving(true)
setInviteError(null) setInviteError(null)
setInviteStatus(null) setInviteStatus(null)
@@ -208,7 +221,7 @@ export default function ProfileInvitesPage() {
code: inviteForm.code || null, code: inviteForm.code || null,
label: inviteForm.label || null, label: inviteForm.label || null,
description: inviteForm.description || null, description: inviteForm.description || null,
recipient_email: inviteForm.recipient_email || null, recipient_email: recipientEmail,
max_uses: inviteForm.max_uses || null, max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null, expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled, enabled: inviteForm.enabled,
@@ -438,13 +451,14 @@ export default function ProfileInvitesPage() {
<div className="invite-form-row"> <div className="invite-form-row">
<div className="invite-form-row-label"> <div className="invite-form-row-label">
<span>Delivery</span> <span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small> <small>Recipient email is required. You can also send the invite immediately after saving.</small>
</div> </div>
<div className="invite-form-row-control invite-form-row-control--stacked"> <div className="invite-form-row-control invite-form-row-control--stacked">
<label> <label>
<span>Recipient email</span> <span>Recipient email (required)</span>
<input <input
type="email" type="email"
required
value={inviteForm.recipient_email} value={inviteForm.recipient_email}
onChange={(event) => onChange={(event) =>
setInviteForm((current) => ({ setInviteForm((current) => ({
@@ -452,7 +466,7 @@ export default function ProfileInvitesPage() {
recipient_email: event.target.value, recipient_email: event.target.value,
})) }))
} }
placeholder="friend@example.com" placeholder="Required recipient email"
/> />
</label> </label>
<label> <label>
+69 -3
View File
@@ -55,6 +55,44 @@ type ActionHistory = {
created_at: string created_at: string
} }
const readApiError = async (response: Response, fallback: string) => {
try {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
return payload.detail
}
if (typeof payload?.message === 'string' && payload.message.trim()) {
return payload.message
}
} else {
const text = await response.text()
if (text.trim()) {
return text.trim()
}
}
} catch (error) {
console.error(error)
}
return fallback
}
const isSnapshotPayload = (value: unknown): value is Snapshot => {
if (!value || typeof value !== 'object') {
return false
}
const snapshot = value as Partial<Snapshot>
return (
typeof snapshot.request_id === 'string' &&
typeof snapshot.title === 'string' &&
typeof snapshot.request_type === 'string' &&
typeof snapshot.state === 'string' &&
Array.isArray(snapshot.timeline) &&
Array.isArray(snapshot.actions)
)
}
const percentFromTorrent = (torrent: Record<string, any>) => { const percentFromTorrent = (torrent: Record<string, any>) => {
const progress = Number(torrent.progress) const progress = Number(torrent.progress)
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) { if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) {
@@ -201,6 +239,7 @@ export default function RequestTimelinePage() {
const router = useRouter() const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null) const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [showDetails, setShowDetails] = useState(false) const [showDetails, setShowDetails] = useState(false)
const [actionMessage, setActionMessage] = useState<string | null>(null) const [actionMessage, setActionMessage] = useState<string | null>(null)
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([]) const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
@@ -214,6 +253,9 @@ export default function RequestTimelinePage() {
return return
} }
const load = async () => { const load = async () => {
setLoading(true)
setLoadError(null)
setSnapshot(null)
try { try {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -226,12 +268,22 @@ export default function RequestTimelinePage() {
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`), authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`),
]) ])
if (snapshotResponse.status === 401) { const authExpired = [snapshotResponse, historyResponse, actionsResponse].some(
(response) => response.status === 401
)
if (authExpired) {
clearToken() clearToken()
router.push('/login') router.push('/login')
return return
} }
if (!snapshotResponse.ok) {
const message = await readApiError(snapshotResponse, 'Unable to load this request.')
throw new Error(message)
}
const snapshotData = await snapshotResponse.json() const snapshotData = await snapshotResponse.json()
if (!isSnapshotPayload(snapshotData)) {
throw new Error('Unable to load this request.')
}
setSnapshot(snapshotData) setSnapshot(snapshotData)
setReleaseOptions([]) setReleaseOptions([])
setSearchRan(false) setSearchRan(false)
@@ -251,6 +303,9 @@ export default function RequestTimelinePage() {
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
setLoadError(
error instanceof Error && error.message ? error.message : 'Unable to load this request.'
)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -328,8 +383,12 @@ export default function RequestTimelinePage() {
) )
} }
if (loadError) {
return <main className="card">{loadError}</main>
}
if (!snapshot) { if (!snapshot) {
return <main className="card">Could not load that request.</main> return <main className="card">Unable to load this request.</main>
} }
const summary = const summary =
@@ -369,6 +428,13 @@ export default function RequestTimelinePage() {
const posterUrl = snapshot.artwork?.poster_url const posterUrl = snapshot.artwork?.poster_url
const resolvedPoster = const resolvedPoster =
posterUrl && posterUrl.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null posterUrl && posterUrl.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
const hasPartialReadyTimeline = snapshot.timeline.some(
(hop) => hop.service === 'Seerr' && hop.status === 'Partially ready'
)
const currentStatusText =
snapshot.state === 'IMPORTING' && hasPartialReadyTimeline
? 'Partially ready'
: friendlyState(snapshot.state)
return ( return (
<main className="card"> <main className="card">
@@ -400,7 +466,7 @@ export default function RequestTimelinePage() {
<section className="status-box"> <section className="status-box">
<div> <div>
<h2>Status</h2> <h2>Status</h2>
<p className="status-text">{friendlyState(snapshot.state)}</p> <p className="status-text">{currentStatusText}</p>
</div> </div>
<div> <div>
<h2>What this means</h2> <h2>What this means</h2>
+4 -3
View File
@@ -106,6 +106,7 @@ function SignupPageContent() {
const response = await fetch(`${baseUrl}/auth/signup`, { const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
invite_code: inviteCode, invite_code: inviteCode,
username: username.trim(), username: username.trim(),
@@ -117,12 +118,12 @@ function SignupPageContent() {
throw new Error(text || 'Sign-up failed') throw new Error(text || 'Sign-up failed')
} }
const data = await response.json() const data = await response.json()
if (data?.access_token) { if (data?.authenticated) {
setToken(data.access_token) setToken('cookie')
window.location.href = '/' window.location.href = '/'
return return
} }
throw new Error('Sign-up did not return a token') throw new Error('Sign-up did not complete')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.') setError(err instanceof Error ? err.message : 'Unable to create account.')
+100
View File
@@ -56,6 +56,21 @@ type AdminDiagnosticsPanelProps = {
embedded?: boolean embedded?: boolean
} }
type DatabaseDiagnosticDetail = {
integrity_check?: string
database_path?: string
database_size_bytes?: number
wal_size_bytes?: number
shm_size_bytes?: number
page_size_bytes?: number
page_count?: number
freelist_pages?: number
allocated_bytes?: number
free_bytes?: number
row_counts?: Record<string, number>
timings_ms?: Record<string, number>
}
const REFRESH_INTERVAL_MS = 30000 const REFRESH_INTERVAL_MS = 30000
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -85,6 +100,54 @@ function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status return STATUS_LABELS[status] ?? status
} }
function formatBytes(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
return '0 B'
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${value} B`
}
function formatDetailLabel(value: string) {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
return null
}
return detail as DatabaseDiagnosticDetail
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null
}
return (
<div className="diagnostic-detail-group">
<h4>{title}</h4>
<div className="diagnostic-detail-grid">
{values.map(([label, value]) => (
<div key={`${title}-${label}`} className="diagnostic-detail-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
)
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) { export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter() const router = useRouter()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -405,6 +468,43 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<span className="system-dot" /> <span className="system-dot" />
<span>{isRunning ? 'Running diagnostic...' : check.message}</span> <span>{isRunning ? 'Running diagnostic...' : check.message}</span>
</div> </div>
{check.key === 'database'
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail)
if (!detail) {
return null
}
return (
<div className="diagnostic-detail-panel">
{renderDatabaseMetricGroup('Storage', [
['Database file', formatBytes(detail.database_size_bytes)],
['WAL file', formatBytes(detail.wal_size_bytes)],
['Shared memory', formatBytes(detail.shm_size_bytes)],
['Allocated bytes', formatBytes(detail.allocated_bytes)],
['Free bytes', formatBytes(detail.free_bytes)],
['Page size', formatBytes(detail.page_size_bytes)],
['Page count', `${detail.page_count?.toLocaleString() ?? 0}`],
['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
'Tables',
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
'Timings',
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</div>
)
})()
: null}
</article> </article>
) )
})} })}
+2
View File
@@ -42,6 +42,8 @@ export default function HeaderActions() {
<div className="header-actions-right"> <div className="header-actions-right">
<a href="/">Requests</a> <a href="/">Requests</a>
<a href="/profile/invites">Invites</a> <a href="/profile/invites">Invites</a>
<a href="/portal/requests">Portal</a>
<a href="/portal/issues">Issues</a>
</div> </div>
</div> </div>
) )
+4 -3
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
export default function HeaderIdentity() { export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null) const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
@@ -49,7 +49,8 @@ export default function HeaderIdentity() {
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}` const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase() const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = () => { const signOut = async () => {
await logout().catch(() => undefined)
clearToken() clearToken()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login' window.location.href = '/login'
@@ -83,7 +84,7 @@ export default function HeaderIdentity() {
<a href="/changelog" onClick={() => setOpen(false)}> <a href="/changelog" onClick={() => setOpen(false)}>
Changelog Changelog
</a> </a>
<button type="button" className="signed-in-signout" onClick={signOut}> <button type="button" className="signed-in-signout" onClick={() => void signOut()}>
Sign out Sign out
</button> </button>
</div> </div>
+5
View File
@@ -20,6 +20,7 @@ type UserStats = {
type AdminUser = { type AdminUser = {
id?: number id?: number
username: string username: string
email?: string | null
role: string role: string
auth_provider?: string | null auth_provider?: string | null
last_login_at?: string | null last_login_at?: string | null
@@ -459,6 +460,10 @@ export default function UserDetailPage() {
</p> </p>
</div> </div>
<div className="user-detail-meta-grid"> <div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Email</span>
<strong>{user.email || 'Not set'}</strong>
</div>
<div className="user-detail-meta-item"> <div className="user-detail-meta-item">
<span className="label">Seerr ID</span> <span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong> <strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
+6
View File
@@ -9,6 +9,7 @@ import AdminShell from '../ui/AdminShell'
type AdminUser = { type AdminUser = {
id: number id: number
username: string username: string
email?: string | null
role: string role: string
authProvider?: string | null authProvider?: string | null
lastLoginAt?: string | null lastLoginAt?: string | null
@@ -109,6 +110,7 @@ export default function UsersPage() {
setUsers( setUsers(
data.users.map((user: any) => ({ data.users.map((user: any) => ({
username: user.username ?? 'Unknown', username: user.username ?? 'Unknown',
email: user.email ?? null,
role: user.role ?? 'user', role: user.role ?? 'user',
authProvider: user.auth_provider ?? 'local', authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null, lastLoginAt: user.last_login_at ?? null,
@@ -239,6 +241,7 @@ export default function UsersPage() {
? users.filter((user) => { ? users.filter((user) => {
const fields = [ const fields = [
user.username, user.username,
user.email || '',
user.role, user.role,
user.authProvider || '', user.authProvider || '',
user.profileId != null ? String(user.profileId) : '', user.profileId != null ? String(user.profileId) : '',
@@ -419,6 +422,9 @@ export default function UsersPage() {
<strong>{user.username}</strong> <strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span> <span className="user-grid-meta">{user.role}</span>
</div> </div>
<div className="user-directory-subtext">
{user.email || 'No email on file'}
</div>
<div className="user-directory-subtext"> <div className="user-directory-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'} Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</div> </div>
+4
View File
@@ -1,2 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0203262044", "version": "0803262237",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0203262044", "version": "0803262237",
"dependencies": { "dependencies": {
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.4", "react": "19.2.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "0203262044", "version": "0803262237",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
+5
View File
@@ -3,6 +3,11 @@ $ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\\.." $repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot Set-Location $repoRoot
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
if ($LASTEXITCODE -ne 0) {
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
}
$now = Get-Date $now = Get-Date
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm") $buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
python_bin="${PYTHON_BIN:-python3}"
echo "Installing backend Python requirements"
"$python_bin" -m pip install -r backend/requirements.txt
echo "Running Python dependency integrity check"
"$python_bin" -m pip check
echo "Running backend unit tests"
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
echo "Backend quality gate passed"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
docker compose up -d --build
"
echo "Running remote smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8000/health', 200),
('http://127.0.0.1:3000/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Deployment completed successfully"
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import csv
import json
import sqlite3
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
def _normalize_email(value: object) -> str | None:
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open("r", encoding="utf-8", newline="") as handle:
return [dict(row) for row in csv.DictReader(handle)]
def _ensure_email_column(conn: sqlite3.Connection) -> None:
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT id, username, email
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
rows = _load_rows(csv_path)
username_counts = Counter(
str(row.get("Username") or "").strip().lower()
for row in rows
if str(row.get("Username") or "").strip()
)
duplicate_usernames = {
username for username, count in username_counts.items() if username and count > 1
}
summary: dict[str, object] = {
"csv_path": str(csv_path),
"db_path": str(db_path),
"source_rows": len(rows),
"updated": 0,
"unchanged": 0,
"missing_email": [],
"missing_user": [],
"duplicate_source_username": [],
}
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
_ensure_email_column(conn)
for row in rows:
username = str(row.get("Username") or "").strip()
if not username:
continue
username_key = username.lower()
if username_key in duplicate_usernames:
cast_list = summary["duplicate_source_username"]
assert isinstance(cast_list, list)
if username not in cast_list:
cast_list.append(username)
continue
email = _normalize_email(row.get("Email"))
if not email:
cast_list = summary["missing_email"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
matches = _lookup_user(conn, username)
if not matches:
cast_list = summary["missing_user"]
assert isinstance(cast_list, list)
cast_list.append(username)
continue
current_emails = {
normalized.lower()
for normalized in (_normalize_email(row["email"]) for row in matches)
if normalized
}
if current_emails == {email.lower()}:
summary["unchanged"] = int(summary["unchanged"]) + 1
continue
conn.execute(
"""
UPDATE users
SET email = ?
WHERE username = ? COLLATE NOCASE
""",
(email, username),
)
summary["updated"] = int(summary["updated"]) + 1
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
parser.add_argument(
"csv_path",
nargs="?",
default=str(DEFAULT_CSV_PATH),
help="CSV file containing Username and Email columns",
)
parser.add_argument(
"--db-path",
default=str(DEFAULT_DB_PATH),
help="Path to the Magent SQLite database",
)
args = parser.parse_args()
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
print(json.dumps(summary, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
+4
View File
@@ -243,6 +243,10 @@ try {
$script:CurrentStep = "updating build metadata" $script:CurrentStep = "updating build metadata"
Update-BuildFiles -BuildNumber $buildNumber Update-BuildFiles -BuildNumber $buildNumber
$script:CurrentStep = "running backend quality gate"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1"
$script:CurrentStep = "rebuilding local docker stack" $script:CurrentStep = "rebuilding local docker stack"
docker compose up -d --build docker compose up -d --build
Assert-LastExitCode -CommandName "docker compose up -d --build" Assert-LastExitCode -CommandName "docker compose up -d --build"
+59
View File
@@ -0,0 +1,59 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)
if ($LASTEXITCODE -ne 0) {
throw "$CommandName failed with exit code $LASTEXITCODE."
}
}
function Get-PythonCommand {
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
return "python"
}
function Ensure-PythonModule {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$ModuleName,
[Parameter(Mandatory = $true)][string]$PackageName
)
& $PythonExe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)"
if ($LASTEXITCODE -eq 0) {
return
}
Write-Host "Installing missing Python package: $PackageName"
& $PythonExe -m pip install $PackageName
Assert-LastExitCode -CommandName "python -m pip install $PackageName"
}
$pythonExe = Get-PythonCommand
Write-Host "Installing backend Python requirements"
& $pythonExe -m pip install -r (Join-Path $repoRoot "backend\requirements.txt")
Assert-LastExitCode -CommandName "python -m pip install -r backend/requirements.txt"
Write-Host "Running Python dependency integrity check"
& $pythonExe -m pip check
Assert-LastExitCode -CommandName "python -m pip check"
Ensure-PythonModule -PythonExe $pythonExe -ModuleName "pip_audit" -PackageName "pip-audit"
Write-Host "Running Python vulnerability scan"
& $pythonExe -m pip_audit -r (Join-Path $repoRoot "backend\requirements.txt") --progress-spinner off --desc
Assert-LastExitCode -CommandName "python -m pip_audit"
Write-Host "Running backend unit tests"
& $pythonExe -m unittest discover -s backend/tests -p "test_*.py" -v
Assert-LastExitCode -CommandName "python -m unittest discover"
Write-Host "Backend quality gate passed"