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 %}
+
+
+ {% endif %}
+
+
+ title="This is the URL that you can use to install the addon in Stremio.">
Please manually copy the URL above and paste it in the Stremio search bar. Do not share this URL with unknown persons.
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