security: harden data auth and deployment
This commit is contained in:
@@ -1593,11 +1593,54 @@ def get_requests_sync_state() -> Dict[str, Any]:
|
||||
|
||||
|
||||
async def _ensure_request_access(
|
||||
client: JellyseerrClient, request_id: int, user: Dict[str, str]
|
||||
) -> None:
|
||||
if user.get("role") == "admin" or user.get("username"):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Request not accessible for this user")
|
||||
client: JellyseerrClient,
|
||||
request_id: int,
|
||||
user: Dict[str, Any],
|
||||
*,
|
||||
require_owner: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if user.get("role") == "admin":
|
||||
return None
|
||||
if not user.get("username"):
|
||||
raise HTTPException(status_code=403, detail="Request not accessible for this user")
|
||||
if not require_owner:
|
||||
return None
|
||||
request_data = await client.get_request(str(request_id))
|
||||
if not isinstance(request_data, dict):
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
requester_id = _extract_requested_by_id(request_data)
|
||||
current_seerr_id = user.get("jellyseerr_user_id")
|
||||
if isinstance(current_seerr_id, int) and requester_id == current_seerr_id:
|
||||
return request_data
|
||||
if _request_matches_user(request_data, str(user.get("username") or "")):
|
||||
return request_data
|
||||
email = str(user.get("email") or "").strip()
|
||||
if email and _request_matches_user(request_data, email):
|
||||
return request_data
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the original requester or an administrator can change this request",
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_request_mutation_access(
|
||||
runtime: Any, request_id: int, user: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fail closed when a non-admin request owner cannot be verified."""
|
||||
if user.get("role") == "admin":
|
||||
return None
|
||||
client = JellyseerrClient(
|
||||
getattr(runtime, "jellyseerr_base_url", None),
|
||||
getattr(runtime, "jellyseerr_api_key", None),
|
||||
)
|
||||
if not client.configured():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Request ownership cannot be verified while Seerr is unavailable",
|
||||
)
|
||||
return await _ensure_request_access(
|
||||
client, request_id, user, require_owner=True
|
||||
)
|
||||
|
||||
|
||||
def _build_recent_map(response: Dict[str, Any]) -> Dict[int, Dict[str, Any]]:
|
||||
@@ -1948,9 +1991,6 @@ async def issue_target_options(
|
||||
if not request_id.isdigit():
|
||||
raise HTTPException(status_code=400, detail="Invalid request id")
|
||||
runtime = get_runtime_settings()
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if seerr.configured():
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict):
|
||||
@@ -2136,9 +2176,7 @@ async def action_replace_media(
|
||||
)
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if seerr.configured():
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict):
|
||||
@@ -2360,6 +2398,7 @@ async def action_search_missing_media(
|
||||
payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
|
||||
)
|
||||
runtime = get_runtime_settings()
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
|
||||
@@ -2497,9 +2536,7 @@ async def action_add_seasons(
|
||||
raise HTTPException(status_code=400, detail="Choose at least one season")
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if seerr.configured():
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
if snapshot.request_type != RequestType.tv:
|
||||
raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
|
||||
@@ -2627,6 +2664,7 @@ async def action_repair_subtitles(
|
||||
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
|
||||
forced = payload.get("forced") is True
|
||||
runtime = get_runtime_settings()
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
|
||||
if not bazarr.configured() or not runtime.bazarr_api_key:
|
||||
raise HTTPException(status_code=400, detail="Bazarr is not configured")
|
||||
@@ -2772,33 +2810,36 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not seerr.configured():
|
||||
raise HTTPException(status_code=400, detail="Seerr is not configured")
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
fresh_request = await _ensure_request_access(
|
||||
seerr, int(request_id), user, require_owner=True
|
||||
)
|
||||
|
||||
try:
|
||||
fresh_request = await seerr.get_request(request_id)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = _format_upstream_error("Seerr", exc)
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
request_id,
|
||||
"recheck_pipeline",
|
||||
"Recheck request status",
|
||||
"failed",
|
||||
detail,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
except Exception as exc:
|
||||
logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
|
||||
detail = "Magent could not reach Seerr to recheck this request."
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
request_id,
|
||||
"recheck_pipeline",
|
||||
"Recheck request status",
|
||||
"failed",
|
||||
detail,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
if fresh_request is None:
|
||||
try:
|
||||
fresh_request = await seerr.get_request(request_id)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = _format_upstream_error("Seerr", exc)
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
request_id,
|
||||
"recheck_pipeline",
|
||||
"Recheck request status",
|
||||
"failed",
|
||||
detail,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
except Exception as exc:
|
||||
logger.warning("Request recheck failed while reading Seerr: request_id=%s error=%s", request_id, exc)
|
||||
detail = "Magent could not reach Seerr to recheck this request."
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
request_id,
|
||||
"recheck_pipeline",
|
||||
"Recheck request status",
|
||||
"failed",
|
||||
detail,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
|
||||
if not isinstance(fresh_request, dict):
|
||||
raise HTTPException(status_code=404, detail="Request not found in Seerr")
|
||||
@@ -3444,11 +3485,14 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
|
||||
return triage_snapshot(snapshot)
|
||||
|
||||
|
||||
async def _request_language_context(request_id, user):
|
||||
async def _request_language_context(request_id, user, *, require_owner: bool = False):
|
||||
runtime = get_runtime_settings()
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
request = await seerr.get_request(request_id)
|
||||
request = await _ensure_request_access(
|
||||
seerr, int(request_id), user, require_owner=require_owner
|
||||
)
|
||||
if request is None:
|
||||
request = await seerr.get_request(request_id)
|
||||
if not isinstance(request, dict) or request.get('type') != 'movie':
|
||||
return runtime, None, None
|
||||
tmdb_id = (request.get('media') or {}).get('tmdbId')
|
||||
@@ -3479,7 +3523,9 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
|
||||
raise HTTPException(403, 'Search and download changes are disabled for this account.')
|
||||
if payload.get('acceptOriginalLanguage') is not True:
|
||||
raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
|
||||
runtime, tmdb_id, language = await _request_language_context(request_id, user)
|
||||
runtime, tmdb_id, language = await _request_language_context(
|
||||
request_id, user, require_owner=True
|
||||
)
|
||||
if not language:
|
||||
raise HTTPException(409, 'This request has no verified non-English original language.')
|
||||
if payload.get('languageCode') != language['code']:
|
||||
@@ -3502,9 +3548,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
||||
total_missing = 0
|
||||
next_offset = None
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
|
||||
@@ -3612,9 +3656,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
if not _user_can_use_search_auto(user):
|
||||
raise HTTPException(status_code=403, detail="Auto search and download is disabled for this user")
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict):
|
||||
@@ -3664,9 +3706,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
@router.post("/{request_id}/actions/qbit/resume")
|
||||
async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
queue = snapshot.raw.get("arr", {}).get("queue")
|
||||
download_ids = _download_ids(_queue_records(queue))
|
||||
@@ -3711,9 +3751,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr
|
||||
@router.post("/{request_id}/actions/readd")
|
||||
async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
jelly = snapshot.raw.get("jellyseerr") or {}
|
||||
media = jelly.get("media") or {}
|
||||
@@ -3870,9 +3908,7 @@ async def action_grab(
|
||||
request_id: str, payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)
|
||||
) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
await _ensure_request_mutation_access(runtime, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
guid = payload.get("guid")
|
||||
indexer_id = payload.get("indexerId")
|
||||
|
||||
Reference in New Issue
Block a user