Compare commits

...

13 Commits

Author SHA1 Message Date
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
38 changed files with 4628 additions and 147 deletions
+1 -1
View File
@@ -1 +1 @@
0403261736 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
+88 -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"
@@ -122,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")
+26 -3
View File
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
sqlite_journal_mode: str = Field( sqlite_journal_mode: str = Field(
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE") default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
) )
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET")) jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) jwt_exp_minutes: int = Field(default=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(
@@ -34,7 +34,22 @@ class Settings(BaseSettings):
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER") 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(
@@ -121,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")
) )
@@ -216,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")
@@ -288,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"),
) )
+679 -1
View File
@@ -20,6 +20,9 @@ SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
SQLITE_BUSY_TIMEOUT_MS = 5_000 SQLITE_BUSY_TIMEOUT_MS = 5_000
SQLITE_CACHE_SIZE_KIB = 32_768 SQLITE_CACHE_SIZE_KIB = 32_768
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024 SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
_DB_UNSET = object()
_DEFAULT_JWT_SECRET = "change-me"
_DEFAULT_ADMIN_PASSWORD = "adminadmin"
def _db_path() -> str: def _db_path() -> str:
@@ -177,6 +180,11 @@ def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
return candidate return candidate
def _has_secure_bootstrap_admin_credentials() -> bool:
password = str(settings.admin_password or "")
return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
@@ -349,6 +357,49 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS portal_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
media_type TEXT,
year INTEGER,
external_ref TEXT,
source_system TEXT,
source_request_id INTEGER,
related_item_id INTEGER,
status TEXT NOT NULL,
workflow_request_status TEXT,
workflow_media_status TEXT,
issue_type TEXT,
issue_resolved_at TEXT,
metadata_json TEXT,
priority TEXT NOT NULL,
created_by_username TEXT NOT NULL,
created_by_id INTEGER,
assignee_username TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_activity_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS portal_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
author_username TEXT NOT NULL,
author_role TEXT NOT NULL,
message TEXT NOT NULL,
is_internal INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
)
"""
)
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -367,12 +418,16 @@ def init_db() -> None:
ON requests_cache (updated_at DESC, request_id DESC) ON requests_cache (updated_at DESC, request_id DESC)
""" """
) )
try:
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
ON requests_cache (requested_by_id, created_at DESC, request_id DESC) ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
""" """
) )
except sqlite3.OperationalError:
# Older databases may not have requested_by_id until later migrations run.
pass
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
@@ -409,6 +464,48 @@ def init_db() -> None:
ON password_reset_tokens (expires_at) ON password_reset_tokens (expires_at)
""" """
) )
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_kind_status
ON portal_items (kind, status, updated_at DESC, id DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_creator
ON portal_items (created_by_username, updated_at DESC, id DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_status
ON portal_items (status, updated_at DESC, id DESC)
"""
)
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
ON portal_comments (item_id, created_at DESC, id DESC)
"""
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS user_activity ( CREATE TABLE IF NOT EXISTS user_activity (
@@ -491,6 +588,48 @@ def init_db() -> None:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT") conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try: try:
conn.execute( conn.execute(
""" """
@@ -639,7 +778,7 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
def ensure_admin_user() -> None: def ensure_admin_user() -> None:
if not settings.admin_username or not settings.admin_password: if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
return return
existing = get_user_by_username(settings.admin_username) existing = get_user_by_username(settings.admin_username)
if existing: if existing:
@@ -647,6 +786,14 @@ def ensure_admin_user() -> None:
create_user(settings.admin_username, settings.admin_password, role="admin") create_user(settings.admin_username, settings.admin_password, role="admin")
def has_admin_user() -> bool:
with _connect() as conn:
row = conn.execute(
"SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
).fetchone()
return bool(row)
def create_user( def create_user(
username: str, username: str,
password: str, password: str,
@@ -2879,6 +3026,535 @@ def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int])
) )
def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
return {
"id": row[0],
"kind": row[1],
"title": row[2],
"description": row[3],
"media_type": row[4],
"year": row[5],
"external_ref": row[6],
"source_system": row[7],
"source_request_id": row[8],
"related_item_id": row[9],
"status": row[10],
"workflow_request_status": row[11],
"workflow_media_status": row[12],
"issue_type": row[13],
"issue_resolved_at": row[14],
"metadata_json": row[15],
"priority": row[16],
"created_by_username": row[17],
"created_by_id": row[18],
"assignee_username": row[19],
"created_at": row[20],
"updated_at": row[21],
"last_activity_at": row[22],
}
def _portal_comment_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
return {
"id": row[0],
"item_id": row[1],
"author_username": row[2],
"author_role": row[3],
"message": row[4],
"is_internal": bool(row[5]),
"created_at": row[6],
}
def create_portal_item(
*,
kind: str,
title: str,
description: str,
created_by_username: str,
created_by_id: Optional[int],
media_type: Optional[str] = None,
year: Optional[int] = None,
external_ref: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
status: str = "new",
workflow_request_status: Optional[str] = None,
workflow_media_status: Optional[str] = None,
issue_type: Optional[str] = None,
issue_resolved_at: Optional[str] = None,
metadata_json: Optional[str] = None,
priority: str = "normal",
assignee_username: Optional[str] = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO portal_items (
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
created_at,
updated_at,
last_activity_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
now,
now,
now,
),
)
item_id = cursor.lastrowid
created = get_portal_item(item_id)
if not created:
raise RuntimeError("Portal item could not be loaded after insert.")
logger.info(
"portal item created id=%s kind=%s status=%s priority=%s created_by=%s",
created["id"],
created["kind"],
created["status"],
created["priority"],
created["created_by_username"],
)
return created
def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT
id,
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
created_at,
updated_at,
last_activity_at
FROM portal_items
WHERE id = ?
""",
(item_id,),
).fetchone()
return _portal_item_from_row(row) if row else None
def list_portal_items(
*,
kind: Optional[str] = None,
status: Optional[str] = None,
workflow_request_status: Optional[str] = None,
workflow_media_status: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
mine_username: Optional[str] = None,
search: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> list[Dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if isinstance(kind, str) and kind.strip():
clauses.append("kind = ?")
params.append(kind.strip().lower())
if isinstance(status, str) and status.strip():
clauses.append("status = ?")
params.append(status.strip().lower())
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
clauses.append("workflow_request_status = ?")
params.append(workflow_request_status.strip().lower())
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
clauses.append("workflow_media_status = ?")
params.append(workflow_media_status.strip().lower())
if isinstance(source_system, str) and source_system.strip():
clauses.append("source_system = ?")
params.append(source_system.strip().lower())
if isinstance(source_request_id, int):
clauses.append("source_request_id = ?")
params.append(source_request_id)
if isinstance(related_item_id, int):
clauses.append("related_item_id = ?")
params.append(related_item_id)
if isinstance(mine_username, str) and mine_username.strip():
clauses.append("created_by_username = ?")
params.append(mine_username.strip())
if isinstance(search, str) and search.strip():
token = f"%{search.strip().lower()}%"
clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
params.extend([token, token, search.strip()])
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
safe_limit = max(1, min(int(limit), 500))
safe_offset = max(0, int(offset))
params.extend([safe_limit, safe_offset])
with _connect() as conn:
rows = conn.execute(
f"""
SELECT
id,
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
created_at,
updated_at,
last_activity_at
FROM portal_items
{where_sql}
ORDER BY last_activity_at DESC, id DESC
LIMIT ? OFFSET ?
""",
tuple(params),
).fetchall()
return [_portal_item_from_row(row) for row in rows]
def count_portal_items(
*,
kind: Optional[str] = None,
status: Optional[str] = None,
workflow_request_status: Optional[str] = None,
workflow_media_status: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
mine_username: Optional[str] = None,
search: Optional[str] = None,
) -> int:
clauses: list[str] = []
params: list[Any] = []
if isinstance(kind, str) and kind.strip():
clauses.append("kind = ?")
params.append(kind.strip().lower())
if isinstance(status, str) and status.strip():
clauses.append("status = ?")
params.append(status.strip().lower())
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
clauses.append("workflow_request_status = ?")
params.append(workflow_request_status.strip().lower())
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
clauses.append("workflow_media_status = ?")
params.append(workflow_media_status.strip().lower())
if isinstance(source_system, str) and source_system.strip():
clauses.append("source_system = ?")
params.append(source_system.strip().lower())
if isinstance(source_request_id, int):
clauses.append("source_request_id = ?")
params.append(source_request_id)
if isinstance(related_item_id, int):
clauses.append("related_item_id = ?")
params.append(related_item_id)
if isinstance(mine_username, str) and mine_username.strip():
clauses.append("created_by_username = ?")
params.append(mine_username.strip())
if isinstance(search, str) and search.strip():
token = f"%{search.strip().lower()}%"
clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
params.extend([token, token, search.strip()])
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with _connect() as conn:
row = conn.execute(
f"SELECT COUNT(*) FROM portal_items {where_sql}",
tuple(params),
).fetchone()
return int(row[0] or 0) if row else 0
def update_portal_item(
item_id: int,
*,
title: Any = _DB_UNSET,
description: Any = _DB_UNSET,
status: Any = _DB_UNSET,
priority: Any = _DB_UNSET,
assignee_username: Any = _DB_UNSET,
media_type: Any = _DB_UNSET,
year: Any = _DB_UNSET,
external_ref: Any = _DB_UNSET,
source_system: Any = _DB_UNSET,
source_request_id: Any = _DB_UNSET,
related_item_id: Any = _DB_UNSET,
workflow_request_status: Any = _DB_UNSET,
workflow_media_status: Any = _DB_UNSET,
issue_type: Any = _DB_UNSET,
issue_resolved_at: Any = _DB_UNSET,
metadata_json: Any = _DB_UNSET,
) -> Optional[Dict[str, Any]]:
updates: list[str] = []
params: list[Any] = []
if title is not _DB_UNSET:
updates.append("title = ?")
params.append(title)
if description is not _DB_UNSET:
updates.append("description = ?")
params.append(description)
if status is not _DB_UNSET:
updates.append("status = ?")
params.append(status)
if priority is not _DB_UNSET:
updates.append("priority = ?")
params.append(priority)
if assignee_username is not _DB_UNSET:
updates.append("assignee_username = ?")
params.append(assignee_username)
if media_type is not _DB_UNSET:
updates.append("media_type = ?")
params.append(media_type)
if year is not _DB_UNSET:
updates.append("year = ?")
params.append(year)
if external_ref is not _DB_UNSET:
updates.append("external_ref = ?")
params.append(external_ref)
if source_system is not _DB_UNSET:
updates.append("source_system = ?")
params.append(source_system)
if source_request_id is not _DB_UNSET:
updates.append("source_request_id = ?")
params.append(source_request_id)
if related_item_id is not _DB_UNSET:
updates.append("related_item_id = ?")
params.append(related_item_id)
if workflow_request_status is not _DB_UNSET:
updates.append("workflow_request_status = ?")
params.append(workflow_request_status)
if workflow_media_status is not _DB_UNSET:
updates.append("workflow_media_status = ?")
params.append(workflow_media_status)
if issue_type is not _DB_UNSET:
updates.append("issue_type = ?")
params.append(issue_type)
if issue_resolved_at is not _DB_UNSET:
updates.append("issue_resolved_at = ?")
params.append(issue_resolved_at)
if metadata_json is not _DB_UNSET:
updates.append("metadata_json = ?")
params.append(metadata_json)
if not updates:
return get_portal_item(item_id)
now = datetime.now(timezone.utc).isoformat()
updates.append("updated_at = ?")
updates.append("last_activity_at = ?")
params.extend([now, now, item_id])
with _connect() as conn:
changed = conn.execute(
f"""
UPDATE portal_items
SET {', '.join(updates)}
WHERE id = ?
""",
tuple(params),
).rowcount
if not changed:
return None
updated = get_portal_item(item_id)
if updated:
logger.info(
"portal item updated id=%s status=%s priority=%s assignee=%s",
updated["id"],
updated["status"],
updated["priority"],
updated["assignee_username"],
)
return updated
def add_portal_comment(
item_id: int,
*,
author_username: str,
author_role: str,
message: str,
is_internal: bool = False,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO portal_comments (
item_id,
author_username,
author_role,
message,
is_internal,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
item_id,
author_username,
author_role,
message,
1 if is_internal else 0,
now,
),
)
conn.execute(
"""
UPDATE portal_items
SET last_activity_at = ?, updated_at = ?
WHERE id = ?
""",
(now, now, item_id),
)
comment_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, item_id, author_username, author_role, message, is_internal, created_at
FROM portal_comments
WHERE id = ?
""",
(comment_id,),
).fetchone()
if not row:
raise RuntimeError("Portal comment could not be loaded after insert.")
comment = _portal_comment_from_row(row)
logger.info(
"portal comment created id=%s item_id=%s author=%s internal=%s",
comment["id"],
comment["item_id"],
comment["author_username"],
comment["is_internal"],
)
return comment
def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: int = 200) -> list[Dict[str, Any]]:
clauses = ["item_id = ?"]
params: list[Any] = [item_id]
if not include_internal:
clauses.append("is_internal = 0")
safe_limit = max(1, min(int(limit), 500))
params.append(safe_limit)
with _connect() as conn:
rows = conn.execute(
f"""
SELECT id, item_id, author_username, author_role, message, is_internal, created_at
FROM portal_comments
WHERE {' AND '.join(clauses)}
ORDER BY created_at ASC, id ASC
LIMIT ?
""",
tuple(params),
).fetchall()
return [_portal_comment_from_row(row) for row in rows]
def get_portal_overview() -> Dict[str, Any]:
with _connect() as conn:
kind_rows = conn.execute(
"""
SELECT kind, COUNT(*)
FROM portal_items
GROUP BY kind
"""
).fetchall()
status_rows = conn.execute(
"""
SELECT status, COUNT(*)
FROM portal_items
GROUP BY status
"""
).fetchall()
request_workflow_rows = conn.execute(
"""
SELECT
COALESCE(workflow_request_status, ''),
COALESCE(workflow_media_status, ''),
COUNT(*)
FROM portal_items
WHERE kind = 'request'
GROUP BY workflow_request_status, workflow_media_status
"""
).fetchall()
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
request_workflow: Dict[str, Dict[str, int]] = {}
for row in request_workflow_rows:
request_status = str(row[0] or "")
media_status = str(row[1] or "")
request_workflow.setdefault(request_status, {})
request_workflow[request_status][media_status] = int(row[2] or 0)
return {
"total_items": int(total_items_row[0] or 0) if total_items_row else 0,
"total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0,
"by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows},
"by_status": {str(row[0]): int(row[1] or 0) for row in status_rows},
"request_workflow": request_workflow,
}
def run_integrity_check() -> str: def run_integrity_check() -> str:
with _connect() as conn: with _connect() as conn:
row = conn.execute("PRAGMA integrity_check").fetchone() row = conn.execute("PRAGMA integrity_check").fetchone()
@@ -2922,6 +3598,8 @@ def get_database_diagnostics() -> Dict[str, Any]:
"snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0), "snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0),
"seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0), "seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0),
"password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0), "password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0),
"portal_items": int(conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()[0] or 0),
"portal_comments": int(conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()[0] or 0),
} }
row_count_ms = round((perf_counter() - row_count_started) * 1000, 1) row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)
+21 -5
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,
@@ -164,13 +165,15 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
def _log_security_configuration_warnings() -> None: def _log_security_configuration_warnings() -> None:
if str(settings.jwt_secret or "").strip() == "change-me": jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
logger.warning( logger.warning(
"security configuration warning: JWT_SECRET is still set to the default value" "security configuration warning: JWT_SECRET is unset or still set to the default value"
) )
if str(settings.admin_password or "") == "adminadmin": admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
logger.warning( logger.warning(
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default" "security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
) )
if bool(settings.api_docs_enabled): if bool(settings.api_docs_enabled):
logger.warning( logger.warning(
@@ -178,6 +181,17 @@ def _log_security_configuration_warnings() -> None:
) )
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(
@@ -191,6 +205,7 @@ async def startup() -> None:
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number) logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings() _log_security_configuration_warnings()
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,
@@ -228,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
+13
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,
@@ -153,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",
@@ -659,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)
+75 -39
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 (
@@ -47,8 +47,15 @@ from ..security import (
verify_password, 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, extract_jellyseerr_user_email,
@@ -96,6 +103,8 @@ def _require_recipient_email(value: object) -> str:
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()
@@ -358,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"),
@@ -580,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",
@@ -629,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",
@@ -668,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",
@@ -682,7 +708,7 @@ 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:
@@ -724,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",
@@ -745,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",
@@ -753,11 +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(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
@@ -791,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")
@@ -803,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(
@@ -832,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()
@@ -908,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.",
@@ -987,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")
+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
+151
View File
@@ -421,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)
@@ -1864,12 +1892,135 @@ async def search_requests(
"statusLabel": status_label, "statusLabel": status_label,
"requestedBy": requested_by, "requestedBy": requested_by,
"accessible": accessible, "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()
+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")
+4
View File
@@ -44,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")
@@ -55,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])
+32 -5
View File
@@ -17,6 +17,7 @@ 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 get_database_diagnostics 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()
+32
View File
@@ -1165,6 +1165,38 @@ async def send_templated_email(
} }
async def send_generic_email(
*,
recipient_email: str,
subject: str,
body_text: str,
body_html: str = "",
) -> Dict[str, str]:
ready, detail = smtp_email_config_ready()
if not ready:
raise RuntimeError(detail)
resolved_email = _normalize_email(recipient_email)
if not resolved_email:
raise RuntimeError("A valid recipient email is required.")
receipt = await asyncio.to_thread(
_send_email_sync,
recipient_email=resolved_email,
subject=subject.strip() or f"{env_settings.app_name} notification",
body_text=body_text.strip(),
body_html=body_html.strip(),
)
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
return {
"recipient_email": resolved_email,
"subject": subject.strip() or f"{env_settings.app_name} notification",
**{
key: value
for key, value in receipt.items()
if key in {"provider_message_id", "provider_internal_id", "data_response"}
},
}
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]: async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
ready, detail = smtp_email_config_ready() ready, detail = smtp_email_config_ready()
if not ready: if not ready:
+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}
+101
View File
@@ -8,7 +8,10 @@ from starlette.requests import Request
from backend.app import db from backend.app import db
from backend.app.config import settings from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.routers import auth as auth_router from backend.app.routers import auth as auth_router
from backend.app.routers import portal as portal_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset from backend.app.services import password_reset
@@ -71,6 +74,49 @@ class PasswordPolicyTests(unittest.TestCase):
self.assertEqual(validate_password_policy(" password123 "), "password123") 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 DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None: def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing( created = db.create_user_if_missing(
@@ -143,3 +189,58 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
context.exception.detail, context.exception.detail,
"recipient_email is required and must be a valid email address.", "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)
+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 {
+349
View File
@@ -3565,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 {
@@ -6558,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 ''
}
}
+3 -2
View File
@@ -42,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
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" />
}
+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.')
+1
View File
@@ -42,6 +42,7 @@ 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">Portal</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>
+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": "0403261736", "version": "0803262237",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0403261736", "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": "0403261736", "version": "0803262237",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
+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"