diff --git a/api/main.py b/api/main.py index 709e43b..7fec0e0 100644 --- a/api/main.py +++ b/api/main.py @@ -21,7 +21,7 @@ from api import middleware from db import database, crud, schemas from db.config import settings from streaming_providers.routes import router as streaming_provider_router -from utils import crypto, torrent, poster, const, rate_limiter +from utils import crypto, torrent, poster, const, wrappers from utils.parser import generate_manifest logging.basicConfig( @@ -106,7 +106,7 @@ async def get_home(request: Request): @app.get("/health", tags=["health"]) -@rate_limiter.exclude +@wrappers.exclude async def health(request: Request): return {"status": "healthy"} @@ -154,12 +154,14 @@ async def configure( "catalogs": sorted_catalogs, "resolutions": const.RESOLUTIONS, "sorting_options": const.TORRENT_SORTING_PRIORITY, + "authentication_required": settings.api_password is not None, }, ) @app.get("/manifest.json", tags=["manifest"]) @app.get("/{secret_str}/manifest.json", tags=["manifest"]) +@wrappers.auth_required async def get_manifest( response: Response, user_data: schemas.UserData = Depends(get_user_data) ): @@ -212,7 +214,8 @@ async def get_manifest( response_model_by_alias=False, tags=["catalog"], ) -@rate_limiter.rate_limit(150, 300, "catalog") +@wrappers.auth_required +@wrappers.rate_limit(150, 300, "catalog") async def get_catalog( response: Response, request: Request, @@ -271,6 +274,7 @@ async def get_catalog( response_model_exclude_none=True, response_model_by_alias=False, ) +@wrappers.auth_required async def search_meta( response: Response, catalog_type: Literal["movie", "series", "tv"], @@ -301,6 +305,7 @@ async def search_meta( response_model_exclude_none=True, response_model_by_alias=False, ) +@wrappers.auth_required async def get_meta( catalog_type: Literal["movie", "series", "tv"], meta_id: str, @@ -359,7 +364,8 @@ async def get_meta( response_model_exclude_none=True, tags=["stream"], ) -@rate_limiter.rate_limit(10, 60 * 60, "stream") +@wrappers.auth_required +@wrappers.rate_limit(10, 60 * 60, "stream") async def get_streams( catalog_type: Literal["movie", "series", "tv"], video_id: str, @@ -387,13 +393,14 @@ async def get_streams( @app.post("/encrypt-user-data", tags=["user_data"]) +@wrappers.rate_limit(30, 60 * 5, "user_data") async def encrypt_user_data(user_data: schemas.UserData): encrypted_str = crypto.encrypt_user_data(user_data) return {"encrypted_str": encrypted_str} @app.get("/poster/{catalog_type}/{mediafusion_id}.jpg", tags=["poster"]) -@rate_limiter.exclude +@wrappers.exclude async def get_poster( catalog_type: Literal["movie", "series", "tv"], mediafusion_id: str, diff --git a/api/middleware.py b/api/middleware.py index 31816de..94f8014 100644 --- a/api/middleware.py +++ b/api/middleware.py @@ -61,10 +61,21 @@ class SecureLoggingMiddleware(BaseHTTPMiddleware): class UserDataMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable): - await find_route_handler(request.app, request) + endpoint = await find_route_handler(request.app, request) secret_str = request.path_params.get("secret_str") # Decrypt and parse the UserData from secret_str user_data = crypto.decrypt_user_data(secret_str) + + # validate api password if set + if settings.api_password: + is_auth_required = getattr(endpoint, "auth_required", False) + if is_auth_required and user_data.api_password != settings.api_password: + return Response( + content="Unauthorized", + status_code=401, + headers=const.NO_CACHE_HEADERS, + ) + # Attach UserData to request state for access in endpoints request.scope["user"] = user_data diff --git a/db/config.py b/db/config.py index ae36877..2a8fc41 100644 --- a/db/config.py +++ b/db/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): prowlarr_immediate_max_process_time: int = 15 adult_content_regex_keywords: str = r"(^|\b|\s)(18\+|adult|porn|sex|xxx|nude|naked|erotic|sexy|18\s*plus)(\b|\s|$|[._-])" enable_rate_limit: bool = True + api_password: str | None = None class Config: env_file = ".env" diff --git a/db/schemas.py b/db/schemas.py index 924146a..8f5f144 100644 --- a/db/schemas.py +++ b/db/schemas.py @@ -98,6 +98,7 @@ class UserData(BaseModel): max_streams_per_resolution: int = 3 show_full_torrent_name: bool = False torrent_sorting_priority: list[str] = Field(default=const.TORRENT_SORTING_PRIORITY) + api_password: str | None = None @model_validator(mode="after") def validate_selected_resolutions(self) -> "UserData": diff --git a/resources/html/configure.html b/resources/html/configure.html index 1152a59..8172fbe 100644 --- a/resources/html/configure.html +++ b/resources/html/configure.html @@ -247,10 +247,34 @@ + {% if authentication_required %} + +
+

API Security Configuration

+
+ + +
+ +
+ + +
+ API Password is required. +
+
+
+
+ {% endif %} + + diff --git a/resources/js/config_script.js b/resources/js/config_script.js index 0203162..8aa2bf6 100644 --- a/resources/js/config_script.js +++ b/resources/js/config_script.js @@ -253,6 +253,7 @@ function getUserData() { } }; + // Validate and collect streaming provider data if (provider) { if (servicesRequiringCredentials.includes(provider)) { validateInput('username', document.getElementById('username').value); @@ -269,22 +270,29 @@ function getUserData() { streamingProviderData = null; } + // Collect and validate other user data const maxSizeSlider = document.getElementById('max_size_slider'); const maxSizeValue = maxSizeSlider.value; const maxSize = maxSizeSlider.max; const maxSizeBytes = maxSizeValue === maxSize ? 'inf' : maxSizeValue; - const maxStreamsPerResolution = document.getElementById('maxStreamsPerResolution').value; validateInput('maxStreamsPerResolution', maxStreamsPerResolution && !isNaN(maxStreamsPerResolution) && maxStreamsPerResolution > 0); + let apiPassword = null; + // Check for API Password if authentication is required + if (document.getElementById('api_password')) { + validateInput('api_password', document.getElementById('api_password').value); + apiPassword = document.getElementById('api_password').value; + } + if (!isValid) { return null; // Return null if validation fails } + // Collect and return the rest of the user data const selectedSortingOptions = Array.from(document.querySelectorAll('#streamSortOrder .form-check-input:checked')).map(el => el.value); const torrentDisplayOption = document.querySelector('input[name="torrentDisplayOption"]:checked').value; - // Return user data if all validations pass return { streaming_provider: streamingProviderData, selected_catalogs: Array.from(document.querySelectorAll('input[name="selected_catalogs"]:checked')).map(el => el.value), @@ -294,6 +302,7 @@ function getUserData() { max_streams_per_resolution: maxStreamsPerResolution, torrent_sorting_priority: selectedSortingOptions, show_full_torrent_name: torrentDisplayOption === 'fullName', + api_password: apiPassword, }; } @@ -306,6 +315,20 @@ function displayFallbackUrl(url) { textarea.focus(); } +function setupPasswordToggle(passwordInputId, toggleButtonId, toggleIconId) { + document.getElementById(toggleButtonId).addEventListener('click', function (_) { + const passwordInput = document.getElementById(passwordInputId); + const passwordIcon = document.getElementById(toggleIconId); + if (passwordInput.type === "password") { + passwordInput.type = "text"; + passwordIcon.className = "bi bi-eye-slash"; + } else { + passwordInput.type = "password"; + passwordIcon.className = "bi bi-eye"; + } + }); +} + // ---- Event Listeners ---- document.getElementById('provider_token').addEventListener('input', function () { @@ -315,18 +338,6 @@ document.getElementById('provider_token').addEventListener('input', function () // Event listener for the slider document.getElementById('max_size_slider').addEventListener('input', updateSizeOutput); -document.getElementById('togglePassword').addEventListener('click', function (e) { - const passwordInput = document.getElementById('password'); - const passwordIcon = document.getElementById('togglePasswordIcon'); - if (passwordInput.type === "password") { - passwordInput.type = "text"; - passwordIcon.className = "bi bi-eye-slash"; - } else { - passwordInput.type = "password"; - passwordIcon.className = "bi bi-eye"; - } -}); - oAuthBtn.addEventListener('click', async function () { const provider = document.getElementById('provider_service').value; oAuthBtn.disabled = true; @@ -400,6 +411,9 @@ document.addEventListener('DOMContentLoaded', function () { return new bootstrap.Tooltip(tooltipTriggerEl) }); + setupPasswordToggle('password', 'togglePassword', 'togglePasswordIcon'); + setupPasswordToggle('api_password', 'toggleApiPassword', 'toggleApiPasswordIcon'); + if (navigator.share) { document.getElementById('shareBtn').style.display = 'block'; } else { diff --git a/streaming_providers/routes.py b/streaming_providers/routes.py index 7b0bf01..4240a07 100644 --- a/streaming_providers/routes.py +++ b/streaming_providers/routes.py @@ -23,13 +23,14 @@ from streaming_providers.realdebrid.utils import get_direct_link_from_realdebrid from streaming_providers.seedr.api import router as seedr_router from streaming_providers.seedr.utils import get_direct_link_from_seedr from streaming_providers.torbox.utils import get_direct_link_from_torbox -from utils import crypto, torrent, rate_limiter, const +from utils import crypto, torrent, wrappers, const router = APIRouter() @router.get("/{secret_str}/stream", tags=["streaming_provider"]) -@rate_limiter.exclude +@wrappers.exclude +@wrappers.auth_required async def streaming_provider_endpoint( secret_str: str, info_hash: str, diff --git a/utils/rate_limiter.py b/utils/wrappers.py similarity index 81% rename from utils/rate_limiter.py rename to utils/wrappers.py index cdf0297..c19b650 100644 --- a/utils/rate_limiter.py +++ b/utils/wrappers.py @@ -30,3 +30,12 @@ def exclude(func): wrapper.exclude = True return wrapper + + +def auth_required(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + wrapper.auth_required = True + return wrapper