chore: standardize security and quality foundations
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 '')
|
||||
|
||||
Reference in New Issue
Block a user