mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
#124: Add feature to secure the API of self-hosted resources with Password
This commit is contained in:
+12
-5
@@ -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,
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -247,10 +247,34 @@
|
||||
|
||||
</div>
|
||||
|
||||
{% if authentication_required %}
|
||||
<!-- API Password Configuration -->
|
||||
<div class="section-container">
|
||||
<h4 class="section-header">API Security Configuration</h4>
|
||||
<hr class="section-divider">
|
||||
|
||||
<!-- API Password Input -->
|
||||
<div class="mb-3">
|
||||
<label for="api_password">API Password: <span class="bi bi-question-circle" data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
title="Enter the API password you configured in the environment variables."></span></label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="password" id="api_password" name="api_password" placeholder="Enter API Password">
|
||||
<button class="btn btn-outline-secondary" type="button" id="toggleApiPassword">
|
||||
<span id="toggleApiPasswordIcon" class="bi bi-eye"></span>
|
||||
</button>
|
||||
<div class="invalid-feedback">
|
||||
API Password is required.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Fallback URL Display - Initially hidden -->
|
||||
<div id="fallbackUrlContainer" class="fallback-url-container" style="display: none;">
|
||||
<label for="fallbackUrl">Installation URL <span class="bi bi-question-circle" data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
title="This is the URL that you can use to install the addon in Stremio."></span></label>
|
||||
title="This is the URL that you can use to install the addon in Stremio."></span></label>
|
||||
<textarea id="fallbackUrl" class="form-control" readonly></textarea>
|
||||
<p>Please manually copy the URL above and paste it in the Stremio search bar. Do not share this URL with unknown persons.</p>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user