diff --git a/backend/routers/auth.py b/backend/routers/auth.py index d1d3ffe..281bf5a 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -2,7 +2,7 @@ import pyotp import logging -from fastapi import APIRouter, Depends, HTTPException, Request, status, Query +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession @@ -44,6 +44,12 @@ def _generate_api_key() -> str: return secrets.token_urlsafe(32) router = APIRouter() + + +def _prevent_sensitive_response_caching(response: Response) -> None: + response.headers["Cache-Control"] = "no-store" + + def _parse_nuvio_profile_id(value: str | None) -> int: try: return parse_profile_id(value) @@ -672,63 +678,76 @@ async def regenerate_api_key( @router.post("/test-tmdb") async def test_tmdb( - key: str = Query(...), + body: schemas.ApiKeyTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import tmdb - success = await tmdb.validate_api_key(key) + _prevent_sensitive_response_caching(response) + success = await tmdb.validate_api_key(body.key.get_secret_value()) if not success: raise HTTPException(status_code=400, detail="Invalid TMDB API Key") return {"status": "ok", "message": "TMDB API key is valid."} @router.post("/test-tvdb") async def test_tvdb( - key: str = Query(...), + body: schemas.ApiKeyTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import tvdb - success = await tvdb.validate_api_key(key) + _prevent_sensitive_response_caching(response) + success = await tvdb.validate_api_key(body.key.get_secret_value()) if not success: raise HTTPException(status_code=400, detail="Invalid TVDB API Key") return {"status": "ok", "message": "TVDB API key is valid."} @router.post("/test-jellyfin") async def test_jellyfin( - url: str = Query(...), - token: str = Query(...), - user_id: Optional[str] = Query(None), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import jellyfin - url = await validate_service_url(url, "Jellyfin URL") - success = await jellyfin.validate_connection(url, token, user_id) + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Jellyfin URL") + success = await jellyfin.validate_connection( + url, + body.token.get_secret_value(), + body.user_id, + ) if not success: raise HTTPException(status_code=400, detail="Failed to connect to Jellyfin or invalid User ID") return {"status": "ok"} @router.post("/test-emby") async def test_emby( - url: str = Query(...), - token: str = Query(...), - user_id: Optional[str] = Query(None), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import emby - url = await validate_service_url(url, "Emby URL") - success = await emby.validate_connection(url, token, user_id) + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Emby URL") + success = await emby.validate_connection( + url, + body.token.get_secret_value(), + body.user_id, + ) if not success: raise HTTPException(status_code=400, detail="Failed to connect to Emby or invalid User ID") return {"status": "ok"} @router.post("/test-plex") async def test_plex( - url: str = Query(...), - token: str = Query(...), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import plex - url = await validate_service_url(url, "Plex URL") - success = await plex.validate_connection(url, token) + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Plex URL") + success = await plex.validate_connection(url, body.token.get_secret_value()) if not success: raise HTTPException(status_code=400, detail="Failed to connect to Plex") return {"status": "ok"} @@ -775,25 +794,28 @@ async def test_nuvio( @router.post("/test-radarr") async def test_radarr( - url: str = Query(...), - token: str = Query(...), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import radarr - url = await validate_service_url(url, "Radarr URL") - success = await radarr.validate_connection(url, token) + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Radarr URL") + success = await radarr.validate_connection(url, body.token.get_secret_value()) if not success: raise HTTPException(status_code=400, detail="Failed to connect to Radarr") return {"status": "ok"} -@router.get("/radarr/profiles") +@router.post("/radarr/profiles") async def get_radarr_profiles( - url: str = Query(...), - token: str = Query(...), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import radarr - url = await validate_service_url(url, "Radarr URL") + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Radarr URL") + token = body.token.get_secret_value() quality_profiles = await radarr.get_quality_profiles(url, token) root_folders = await radarr.get_root_folders(url, token) tags = await radarr.get_tags(url, token) @@ -805,13 +827,14 @@ async def get_radarr_profiles( @router.post("/test-sonarr") async def test_sonarr( - url: str = Query(...), - token: str = Query(...), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import sonarr - url = await validate_service_url(url, "Sonarr URL") - success = await sonarr.validate_connection(url, token) + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Sonarr URL") + success = await sonarr.validate_connection(url, body.token.get_secret_value()) if not success: raise HTTPException(status_code=400, detail="Failed to connect to Sonarr") return {"status": "ok"} @@ -934,14 +957,16 @@ async def check_mdblist(): return {"radarr": rdr_status, "sonarr": snr_status, "trakt": trakt_status, "simkl": simkl_status, "mdblist": mdblist_status, "connections": ms_statuses} -@router.get("/sonarr/profiles") +@router.post("/sonarr/profiles") async def get_sonarr_profiles( - url: str = Query(...), - token: str = Query(...), + body: schemas.ServiceConnectionTestRequest, + response: Response, current_user: User = Depends(get_current_user) ): from core import sonarr - url = await validate_service_url(url, "Sonarr URL") + _prevent_sensitive_response_caching(response) + url = await validate_service_url(body.url, "Sonarr URL") + token = body.token.get_secret_value() quality_profiles = await sonarr.get_quality_profiles(url, token) root_folders = await sonarr.get_root_folders(url, token) tags = await sonarr.get_tags(url, token) diff --git a/backend/schemas.py b/backend/schemas.py index f6bb83f..f16e80e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, EmailStr, SecretStr, field_validator from typing import Optional from datetime import datetime from models.base import UserRole, MediaType, PrivacyLevel @@ -151,6 +151,16 @@ class NuvioConnectionTestRequest(BaseModel): profile_id: int +class ApiKeyTestRequest(BaseModel): + key: SecretStr + + +class ServiceConnectionTestRequest(BaseModel): + url: str + token: SecretStr + user_id: Optional[str] = None + + class MediaServerConnectionBase(BaseModel): type: str name: str diff --git a/backend/tests/test_auth_integration_credentials.py b/backend/tests/test_auth_integration_credentials.py new file mode 100644 index 0000000..2601216 --- /dev/null +++ b/backend/tests/test_auth_integration_credentials.py @@ -0,0 +1,181 @@ +import os +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +from fastapi import FastAPI + +os.environ.setdefault("SECRET_KEY", "test-secret") +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + +import schemas +from dependencies import get_current_user +from routers import auth + + +def _test_app() -> FastAPI: + app = FastAPI() + app.include_router(auth.router, prefix="/auth") + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1) + return app + + +class IntegrationCredentialContractTests(unittest.TestCase): + def test_sensitive_endpoints_accept_credentials_only_in_request_bodies(self) -> None: + openapi = _test_app().openapi() + paths = ( + "/auth/test-tmdb", + "/auth/test-tvdb", + "/auth/test-jellyfin", + "/auth/test-emby", + "/auth/test-plex", + "/auth/test-radarr", + "/auth/radarr/profiles", + "/auth/test-sonarr", + "/auth/sonarr/profiles", + ) + + for path in paths: + with self.subTest(path=path): + operations = openapi["paths"][path] + self.assertIn("post", operations) + self.assertIn("requestBody", operations["post"]) + self.assertNotIn("get", operations) + query_parameters = { + parameter["name"] + for parameter in operations["post"].get("parameters", []) + if parameter["in"] == "query" + } + self.assertTrue( + query_parameters.isdisjoint({"key", "url", "token", "user_id"}) + ) + + def test_secret_fields_are_redacted_from_model_representations(self) -> None: + secret = "credential-that-must-not-be-logged" + api_key_request = schemas.ApiKeyTestRequest(key=secret) + connection_request = schemas.ServiceConnectionTestRequest( + url="https://media.example", + token=secret, + ) + + self.assertNotIn(secret, repr(api_key_request)) + self.assertNotIn(secret, repr(connection_request)) + + +class IntegrationCredentialRequestTests(unittest.IsolatedAsyncioTestCase): + async def test_tmdb_uses_json_body_and_disables_response_caching(self) -> None: + app = _test_app() + transport = httpx.ASGITransport(app=app) + validate_api_key = AsyncMock(return_value=True) + + with patch("core.tmdb.validate_api_key", validate_api_key): + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + ) as client: + response = await client.post( + "/auth/test-tmdb", + json={"key": "tmdb-secret"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + validate_api_key.assert_awaited_once_with("tmdb-secret") + + async def test_tmdb_rejects_legacy_query_credentials(self) -> None: + app = _test_app() + transport = httpx.ASGITransport(app=app) + validate_api_key = AsyncMock(return_value=True) + + with patch("core.tmdb.validate_api_key", validate_api_key): + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + ) as client: + response = await client.post( + "/auth/test-tmdb", + params={"key": "tmdb-secret"}, + ) + + self.assertEqual(response.status_code, 422) + validate_api_key.assert_not_awaited() + + async def test_media_server_test_passes_body_credentials_to_provider(self) -> None: + app = _test_app() + transport = httpx.ASGITransport(app=app) + validate_url = AsyncMock(return_value="https://plex.example") + validate_connection = AsyncMock(return_value=True) + + with ( + patch.object(auth, "validate_service_url", validate_url), + patch("core.plex.validate_connection", validate_connection), + ): + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + ) as client: + response = await client.post( + "/auth/test-plex", + json={ + "url": "https://plex.example/", + "token": "plex-secret", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + validate_url.assert_awaited_once_with( + "https://plex.example/", + "Plex URL", + ) + validate_connection.assert_awaited_once_with( + "https://plex.example", + "plex-secret", + ) + + async def test_profile_discovery_uses_post_body_credentials(self) -> None: + app = _test_app() + transport = httpx.ASGITransport(app=app) + validate_url = AsyncMock(return_value="https://radarr.example") + quality_profiles = AsyncMock(return_value=[{"id": 1, "name": "HD"}]) + root_folders = AsyncMock(return_value=[{"path": "/movies"}]) + tags = AsyncMock(return_value=[]) + + with ( + patch.object(auth, "validate_service_url", validate_url), + patch("core.radarr.get_quality_profiles", quality_profiles), + patch("core.radarr.get_root_folders", root_folders), + patch("core.radarr.get_tags", tags), + ): + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + ) as client: + response = await client.post( + "/auth/radarr/profiles", + json={ + "url": "https://radarr.example/", + "token": "radarr-secret", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertEqual(response.json()["quality_profiles"][0]["name"], "HD") + quality_profiles.assert_awaited_once_with( + "https://radarr.example", + "radarr-secret", + ) + root_folders.assert_awaited_once_with( + "https://radarr.example", + "radarr-secret", + ) + tags.assert_awaited_once_with( + "https://radarr.example", + "radarr-secret", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 338cc55..f8125d7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -989,21 +989,21 @@ export const api = { deleteScrobbleConnection: (id: number, token: string) => del<{ status: string }>(`/auth/scrobble-connections/${id}`, token), testJellyfin: (url: string, token: string, jellyfinUserId: string | null, userToken: string) => - post<{ success: boolean; message: string }>(`/auth/test-jellyfin?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}${jellyfinUserId ? `&user_id=${encodeURIComponent(jellyfinUserId)}` : ""}`, undefined, userToken), + post<{ success: boolean; message: string }>("/auth/test-jellyfin", { url, token, user_id: jellyfinUserId }, userToken), testEmby: (url: string, token: string, embyUserId: string | null, userToken: string) => - post<{ success: boolean; message: string }>(`/auth/test-emby?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}${embyUserId ? `&user_id=${encodeURIComponent(embyUserId)}` : ""}`, undefined, userToken), + post<{ success: boolean; message: string }>("/auth/test-emby", { url, token, user_id: embyUserId }, userToken), testPlex: (url: string, token: string, userToken: string) => - post<{ success: boolean; message: string }>(`/auth/test-plex?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`, undefined, userToken), + post<{ success: boolean; message: string }>("/auth/test-plex", { url, token }, userToken), testRadarr: (url: string, token: string, userToken: string) => - post<{ success: boolean; message: string }>(`/auth/test-radarr?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`, undefined, userToken), + post<{ success: boolean; message: string }>("/auth/test-radarr", { url, token }, userToken), getRadarrProfiles: (url: string, token: string, userToken: string) => - get<{ quality_profiles: any[]; root_folders: any[] }>(`/auth/radarr/profiles?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`, undefined, userToken), + post<{ quality_profiles: any[]; root_folders: any[] }>("/auth/radarr/profiles", { url, token }, userToken), testSonarr: (url: string, token: string, userToken: string) => - post<{ success: boolean; message: string }>(`/auth/test-sonarr?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`, undefined, userToken), + post<{ success: boolean; message: string }>("/auth/test-sonarr", { url, token }, userToken), getSonarrProfiles: (url: string, token: string, userToken: string) => - get<{ quality_profiles: any[]; root_folders: any[]; language_profiles: any[] }>(`/auth/sonarr/profiles?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`, undefined, userToken), + post<{ quality_profiles: any[]; root_folders: any[]; language_profiles: any[] }>("/auth/sonarr/profiles", { url, token }, userToken), testTmdb: (key: string, userToken: string) => - post<{ success: boolean; message: string }>(`/auth/test-tmdb?key=${encodeURIComponent(key)}`, undefined, userToken), + post<{ success: boolean; message: string }>("/auth/test-tmdb", { key }, userToken), getConnectionStatus: (token: string) => get("/auth/connection-status", undefined, token), totp2faSetup: (token: string) => @@ -1321,4 +1321,3 @@ export function tmdbImageUrl(path: string | null | undefined, size: string = "w5 const cleanPath = path.startsWith("/") ? path : `/${path}`; return `/api/proxy/media/image/${size}${cleanPath}`; } - diff --git a/frontend/src/pages/admin.astro b/frontend/src/pages/admin.astro index 7096642..cc5d704 100644 --- a/frontend/src/pages/admin.astro +++ b/frontend/src/pages/admin.astro @@ -652,20 +652,23 @@ const adminCount = users.filter((u: AdminUser) => u.is_admin).length; }); // ── Test & Fetch ── + const postIntegrationCredentials = (path: string, credentials: { url: string; token: string }) => + fetch(path, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + document.getElementById('test-radarr-btn')?.addEventListener('click', async () => { const u = (document.getElementById('radarr_url') as HTMLInputElement).value.trim(); const t = (document.getElementById('radarr_token') as HTMLInputElement).value.trim(); if (!u || !t) { showMessage('Enter Radarr URL and API key first.', 'error'); return; } setConnStatus('radarr-conn-status', 'checking'); try { - const testRes = await fetch(`/api/proxy/auth/test-radarr?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, - }); + const testRes = await postIntegrationCredentials('/api/proxy/auth/test-radarr', { url: u, token: t }); if (!testRes.ok) { setConnStatus('radarr-conn-status', 'disconnected'); throw new Error((await testRes.json()).detail || 'Failed to connect'); } - const profRes = await fetch(`/api/proxy/auth/radarr/profiles?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); + const profRes = await postIntegrationCredentials('/api/proxy/auth/radarr/profiles', { url: u, token: t }); const profData = await profRes.json(); const rootSelect = document.getElementById('radarr_root_folder') as HTMLSelectElement; @@ -696,14 +699,10 @@ const adminCount = users.filter((u: AdminUser) => u.is_admin).length; if (!u || !t) { showMessage('Enter Sonarr URL and API key first.', 'error'); return; } setConnStatus('sonarr-conn-status', 'checking'); try { - const testRes = await fetch(`/api/proxy/auth/test-sonarr?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, - }); + const testRes = await postIntegrationCredentials('/api/proxy/auth/test-sonarr', { url: u, token: t }); if (!testRes.ok) { setConnStatus('sonarr-conn-status', 'disconnected'); throw new Error((await testRes.json()).detail || 'Failed to connect'); } - const profRes = await fetch(`/api/proxy/auth/sonarr/profiles?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); + const profRes = await postIntegrationCredentials('/api/proxy/auth/sonarr/profiles', { url: u, token: t }); const profData = await profRes.json(); const rootSelect = document.getElementById('sonarr_root_folder') as HTMLSelectElement; @@ -741,13 +740,15 @@ const adminCount = users.filter((u: AdminUser) => u.is_admin).length; setConnStatus('radarr-conn-status', 'checking'); checks.push((async () => { try { - const testRes = await fetch(`/api/proxy/auth/test-radarr?url=${encodeURIComponent(radarrUrl)}&token=${encodeURIComponent(radarrToken)}`, { - method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, - }); + const testRes = await postIntegrationCredentials( + '/api/proxy/auth/test-radarr', + { url: radarrUrl, token: radarrToken }, + ); if (!testRes.ok) { setConnStatus('radarr-conn-status', 'disconnected'); return; } - const profRes = await fetch(`/api/proxy/auth/radarr/profiles?url=${encodeURIComponent(radarrUrl)}&token=${encodeURIComponent(radarrToken)}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); + const profRes = await postIntegrationCredentials( + '/api/proxy/auth/radarr/profiles', + { url: radarrUrl, token: radarrToken }, + ); const profData = await profRes.json(); const rootSelect = document.getElementById('radarr_root_folder') as HTMLSelectElement; const qualSelect = document.getElementById('radarr_quality_profile') as HTMLSelectElement; @@ -771,13 +772,15 @@ const adminCount = users.filter((u: AdminUser) => u.is_admin).length; setConnStatus('sonarr-conn-status', 'checking'); checks.push((async () => { try { - const testRes = await fetch(`/api/proxy/auth/test-sonarr?url=${encodeURIComponent(sonarrUrl)}&token=${encodeURIComponent(sonarrToken)}`, { - method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, - }); + const testRes = await postIntegrationCredentials( + '/api/proxy/auth/test-sonarr', + { url: sonarrUrl, token: sonarrToken }, + ); if (!testRes.ok) { setConnStatus('sonarr-conn-status', 'disconnected'); return; } - const profRes = await fetch(`/api/proxy/auth/sonarr/profiles?url=${encodeURIComponent(sonarrUrl)}&token=${encodeURIComponent(sonarrToken)}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); + const profRes = await postIntegrationCredentials( + '/api/proxy/auth/sonarr/profiles', + { url: sonarrUrl, token: sonarrToken }, + ); const profData = await profRes.json(); const rootSelect = document.getElementById('sonarr_root_folder') as HTMLSelectElement; const qualSelect = document.getElementById('sonarr_quality_profile') as HTMLSelectElement; diff --git a/frontend/src/pages/settings.astro b/frontend/src/pages/settings.astro index bf6664d..8b3f6d5 100644 --- a/frontend/src/pages/settings.astro +++ b/frontend/src/pages/settings.astro @@ -2618,6 +2618,13 @@ if (me.totp_enabled) { } + const postIntegrationCredentials = (path: string, credentials: Record) => + fetch(path, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + function setupActions() { const btns = document.querySelectorAll('.js-action-btn'); btns.forEach(btn => { @@ -2636,14 +2643,17 @@ if (me.totp_enabled) { try { let url = ''; let method = 'POST'; + let requestBody: Record | undefined; let syncConnId: string | null = null; // set for sync/push actions to start progress poller if (action === 'test_tmdb') { const val = (document.getElementById('tmdb_api_key') as HTMLInputElement).value; - url = `/api/proxy/auth/test-tmdb?key=${encodeURIComponent(val)}`; + url = '/api/proxy/auth/test-tmdb'; + requestBody = { key: val }; } else if (action === 'test_tvdb') { const val = (document.getElementById('tvdb_api_key') as HTMLInputElement).value; - url = `/api/proxy/auth/test-tvdb?key=${encodeURIComponent(val)}`; + url = '/api/proxy/auth/test-tvdb'; + requestBody = { key: val }; } else if (action === 'test_connection') { const connId = newBtn.getAttribute('data-conn-id'); const connType = newBtn.getAttribute('data-conn-type'); @@ -2659,15 +2669,11 @@ if (me.totp_enabled) { body: JSON.stringify({ url: u, token: t, profile_id: parseInt(ui) }), }); } else { - let testEndpoint: string; - if (connType === 'plex') { - testEndpoint = `/api/proxy/auth/test-plex?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`; - } else if (connType === 'emby') { - testEndpoint = `/api/proxy/auth/test-emby?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}&user_id=${encodeURIComponent(ui)}`; - } else { - testEndpoint = `/api/proxy/auth/test-jellyfin?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}&user_id=${encodeURIComponent(ui)}`; - } - testRes = await fetch(testEndpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); + const testEndpoint = `/api/proxy/auth/test-${connType === 'plex' ? 'plex' : connType === 'emby' ? 'emby' : 'jellyfin'}`; + const credentials = connType === 'plex' + ? { url: u, token: t } + : { url: u, token: t, user_id: ui }; + testRes = await postIntegrationCredentials(testEndpoint, credentials); } const testData = await testRes.json(); if (testRes.ok) { @@ -2857,19 +2863,14 @@ if (me.totp_enabled) { const t = document.getElementById('radarr_token').value; setConnStatus('radarr-conn-status', 'checking'); // First test connection - const testRes = await fetch(`/api/proxy/auth/test-radarr?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + const testRes = await postIntegrationCredentials('/api/proxy/auth/test-radarr', { url: u, token: t }); if (!testRes.ok) { setConnStatus('radarr-conn-status', 'disconnected'); throw new Error((await testRes.json()).detail || 'Failed to connect to Radarr'); } // Then fetch profiles - const profRes = await fetch(`/api/proxy/auth/radarr/profiles?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + const profRes = await postIntegrationCredentials('/api/proxy/auth/radarr/profiles', { url: u, token: t }); const profData = await profRes.json(); // Populate selects @@ -2899,19 +2900,14 @@ if (me.totp_enabled) { const t = document.getElementById('sonarr_token').value; setConnStatus('sonarr-conn-status', 'checking'); // First test connection - const testRes = await fetch(`/api/proxy/auth/test-sonarr?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } - }); + const testRes = await postIntegrationCredentials('/api/proxy/auth/test-sonarr', { url: u, token: t }); if (!testRes.ok) { setConnStatus('sonarr-conn-status', 'disconnected'); throw new Error((await testRes.json()).detail || 'Failed to connect to Sonarr'); } // Then fetch profiles - const profRes = await fetch(`/api/proxy/auth/sonarr/profiles?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`, { - headers: { 'Authorization': `Bearer ${token}` } - }); + const profRes = await postIntegrationCredentials('/api/proxy/auth/sonarr/profiles', { url: u, token: t }); const profData = await profRes.json(); // Populate selects @@ -3074,7 +3070,11 @@ if (me.totp_enabled) { const res = await fetch(url, { method, - headers: { 'Authorization': `Bearer ${token}` } + headers: { + 'Authorization': `Bearer ${token}`, + ...(requestBody ? { 'Content-Type': 'application/json' } : {}), + }, + body: requestBody ? JSON.stringify(requestBody) : undefined, }); const data = await res.json(); @@ -3692,15 +3692,11 @@ if (me.totp_enabled) { await authenticateNuvio(); showMessage('Nuvio connection successful. Select the profile to sync.'); } else { - let ep: string; - if (type === 'plex') { - ep = `/api/proxy/auth/test-plex?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}`; - } else if (type === 'emby') { - ep = `/api/proxy/auth/test-emby?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}&user_id=${encodeURIComponent(ui)}`; - } else { - ep = `/api/proxy/auth/test-jellyfin?url=${encodeURIComponent(u)}&token=${encodeURIComponent(t)}&user_id=${encodeURIComponent(ui)}`; - } - const res = await fetch(ep, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); + const endpoint = `/api/proxy/auth/test-${type === 'plex' ? 'plex' : type === 'emby' ? 'emby' : 'jellyfin'}`; + const credentials = type === 'plex' + ? { url: u, token: t } + : { url: u, token: t, user_id: ui }; + const res = await postIntegrationCredentials(endpoint, credentials); const data = await res.json(); if (res.ok) showMessage(data.message || 'Connection successful!'); else throw new Error(data.detail || 'Connection test failed');