Added streaming auth

This commit is contained in:
Chris 2026-04-05 15:09:07 +01:00
parent c342c8b1e2
commit 2c60976525
5 changed files with 138 additions and 2 deletions

View File

@ -30,4 +30,9 @@ urlpatterns = [
views.HealthCheck.as_view(),
name="api-health",
),
path(
"stream-auth/",
views.StreamAuthView.as_view(),
name="api-stream-auth",
),
]

View File

@ -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)

View File

@ -30,4 +30,9 @@ urlpatterns = [
views.VideoSimilarView.as_view(),
name="api-video-similar",
),
path(
"<slug:video_id>/stream-token/",
views.StreamTokenView.as_view(),
name="api-video-stream-token",
),
]

View File

@ -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/<video_id>/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)

View File

@ -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;