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