diff --git a/backend/common/urls.py b/backend/common/urls.py index 92ed43b9..6f3430c5 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -30,4 +30,9 @@ urlpatterns = [ views.HealthCheck.as_view(), name="api-health", ), + path( + "stream-auth/", + views.StreamAuthView.as_view(), + name="api-stream-auth", + ), ] diff --git a/backend/common/views.py b/backend/common/views.py index f7ba9973..60a522df 100644 --- a/backend/common/views.py +++ b/backend/common/views.py @@ -1,5 +1,10 @@ """all API views""" +import hashlib +import hmac +import time +from urllib.parse import parse_qs, urlparse + from appsettings.src.config import ReleaseVersion from appsettings.src.reindex import ReindexProgress from common.serializers import ( @@ -18,6 +23,8 @@ from common.src.searching import SearchForm from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminOnly, ApiBaseView +from django.conf import settings +from django.http import HttpResponse from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.response import Response from rest_framework.views import APIView @@ -208,3 +215,57 @@ class HealthCheck(APIView): def get(self, request): """health check, no auth needed""" return Response("OK", status=200) + + +class StreamAuthView(APIView): + """resolves to /api/stream-auth/ + Called by nginx auth_request to validate signed streaming URLs. + Reads the original URI from X-Original-URI, extracts sig + expires + query params, validates HMAC and Redis presence. No DRF auth. + """ + + authentication_classes = [] + permission_classes = [] + + def get(self, request): + # pylint: disable=too-many-return-statements + """validate a signed stream URL""" + original_uri = request.META.get("HTTP_X_ORIGINAL_URI", "") + parsed = urlparse(original_uri) + query = parse_qs(parsed.query) + + sig = query.get("sig", [None])[0] + expires_raw = query.get("expires", [None])[0] + + if not sig or not expires_raw: + return HttpResponse(status=403) + + try: + expires = int(expires_raw) + except ValueError: + return HttpResponse(status=403) + + if expires < int(time.time()): + return HttpResponse(status=403) + + cached = RedisArchivist().get_message_dict(f"stream_token:{sig}") + if not cached: + return HttpResponse(status=403) + + payload = ( + f"{cached['video_id']}:{expires}:{cached['user_id']}" + ) + expected = hmac.new( + settings.SECRET_KEY.encode(), + payload.encode(), + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(sig, expected): + return HttpResponse(status=403) + + # Ensure the requested URI matches the signed video + if f"/{cached['video_id']}." not in parsed.path: + return HttpResponse(status=403) + + return HttpResponse(status=200) diff --git a/backend/video/urls.py b/backend/video/urls.py index f520fb0b..d337860c 100644 --- a/backend/video/urls.py +++ b/backend/video/urls.py @@ -30,4 +30,9 @@ urlpatterns = [ views.VideoSimilarView.as_view(), name="api-video-similar", ), + path( + "/stream-token/", + views.StreamTokenView.as_view(), + name="api-video-stream-token", + ), ] diff --git a/backend/video/views.py b/backend/video/views.py index 4bb95d87..571089a9 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -1,10 +1,15 @@ """all API views for video endpoints""" +import hashlib +import hmac +import time + from common.serializers import ErrorResponseSerializer from common.src.helper import calc_is_watched from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminWriteOnly, ApiBaseView +from django.conf import settings from drf_spectacular.utils import OpenApiResponse, extend_schema from playlist.src.index import YoutubePlaylist from rest_framework.response import Response @@ -287,3 +292,49 @@ class VideoSimilarView(ApiBaseView): self.get_document_list(request, pagination=False) serializer = VideoSerializer(self.response["data"], many=True) return Response(serializer.data) + + +class StreamTokenView(ApiBaseView): + """resolves to /api/video//stream-token/ + POST: mint a short-lived signed streaming token for VLC playback + DELETE: revoke the active streaming token + """ + + search_base = "ta_video/_doc/" + TTL_SECONDS = 300 # 5 minutes + + def post(self, request, video_id): + """mint a streaming token""" + self.get_document(video_id) + if not self.response: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + expires = int(time.time()) + self.TTL_SECONDS + user_id = request.user.id + payload = f"{video_id}:{expires}:{user_id}" + sig = hmac.new( + settings.SECRET_KEY.encode(), + payload.encode(), + hashlib.sha256, + ).hexdigest() + + RedisArchivist().set_message( + f"stream_token:{sig}", + { + "video_id": video_id, + "user_id": user_id, + "expires": expires, + }, + expire=self.TTL_SECONDS, + ) + + return Response({"sig": sig, "expires": expires}) + + def delete(self, request, video_id): + # pylint: disable=unused-argument + """revoke a streaming token""" + sig = request.data.get("sig") or request.query_params.get("sig") + if sig: + RedisArchivist().del_message(f"stream_token:{sig}") + return Response(status=204) diff --git a/docker_assets/nginx.conf b/docker_assets/nginx.conf index 75014784..80f0af41 100644 --- a/docker_assets/nginx.conf +++ b/docker_assets/nginx.conf @@ -26,13 +26,27 @@ server { } location /youtube/ { - auth_request /api/ping/; + # Use signed-URL auth if ?sig= is present, otherwise session auth + set $auth_endpoint /api/ping/; + if ($arg_sig) { + set $auth_endpoint /api/stream-auth/; + } + auth_request $auth_endpoint; alias /youtube/; types { video/mp4 mp4; } } - + + location = /api/stream-auth/ { + internal; + include proxy_params; + proxy_pass http://localhost:8080; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + } + location /api { include proxy_params; proxy_pass http://localhost:8080;