chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-17 20:03:47 +12:00
parent 5639dbcb83
commit f852e7c941
127 changed files with 17928 additions and 10741 deletions
+11 -9
View File
@@ -21,6 +21,7 @@ from ..auth import (
resolve_user_auth_provider,
)
from ..config import normalize_banner_color, settings as env_settings
from ..api_models import COMMON_ERROR_RESPONSES
from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
@@ -35,8 +36,6 @@ from ..db import (
get_user_by_id,
get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
delete_user_data_by_username,
@@ -59,7 +58,6 @@ from ..db import (
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles,
get_user_profile,
create_user_profile,
@@ -73,6 +71,7 @@ from ..db import (
delete_signup_invite,
get_signup_invite_by_code,
disable_signup_invites_by_creator,
delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -81,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
clear_user_import_caches,
@@ -109,7 +104,12 @@ from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
responses=COMMON_ERROR_RESPONSES,
)
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
@@ -247,6 +247,7 @@ SETTING_KEYS: List[str] = [
"qbittorrent_username",
"qbittorrent_password",
"log_level",
"log_format",
"log_file",
"log_file_max_bytes",
"log_file_backup_count",
@@ -741,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
set_setting(key, value_to_store)
updates += 1
changed_keys.append(key)
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
@@ -752,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
)
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates}
+24 -6
View File
@@ -60,6 +60,15 @@ from ..auth import (
set_auth_cookies,
)
from ..config import settings
from ..api_models import (
COMMON_ERROR_RESPONSES,
ChangePasswordRequest,
ForgotPasswordRequest,
PasswordResetRequest,
ProfileEmailUpdateRequest,
SignupRequest,
request_data,
)
from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import (
build_jellyseerr_candidate_map,
@@ -81,7 +90,7 @@ from ..services.password_reset import (
verify_password_reset_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES)
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
@@ -869,7 +878,8 @@ async def invite_details(code: str) -> dict:
@router.post("/signup")
async def signup(payload: dict, response: Response) -> dict:
async def signup(payload: SignupRequest, response: Response) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
@@ -1054,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict:
@router.post("/password/forgot")
async def forgot_password(payload: dict, request: Request) -> dict:
async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
@@ -1106,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict:
@router.post("/password/reset")
async def password_reset(payload: dict) -> dict:
async def password_reset(payload: PasswordResetRequest) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
token = payload.get("token")
@@ -1169,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email")
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
async def update_profile_email(
payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
username = str(current_user.get("username") or "").strip()
@@ -1435,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
async def change_password(
payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
current_password = payload.get("current_password") if isinstance(payload, dict) else None
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
+1 -1
View File
@@ -3,7 +3,7 @@ import warnings
from io import BytesIO
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image, ImageDraw, ImageFont
+1 -1
View File
@@ -3,7 +3,7 @@ import re
import mimetypes
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Response
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
import httpx
+7 -1
View File
@@ -11,6 +11,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..clients.jellyfin import JellyfinClient
from ..db import (
add_portal_item_activity,
@@ -34,7 +35,12 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
router = APIRouter(
prefix="/portal",
tags=["portal"],
dependencies=[Depends(get_current_user), Depends(require_portal_access)],
responses=COMMON_ERROR_RESPONSES,
)
logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
+1 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin
from ..auth import require_admin
from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
+13 -17
View File
@@ -20,6 +20,7 @@ from ..clients.sonarr import SonarrClient
from ..clients.bazarr import BazarrClient
from ..ai.triage import triage_snapshot
from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..runtime import get_runtime_settings
from .images import cache_tmdb_image, is_tmdb_cached
from ..db import (
@@ -30,7 +31,6 @@ from ..db import (
save_action,
get_recent_actions,
get_recent_snapshots,
get_cached_requests,
get_cached_requests_since,
get_cached_request_by_media_id,
get_request_cache_lookup,
@@ -62,6 +62,7 @@ from ..db import (
)
from ..services.media_repair import current_cycle_torrents
from ..services.download_labels import label_episode_downloads
from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path
from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import (
_summarize_qbit,
@@ -70,7 +71,12 @@ from ..services.snapshot import (
jellyfin_item_matches_request,
)
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
router = APIRouter(
prefix="/requests",
tags=["requests"],
dependencies=[Depends(get_current_user), Depends(require_request_access)],
responses=COMMON_ERROR_RESPONSES,
)
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
@@ -1753,7 +1759,6 @@ def _filter_arr_release_results(results: Any, include_rejected: bool = False) ->
"approved": accepted,
"rejected": item.get("rejected"),
"temporarilyRejected": item.get("temporarilyRejected"),
"rejections": item.get("rejections"),
"downloadAllowed": item.get("downloadAllowed"),
"fullSeason": item.get("fullSeason"),
"seasonNumber": item.get("seasonNumber"),
@@ -1971,16 +1976,10 @@ def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit():
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if folder.get("id") == int(root_folder):
path = folder.get("path")
if isinstance(path, str) and path:
return path
raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found")
return root_folder
try:
return await resolve_root_folder_path(client, root_folder, service_name)
except RootFolderNotFoundError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{request_id}/issue-options")
@@ -2979,7 +2978,6 @@ async def recent_requests(
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
mode = (runtime.requests_data_source or "prefer_cache").lower()
# Browsing is always local. Synchronization is owned by background workers.
allow_remote = False
username_norm = _normalize_username(user.get("username", ""))
@@ -3007,8 +3005,6 @@ async def recent_requests(
allow_title_hydrate = False
allow_artwork_hydrate = False
stage_cache = await asyncio.to_thread(get_request_stage_cache)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
results = []
for row in rows:
status = row.get("status")
@@ -3946,7 +3942,7 @@ async def action_grab(
release_title = receipt.get('title')
arr_error: Optional[str] = None
try:
response = await arr_client.grab_release(str(guid), arr_indexer_id)
await arr_client.grab_release(str(guid), arr_indexer_id)
action_message = (
f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')