mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
refactor(permissions): enhance permission handling across various viewsets
This update modifies permission classes in multiple viewsets to restrict access based on user roles. The `IsAdmin` permission is now enforced for several actions, including group management and permission listing, ensuring that only administrators can perform sensitive operations. Additionally, a new utility function, `resolve_safe_local_data_path`, is introduced to enhance security when accessing local file paths. The changes improve overall security and maintainability of the codebase.
This commit is contained in:
parent
b6442e6421
commit
66089f14ee
19 changed files with 400 additions and 254 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -45,6 +45,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration.
|
||||
- **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs.
|
||||
|
||||
### Security
|
||||
|
||||
- **Authentication boundaries and local logo serving.**
|
||||
- Logo and VOD logo `cache` endpoints no longer serve arbitrary filesystem paths: local URLs must resolve under `/data/logos` (blocks `/data/../…` traversal). Empty logo URLs return 404 instead of attempting a remote fetch.
|
||||
- EPG source file upload API (`POST /api/epg/sources/upload/`) is admin-only and uses `safe_upload_path` so uploaded filenames cannot escape `/data/uploads/epgs`. The web UI does not expose this endpoint (EPG sources are added via URL, Schedules Direct, or dummy); the fix hardens the API-only path.
|
||||
- M3U account passwords are omitted from API responses for non-admins; admins still receive them (the M3U edit form already blanks the password field on load).
|
||||
- Channel bulk-regex rename, EPG name/logo/tvg-id apply, channel reorder, channel-group cleanup, recording stop/extend/comskip/metadata actions, and Connect integration APIs require admin. Guide-facing channel read helpers (`summary`, `ids`, etc.) remain available to standard users.
|
||||
- System notifications: any authenticated user can still list and dismiss notifications visible to them; create/update/delete require admin (previously any authenticated user could create notifications).
|
||||
- Django `auth.Group` CRUD (`/api/accounts/groups/`) and the permissions list endpoint require admin. Dispatcharr authorization uses `user_level`, not these groups; the React UI does not call these endpoints.
|
||||
- Removed unused unauthenticated routes: `/proxy/hls/` (HLS proxy package retained for possible future use) and legacy `/output/stream/<uuid>/` (including the nginx location). Live playback continues via `/proxy/ts/stream/`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Live channel Redis `state` no longer stays latched at `buffering` after a buffering-timeout failover.** When FFmpeg speed dipped below the buffering threshold and the timeout triggered a stream switch, the in-memory buffering flag was cleared without rewriting Redis, and the same stats sample could re-write `buffering`. After the new stream recovered, the speed-good path only cleared Redis when that flag was still set, so a healthy session could report `buffering` until restart or retune. A successful buffering-timeout switch now writes `active` and skips the fallthrough `buffering` write. (Fixes #1449)
|
||||
|
|
|
|||
|
|
@ -268,13 +268,18 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
return Response(serializer.data)
|
||||
|
||||
|
||||
# 🔹 3) Group Management APIs
|
||||
# 🔹 3) Group Management APIs (Django auth.Group; unused by the React UI)
|
||||
class GroupViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for Groups"""
|
||||
"""CRUD for Django auth groups and their permissions.
|
||||
|
||||
Dispatcharr authorization uses ``user_level``, not these groups. The
|
||||
endpoint is kept for compatibility but restricted to admins so
|
||||
non-admins cannot invent auth groups or attach Django permissions.
|
||||
"""
|
||||
|
||||
queryset = Group.objects.all()
|
||||
serializer_class = GroupSerializer
|
||||
permission_classes = [Authenticated]
|
||||
permission_classes = [IsAdmin]
|
||||
|
||||
@extend_schema(
|
||||
description="Retrieve a list of groups",
|
||||
|
|
@ -359,9 +364,9 @@ class APIKeyViewSet(viewsets.ViewSet):
|
|||
responses={200: PermissionSerializer(many=True)},
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([Authenticated])
|
||||
@permission_classes([IsAdmin])
|
||||
def list_permissions(request):
|
||||
"""Returns a list of all available permissions"""
|
||||
"""Returns a list of all available Django permissions (admin only)."""
|
||||
permissions = Permission.objects.all()
|
||||
serializer = PermissionSerializer(permissions, many=True)
|
||||
return Response(serializer.data)
|
||||
|
|
|
|||
44
apps/accounts/tests/test_auth_group_permissions.py
Normal file
44
apps/accounts/tests/test_auth_group_permissions.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Django auth.Group API is admin-only (unused by the React UI)."""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.accounts.api_views import GroupViewSet, list_permissions
|
||||
|
||||
|
||||
class AuthGroupPermissionTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_user(
|
||||
username="auth_group_admin",
|
||||
password="x",
|
||||
user_level=User.UserLevel.ADMIN,
|
||||
)
|
||||
self.standard = User.objects.create_user(
|
||||
username="auth_group_user",
|
||||
password="x",
|
||||
user_level=User.UserLevel.STANDARD,
|
||||
)
|
||||
self.factory = APIRequestFactory()
|
||||
self.group = Group.objects.create(name="unused-role")
|
||||
|
||||
def test_standard_user_cannot_list_groups(self):
|
||||
request = self.factory.get("/api/accounts/groups/")
|
||||
force_authenticate(request, user=self.standard)
|
||||
view = GroupViewSet.as_view({"get": "list"})
|
||||
response = view(request)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_admin_can_list_groups(self):
|
||||
request = self.factory.get("/api/accounts/groups/")
|
||||
force_authenticate(request, user=self.admin)
|
||||
view = GroupViewSet.as_view({"get": "list"})
|
||||
response = view(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_standard_user_cannot_list_permissions(self):
|
||||
request = self.factory.get("/api/accounts/permissions/")
|
||||
force_authenticate(request, user=self.standard)
|
||||
response = list_permissions(request)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
|
@ -21,12 +21,13 @@ from apps.accounts.permissions import (
|
|||
Authenticated,
|
||||
IsAdmin,
|
||||
IsOwnerOfObject,
|
||||
IsStandardUser,
|
||||
permission_classes_by_action,
|
||||
permission_classes_by_method,
|
||||
)
|
||||
|
||||
from core.models import UserAgent, CoreSettings
|
||||
from core.utils import RedisClient, safe_upload_path
|
||||
from core.utils import RedisClient, safe_upload_path, resolve_safe_local_data_path
|
||||
from apps.m3u.utils import convert_js_numbered_backreferences
|
||||
|
||||
from .models import (
|
||||
|
|
@ -611,10 +612,12 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = ChannelGroupSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "cleanup_unused_groups":
|
||||
return [IsAdmin()]
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
return [IsAdmin()]
|
||||
|
||||
def get_queryset(self):
|
||||
# Annotate both counts at the SQL level so the serializer methods
|
||||
|
|
@ -899,13 +902,26 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"match_epg",
|
||||
"set_epg",
|
||||
"batch_set_epg",
|
||||
"bulk_regex_rename",
|
||||
"set_names_from_epg",
|
||||
"set_logos_from_epg",
|
||||
"set_tvg_ids_from_epg",
|
||||
"reorder",
|
||||
]:
|
||||
return [IsAdmin()]
|
||||
|
||||
if self.action in (
|
||||
"get_ids",
|
||||
"summary",
|
||||
"numbers_in_range",
|
||||
"by_uuids",
|
||||
):
|
||||
return [IsStandardUser()]
|
||||
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
return [IsAdmin()]
|
||||
|
||||
def get_queryset(self):
|
||||
# get_ids and summary only need the filter conditions, not the full
|
||||
|
|
@ -2765,23 +2781,26 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
"""Streams the logo file, whether it's local or remote."""
|
||||
logo = self.get_object()
|
||||
logo_url = logo.url
|
||||
if not logo_url:
|
||||
raise Http404("Image not found")
|
||||
if logo_url.startswith("/data"): # Local file
|
||||
if not os.path.exists(logo_url):
|
||||
safe_path = resolve_safe_local_data_path(logo_url)
|
||||
if safe_path is None or not os.path.exists(safe_path):
|
||||
raise Http404("Image not found")
|
||||
stat = os.stat(logo_url)
|
||||
stat = os.stat(safe_path)
|
||||
# Get proper mime type (first item of the tuple)
|
||||
content_type, _ = mimetypes.guess_type(logo_url)
|
||||
content_type, _ = mimetypes.guess_type(safe_path)
|
||||
if not content_type:
|
||||
content_type = "image/jpeg" # Default to a common image type
|
||||
|
||||
# Use context manager and set Content-Disposition to inline
|
||||
# StreamingHttpResponse closes the file when the response finishes.
|
||||
response = StreamingHttpResponse(
|
||||
open(logo_url, "rb"), content_type=content_type
|
||||
open(safe_path, "rb"), content_type=content_type
|
||||
)
|
||||
response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
|
||||
response["Last-Modified"] = http_date(stat.st_mtime)
|
||||
response["Content-Disposition"] = 'inline; filename="{}"'.format(
|
||||
os.path.basename(logo_url)
|
||||
os.path.basename(safe_path)
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -3298,10 +3317,18 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
# classes run; _user_can_play_recording enforces authenticated access.
|
||||
if self.action in ('file', 'hls'):
|
||||
return [AllowAny()]
|
||||
if self.action in (
|
||||
'stop',
|
||||
'extend',
|
||||
'comskip',
|
||||
'refresh_artwork',
|
||||
'update_metadata',
|
||||
):
|
||||
return [IsAdmin()]
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
return [IsAdmin()]
|
||||
|
||||
def _user_can_play_recording(self, request, recording):
|
||||
"""Authorization gate for recording playback (file/hls actions).
|
||||
|
|
|
|||
77
apps/channels/tests/test_logo_path_jail.py
Normal file
77
apps/channels/tests/test_logo_path_jail.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Security-focused tests for local logo path jailing and related helpers."""
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.channels.api_views import LogoViewSet
|
||||
from apps.channels.models import Logo
|
||||
from core.utils import resolve_safe_local_data_path, safe_upload_path
|
||||
|
||||
|
||||
class ResolveSafeLocalDataPathTests(TestCase):
|
||||
def test_accepts_file_under_logos_root(self):
|
||||
name = f"_jail_test_{uuid.uuid4().hex}.png"
|
||||
target = Path("/data/logos") / name
|
||||
target.write_bytes(b"x")
|
||||
try:
|
||||
resolved = resolve_safe_local_data_path(
|
||||
str(target), allowed_roots=("/data/logos",)
|
||||
)
|
||||
self.assertEqual(resolved, str(target.resolve()))
|
||||
finally:
|
||||
target.unlink(missing_ok=True)
|
||||
|
||||
def test_rejects_path_traversal_outside_root(self):
|
||||
self.assertIsNone(
|
||||
resolve_safe_local_data_path(
|
||||
"/data/../etc/passwd", allowed_roots=("/data/logos",)
|
||||
)
|
||||
)
|
||||
|
||||
def test_rejects_non_data_prefix(self):
|
||||
self.assertIsNone(resolve_safe_local_data_path("/etc/passwd"))
|
||||
|
||||
def test_safe_upload_path_strips_directory_components(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = safe_upload_path("../../evil.xml", tmp)
|
||||
self.assertEqual(Path(path).name, "evil.xml")
|
||||
self.assertTrue(Path(path).is_relative_to(Path(tmp).resolve()))
|
||||
|
||||
|
||||
class LogoCachePathJailTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
User = get_user_model()
|
||||
self.user, _ = User.objects.get_or_create(
|
||||
username="logo_cache_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
|
||||
def test_traversal_url_returns_404(self):
|
||||
logo = Logo.objects.create(
|
||||
name="Traversal",
|
||||
url="/data/../etc/passwd",
|
||||
)
|
||||
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
|
||||
force_authenticate(request, user=self.user)
|
||||
view = LogoViewSet.as_view({"get": "cache"})
|
||||
response = view(request, pk=logo.id)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_valid_local_logo_is_served(self):
|
||||
name = f"_jail_serve_{uuid.uuid4().hex}.png"
|
||||
file_path = Path("/data/logos") / name
|
||||
file_path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
logo = Logo.objects.create(name="Ok", url=str(file_path))
|
||||
try:
|
||||
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
|
||||
view = LogoViewSet.as_view({"get": "cache"})
|
||||
response = view(request, pk=logo.id)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
b"".join(response.streaming_content)
|
||||
finally:
|
||||
file_path.unlink(missing_ok=True)
|
||||
|
|
@ -12,8 +12,6 @@ from .serializers import (
|
|||
DeliveryLogSerializer,
|
||||
)
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
IsAdmin,
|
||||
)
|
||||
from .handlers.webhook import WebhookHandler
|
||||
|
|
@ -25,12 +23,8 @@ class IntegrationViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = IntegrationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
perms = permission_classes_by_action[self.action]
|
||||
except KeyError:
|
||||
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
|
||||
perms = getattr(self, "permission_classes", [Authenticated])
|
||||
return [perm() for perm in perms]
|
||||
# Integrations expose webhook URLs / script paths in config; admin only.
|
||||
return [IsAdmin()]
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="subscriptions")
|
||||
def list_subscriptions(self, request, pk=None):
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from apps.accounts.permissions import (
|
|||
permission_classes_by_action,
|
||||
permission_classes_by_method,
|
||||
)
|
||||
from core.utils import safe_upload_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -50,6 +51,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = EPGSourceSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "upload":
|
||||
return [IsAdmin()]
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
|
|
@ -57,7 +60,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
if self.request.method == 'GET':
|
||||
return [IsStandardUser()]
|
||||
return [IsAdmin()]
|
||||
return [Authenticated()]
|
||||
return [IsAdmin()]
|
||||
|
||||
def get_queryset(self):
|
||||
from django.db.models import Exists, OuterRef
|
||||
|
|
@ -82,8 +85,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
)
|
||||
|
||||
file = request.FILES["file"]
|
||||
file_name = file.name
|
||||
file_path = os.path.join("/data/uploads/epgs", file_name)
|
||||
try:
|
||||
file_path = safe_upload_path(file.name, "/data/uploads/epgs")
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"error": "Invalid filename"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "wb+") as destination:
|
||||
|
|
|
|||
49
apps/epg/tests/test_upload_security.py
Normal file
49
apps/epg/tests/test_upload_security.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""EPG upload path safety and admin-only permission."""
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from apps.epg.api_views import EPGSourceViewSet
|
||||
|
||||
|
||||
class EPGUploadSecurityTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_user(
|
||||
username="epg_upload_admin",
|
||||
password="x",
|
||||
user_level=User.UserLevel.ADMIN,
|
||||
)
|
||||
self.standard = User.objects.create_user(
|
||||
username="epg_upload_user",
|
||||
password="x",
|
||||
user_level=User.UserLevel.STANDARD,
|
||||
)
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def _upload(self, user, filename="guide.xml"):
|
||||
content = SimpleUploadedFile(
|
||||
filename, b"<?xml version='1.0'?><tv></tv>", content_type="application/xml"
|
||||
)
|
||||
request = self.factory.post(
|
||||
"/api/epg/sources/upload/",
|
||||
{"file": content, "name": "Uploaded", "source_type": "xmltv"},
|
||||
format="multipart",
|
||||
)
|
||||
force_authenticate(request, user=user)
|
||||
view = EPGSourceViewSet.as_view({"post": "upload"})
|
||||
return view(request)
|
||||
|
||||
def test_standard_user_forbidden(self):
|
||||
response = self._upload(self.standard)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@patch("apps.epg.api_views.safe_upload_path", side_effect=ValueError("bad"))
|
||||
def test_traversal_filename_rejected(self, _mock):
|
||||
response = self._upload(self.admin, filename="../../evil.xml")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("Invalid filename", response.data.get("error", ""))
|
||||
|
|
@ -189,6 +189,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
"password": {
|
||||
"required": False,
|
||||
"allow_blank": True,
|
||||
"write_only": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +214,13 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
|
||||
data = super().to_representation(instance)
|
||||
|
||||
# write_only strips password for everyone; re-add only for admins so
|
||||
# operator tooling / profile regex helpers still see credentials.
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None) if request else None
|
||||
if user is not None and getattr(user, "user_level", 0) >= 10:
|
||||
data["password"] = instance.password or ""
|
||||
|
||||
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
|
||||
custom_props = instance.custom_properties or {}
|
||||
|
||||
|
|
|
|||
46
apps/m3u/tests/test_password_visibility.py
Normal file
46
apps/m3u/tests/test_password_visibility.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""M3U account password visibility for admin vs standard users."""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.m3u.serializers import M3UAccountSerializer
|
||||
|
||||
|
||||
class M3UPasswordVisibilityTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_user(
|
||||
username="m3u_pwd_admin",
|
||||
password="x",
|
||||
user_level=User.UserLevel.ADMIN,
|
||||
)
|
||||
self.standard = User.objects.create_user(
|
||||
username="m3u_pwd_user",
|
||||
password="x",
|
||||
user_level=User.UserLevel.STANDARD,
|
||||
)
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="XC Acc",
|
||||
server_url="http://example.test",
|
||||
account_type="XC",
|
||||
username="xcuser",
|
||||
password="super-secret",
|
||||
)
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def test_admin_sees_password(self):
|
||||
request = self.factory.get("/api/m3u/accounts/")
|
||||
request.user = self.admin
|
||||
data = M3UAccountSerializer(
|
||||
self.account, context={"request": request}
|
||||
).data
|
||||
self.assertEqual(data.get("password"), "super-secret")
|
||||
|
||||
def test_standard_user_does_not_see_password(self):
|
||||
request = self.factory.get("/api/m3u/accounts/")
|
||||
request.user = self.standard
|
||||
data = M3UAccountSerializer(
|
||||
self.account, context={"request": request}
|
||||
).data
|
||||
self.assertNotIn("password", data)
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from django.urls import path, re_path, include
|
||||
from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream
|
||||
from core.views import stream_view
|
||||
|
||||
app_name = "output"
|
||||
|
||||
|
|
@ -9,6 +8,4 @@ urlpatterns = [
|
|||
re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"),
|
||||
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
|
||||
re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"),
|
||||
# Allow both `/stream/<int:stream_id>` and `/stream/<int:stream_id>/`
|
||||
re_path(r"^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$", stream_view, name="stream"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ def epg_endpoint(request, profile_name=None, user=None):
|
|||
def generate_m3u(request, profile_name=None, user=None):
|
||||
"""
|
||||
Dynamically generate an M3U file from channels.
|
||||
The stream URL now points to the new stream_view that uses StreamProfile.
|
||||
The stream URL points to the live TS proxy (``/proxy/ts/stream/``).
|
||||
Supports both GET and POST methods for compatibility with IPTVSmarters.
|
||||
"""
|
||||
# Check if this is a POST request and the body is not empty (which we don't want to allow)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,5 @@ urlpatterns = [
|
|||
path('stats/', stats_views.combined_stats, name='combined_stats'),
|
||||
path('ts/', include('apps.proxy.live_proxy.urls')),
|
||||
path('catchup/', include('apps.timeshift.urls')),
|
||||
path('hls/', include('apps.proxy.hls_proxy.urls')),
|
||||
path('vod/', include('apps.proxy.vod_proxy.urls')),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from apps.accounts.permissions import (
|
|||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
)
|
||||
from core.utils import resolve_safe_local_data_path
|
||||
from .models import (
|
||||
Series, VODCategory, Movie, Episode, VODLogo,
|
||||
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation
|
||||
|
|
@ -860,17 +861,16 @@ class VODLogoViewSet(viewsets.ModelViewSet):
|
|||
return HttpResponse(status=404)
|
||||
|
||||
# Check if this is a local file path
|
||||
if logo.url.startswith('/data/'):
|
||||
# It's a local file
|
||||
file_path = logo.url
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"VOD logo file not found: {file_path}")
|
||||
if logo.url.startswith('/data'):
|
||||
safe_path = resolve_safe_local_data_path(logo.url)
|
||||
if safe_path is None or not os.path.exists(safe_path):
|
||||
logger.error(f"VOD logo file not found or unsafe path: {logo.url}")
|
||||
return HttpResponse(status=404)
|
||||
|
||||
try:
|
||||
return FileResponse(open(file_path, 'rb'), content_type='image/png')
|
||||
return FileResponse(open(safe_path, 'rb'), content_type='image/png')
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving VOD logo file {file_path}: {str(e)}")
|
||||
logger.error(f"Error serving VOD logo file {safe_path}: {str(e)}")
|
||||
return HttpResponse(status=500)
|
||||
else:
|
||||
# It's a remote URL - proxy it
|
||||
|
|
|
|||
|
|
@ -584,7 +584,17 @@ class SystemNotificationViewSet(viewsets.ModelViewSet):
|
|||
Admins can create and manage notifications.
|
||||
"""
|
||||
serializer_class = SystemNotificationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in (
|
||||
"create",
|
||||
"update",
|
||||
"partial_update",
|
||||
"destroy",
|
||||
):
|
||||
return [IsAdmin()]
|
||||
# list, retrieve, dismiss, dismiss_all, unread_count
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
|
|
|
|||
62
core/tests/test_system_notifications_permissions.py
Normal file
62
core/tests/test_system_notifications_permissions.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""SystemNotification write vs dismiss permission split."""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
|
||||
from core.api_views import SystemNotificationViewSet
|
||||
from core.models import SystemNotification
|
||||
|
||||
|
||||
class SystemNotificationPermissionTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_user(
|
||||
username="notif_admin",
|
||||
password="x",
|
||||
user_level=User.UserLevel.ADMIN,
|
||||
)
|
||||
self.standard = User.objects.create_user(
|
||||
username="notif_user",
|
||||
password="x",
|
||||
user_level=User.UserLevel.STANDARD,
|
||||
)
|
||||
self.notif = SystemNotification.objects.create(
|
||||
notification_key="test.notif",
|
||||
title="Hello",
|
||||
message="World",
|
||||
is_active=True,
|
||||
admin_only=False,
|
||||
)
|
||||
self.factory = APIRequestFactory()
|
||||
|
||||
def test_standard_user_can_list(self):
|
||||
request = self.factory.get("/api/system/notifications/")
|
||||
force_authenticate(request, user=self.standard)
|
||||
view = SystemNotificationViewSet.as_view({"get": "list"})
|
||||
response = view(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_standard_user_cannot_create(self):
|
||||
request = self.factory.post(
|
||||
"/api/system/notifications/",
|
||||
{
|
||||
"notification_key": "evil",
|
||||
"title": "Phish",
|
||||
"message": "Click",
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.standard)
|
||||
view = SystemNotificationViewSet.as_view({"post": "create"})
|
||||
response = view(request)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_standard_user_can_dismiss(self):
|
||||
request = self.factory.post(
|
||||
f"/api/system/notifications/{self.notif.id}/dismiss/"
|
||||
)
|
||||
force_authenticate(request, user=self.standard)
|
||||
view = SystemNotificationViewSet.as_view({"post": "dismiss"})
|
||||
response = view(request, pk=self.notif.id)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
@ -660,6 +660,31 @@ def safe_upload_path(filename: str, base_dir) -> str:
|
|||
return str(file_path)
|
||||
|
||||
|
||||
def resolve_safe_local_data_path(path: str, allowed_roots=("/data/logos",)):
|
||||
"""Return a realpath under one of *allowed_roots*, or None if unsafe.
|
||||
|
||||
Rejects path traversal (``/data/../etc/passwd``), symlinks that escape
|
||||
the allowlisted roots, and non-``/data`` paths. Used by logo cache
|
||||
endpoints that must remain AllowAny for player clients.
|
||||
"""
|
||||
if not path or not isinstance(path, str):
|
||||
return None
|
||||
if not path.startswith("/data"):
|
||||
return None
|
||||
try:
|
||||
real = Path(path).resolve()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
root_real = Path(root).resolve()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
if real == root_real or real.is_relative_to(root_real):
|
||||
return str(real)
|
||||
return None
|
||||
|
||||
|
||||
def is_protected_path(file_path):
|
||||
"""
|
||||
Determine if a file path is in a protected directory that shouldn't be deleted.
|
||||
|
|
|
|||
205
core/views.py
205
core/views.py
|
|
@ -1,214 +1,9 @@
|
|||
# core/views.py
|
||||
import os
|
||||
import signal
|
||||
from shlex import split as shlex_split
|
||||
import sys
|
||||
import logging
|
||||
import regex
|
||||
import redis
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse, HttpResponseServerError
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.channels.models import Channel, Stream
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
|
||||
# Import the persistent lock (the “real” lock)
|
||||
from dispatcharr.persistent_lock import PersistentLock
|
||||
|
||||
# Configure logging to output to the console.
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def settings_view(request):
|
||||
"""
|
||||
Renders the settings page.
|
||||
"""
|
||||
return render(request, 'settings.html')
|
||||
|
||||
|
||||
def stream_view(request, channel_uuid):
|
||||
"""
|
||||
Streams the first available stream for the given channel.
|
||||
It uses the channel’s assigned StreamProfile.
|
||||
A persistent Redis lock is used to prevent concurrent streaming on the same channel.
|
||||
"""
|
||||
try:
|
||||
redis_host = getattr(settings, "REDIS_HOST", "localhost")
|
||||
redis_port = int(getattr(settings, "REDIS_PORT", 6379))
|
||||
redis_db = int(getattr(settings, "REDIS_DB", "0"))
|
||||
redis_password = getattr(settings, "REDIS_PASSWORD", "")
|
||||
redis_user = getattr(settings, "REDIS_USER", "")
|
||||
ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {})
|
||||
redis_client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=redis_port,
|
||||
db=redis_db,
|
||||
password=redis_password if redis_password else None,
|
||||
username=redis_user if redis_user else None,
|
||||
**ssl_params
|
||||
)
|
||||
|
||||
# Retrieve the channel by the provided stream_id.
|
||||
channel = Channel.objects.get(uuid=channel_uuid)
|
||||
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name)
|
||||
|
||||
# Ensure the channel has at least one stream.
|
||||
if not channel.streams.exists():
|
||||
logger.error("No streams found for channel ID=%s", channel.id)
|
||||
return HttpResponseServerError("No stream found for this channel.")
|
||||
|
||||
active_stream = None
|
||||
m3u_account = None
|
||||
active_profile = None
|
||||
lock_key = None
|
||||
persistent_lock = None
|
||||
|
||||
streams = channel.streams.all().order_by('channelstream__order')
|
||||
logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}')
|
||||
for stream in streams:
|
||||
# Get the first available stream.
|
||||
logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name)
|
||||
|
||||
# Retrieve the M3U account associated with the stream.
|
||||
m3u_account = stream.m3u_account
|
||||
logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
|
||||
|
||||
# Use the custom URL if available; otherwise, use the standard URL.
|
||||
input_url = stream.url
|
||||
logger.debug("Input URL: %s", input_url)
|
||||
|
||||
# Determine which profile we can use.
|
||||
m3u_profiles = m3u_account.profiles.all()
|
||||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
profiles = [obj for obj in m3u_profiles if not obj.is_default]
|
||||
|
||||
# -- Loop through profiles and pick the first active one --
|
||||
for profile in [default_profile] + profiles:
|
||||
logger.debug(f'Checking profile {profile.name}...')
|
||||
if not profile.is_active:
|
||||
logger.debug('Profile is not active, skipping.')
|
||||
continue
|
||||
|
||||
logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available')
|
||||
stream_index = 0
|
||||
max_streams = profile.max_streams
|
||||
if max_streams == 0:
|
||||
max_streams = 999999 # maybe a better way than just hardcoding a high number...
|
||||
while stream_index < max_streams:
|
||||
stream_index += 1
|
||||
|
||||
lock_key = f"lock:{profile.id}:{stream_index}"
|
||||
persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120)
|
||||
logger.debug(f'Attempting to acquire lock: {lock_key}')
|
||||
|
||||
if not persistent_lock.acquire():
|
||||
logger.error(f"Could not acquire persistent lock for profile {profile.id} index {stream_index}, currently in use.")
|
||||
persistent_lock = None
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
if persistent_lock is not None:
|
||||
logger.debug(f'Successfully acquired lock: {lock_key}')
|
||||
active_profile = M3UAccountProfile.objects.get(id=profile.id)
|
||||
break
|
||||
|
||||
if active_profile is None or persistent_lock is None:
|
||||
logger.exception("No available profiles for the stream")
|
||||
continue
|
||||
|
||||
logger.debug(f"Found available stream profile: stream={stream.name}, profile={profile.name}")
|
||||
break
|
||||
|
||||
if not active_profile:
|
||||
logger.exception("No available streams for this channel")
|
||||
return HttpResponseServerError("No available streams for this channel")
|
||||
|
||||
logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)")
|
||||
# Prepare the pattern replacement.
|
||||
logger.debug("Executing the following pattern replacement:")
|
||||
logger.debug(f" search: {active_profile.search_pattern}")
|
||||
# Convert JS-style backreferences in replace: $<name> -> \g<name>, $1 -> \1
|
||||
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern)
|
||||
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
|
||||
logger.debug(f" replace: {active_profile.replace_pattern}")
|
||||
logger.debug(f" safe replace: {safe_replace_pattern}")
|
||||
# regex module accepts JS-style (?<name>...) named groups natively
|
||||
stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url)
|
||||
logger.debug(f"Generated stream url: {stream_url}")
|
||||
|
||||
# Get the stream profile set on the channel.
|
||||
stream_profile = channel.stream_profile
|
||||
if not stream_profile:
|
||||
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
|
||||
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
|
||||
|
||||
logger.debug("Stream profile used: %s", stream_profile.name)
|
||||
|
||||
# Determine the user agent to use.
|
||||
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
|
||||
logger.debug("User agent: %s", user_agent)
|
||||
|
||||
# Substitute placeholders in the parameters template.
|
||||
parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
|
||||
logger.debug("Formatted parameters: %s", parameters)
|
||||
|
||||
# Build the final command.
|
||||
cmd = [stream_profile.command] + shlex_split(parameters)
|
||||
logger.debug("Executing command: %s", cmd)
|
||||
|
||||
try:
|
||||
stdout_r, stdout_w = os.pipe()
|
||||
devnull_r = os.open(os.devnull, os.O_RDONLY)
|
||||
devnull_w = os.open(os.devnull, os.O_WRONLY)
|
||||
file_actions = [
|
||||
(os.POSIX_SPAWN_DUP2, devnull_r, 0),
|
||||
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
|
||||
(os.POSIX_SPAWN_DUP2, devnull_w, 2),
|
||||
(os.POSIX_SPAWN_CLOSE, devnull_r),
|
||||
(os.POSIX_SPAWN_CLOSE, devnull_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stdout_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stdout_r),
|
||||
]
|
||||
proc_pid = os.posix_spawn(cmd[0], cmd, dict(os.environ), file_actions=file_actions)
|
||||
for fd in (devnull_r, devnull_w, stdout_w):
|
||||
os.close(fd)
|
||||
proc_stdout = os.fdopen(stdout_r, 'rb')
|
||||
except Exception as e:
|
||||
persistent_lock.release()
|
||||
logger.exception("Error starting stream for channel ID=%s", channel_uuid)
|
||||
return HttpResponseServerError(f"Error starting stream: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error preparing stream for channel ID=%s", channel_uuid)
|
||||
return HttpResponseServerError(f"Error preparing stream: {e}")
|
||||
|
||||
def stream_generator(stdout_file, pid, persistent_lock):
|
||||
try:
|
||||
while True:
|
||||
chunk = stdout_file.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
logger.debug("Streaming process terminated for channel ID=%s", channel.id)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
stdout_file.close()
|
||||
persistent_lock.release()
|
||||
logger.debug("Persistent lock released for channel ID=%s", channel.id)
|
||||
|
||||
return StreamingHttpResponse(
|
||||
stream_generator(proc_stdout, proc_pid, persistent_lock),
|
||||
content_type="video/MP2T"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -77,16 +77,6 @@ server {
|
|||
uwsgi_pass unix:/app/uwsgi.sock;
|
||||
}
|
||||
|
||||
# Serve FFmpeg streams efficiently
|
||||
location /output/stream/ {
|
||||
proxy_pass http://127.0.0.1:5656;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Connection keep-alive;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# WebSockets for real-time communication
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue