From 9302559d4bb3e4d0adb299ed37438b04c39e1864 Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Sun, 17 May 2026 10:32:13 +0300 Subject: [PATCH] feat: introduce x-accell headers --- cms/settings.py | 9 +++ cms/version.py | 2 +- deploy/docker/nginx_http_only.conf | 22 +++++++- files/urls.py | 1 + files/views/__init__.py | 1 + files/views/media_auth.py | 90 ++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 files/views/media_auth.py diff --git a/cms/settings.py b/cms/settings.py index 480ed02d..1632f4bd 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -202,6 +202,15 @@ THUMBNAIL_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/thumbnails/" SUBTITLES_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/subtitles/" HLS_DIR = os.path.join(MEDIA_ROOT, "hls/") +# Protect media files via nginx auth_request +# When True, nginx delegates authorization for /media//... to a +# Django endpoint that checks the Media's state and the user's access. +USE_X_ACCEL_REDIRECT = True +# Subdirectories of MEDIA_ROOT that should be gated. "chunks" is intentionally +# omitted (upload state, not playback). +X_ACCEL_PROTECTED_PATHS = ["encoded", "hls", "original"] +X_ACCEL_AUTH_CACHE_SECONDS = 300 + FFMPEG_COMMAND = "ffmpeg" # this is the path FFPROBE_COMMAND = "ffprobe" # this is the path MP4HLS = "mp4hls" diff --git a/cms/version.py b/cms/version.py index d8081641..3bbed43f 100644 --- a/cms/version.py +++ b/cms/version.py @@ -1 +1 @@ -VERSION = "8.0.8" +VERSION = "8.1.0" diff --git a/deploy/docker/nginx_http_only.conf b/deploy/docker/nginx_http_only.conf index b46c7992..ca494465 100644 --- a/deploy/docker/nginx_http_only.conf +++ b/deploy/docker/nginx_http_only.conf @@ -15,9 +15,27 @@ server { location /static { alias /home/mediacms.io/mediacms/static ; } + location = /_media_auth { + internal; + proxy_pass http://127.0.0.1:9000/api/v1/media-auth; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Host $host; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header Cookie $http_cookie; + } - location /media/original { - alias /home/mediacms.io/mediacms/media_files/original; + location ~ ^/media/(encoded|hls|original)/(.*)$ { + auth_request /_media_auth; + alias /home/mediacms.io/mediacms/media_files/$1/$2; + + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; } location /media { diff --git a/files/urls.py b/files/urls.py index e61b9dcf..c032e80f 100644 --- a/files/urls.py +++ b/files/urls.py @@ -106,6 +106,7 @@ urlpatterns = [ re_path(r"^api/v1/tasks$", views.TasksList.as_view()), re_path(r"^api/v1/tasks/$", views.TasksList.as_view()), re_path(r"^api/v1/tasks/(?P[\w|\W]*)$", views.TaskDetail.as_view()), + re_path(r"^api/v1/media-auth$", views.media_auth, name="media_auth"), re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"), re_path(r"^manage/media$", views.manage_media, name="manage_media"), re_path(r"^manage/users$", views.manage_users, name="manage_users"), diff --git a/files/views/__init__.py b/files/views/__init__.py index db5651ba..f1c8c628 100644 --- a/files/views/__init__.py +++ b/files/views/__init__.py @@ -10,6 +10,7 @@ from .media import MediaDetail # noqa: F401 from .media import MediaList # noqa: F401 from .media import MediaSearch # noqa: F401 from .media import media_share # noqa: F401 +from .media_auth import media_auth # noqa: F401 from .pages import about # noqa: F401 from .pages import add_subtitle # noqa: F401 from .pages import approval_required # noqa: F401 diff --git a/files/views/media_auth.py b/files/views/media_auth.py new file mode 100644 index 00000000..7c54d0c0 --- /dev/null +++ b/files/views/media_auth.py @@ -0,0 +1,90 @@ +import re + +from django.conf import settings +from django.core.cache import cache +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET + +from ..methods import is_mediacms_editor +from ..models import Media + +UID_RE = re.compile(r"[0-9a-f]{32}") + + +def _ttl(): + return getattr(settings, "X_ACCEL_AUTH_CACHE_SECONDS", 300) + + +def _extract_uid(uri): + if not uri: + return None + match = UID_RE.search(uri) + return match.group(0) if match else None + + +def _lookup_state(uid): + """Return (state, owner_id) for a uid, or (None, None) if missing. + + Cached on uid alone since state/ownership do not depend on the requester. + Uses .values() rather than .only() because Media.__init__ touches deferred + file fields, which would otherwise recurse via refresh_from_db. + """ + state_key = f"xaccel:state:{uid}" + cached = cache.get(state_key) + if cached is not None: + return cached + row = Media.objects.filter(uid=uid).values("state", "user_id").first() + value = (row["state"], row["user_id"]) if row else (None, None) + cache.set(state_key, value, _ttl()) + return value + + +def _decide(uid, user): + state, owner_id = _lookup_state(uid) + if state is None: + return False + if state in ("public", "unlisted"): + return True + # private + if not user.is_authenticated: + return False + if owner_id == user.id: + return True + if is_mediacms_editor(user): + return True + # RBAC / MediaPermission path needs a full Media instance. + try: + media = Media.objects.get(uid=uid) + except Media.DoesNotExist: + return False + return user.has_member_access_to_media(media) + + +@csrf_exempt +@require_GET +def media_auth(request): + """Authorize a protected media request from nginx auth_request. + + nginx passes the original request URI in the X-Original-URI header. The + Media.uid (32 hex chars, no dashes) is embedded somewhere in that URI for + every protected path. No uid => deny. Unknown uid => deny. + """ + if not getattr(settings, "USE_X_ACCEL_REDIRECT", True): + return HttpResponse(status=204) + + uri = request.META.get("HTTP_X_ORIGINAL_URI", "") + uid = _extract_uid(uri) + if not uid: + return HttpResponse(status=403) + + user = request.user + cache_key = f"xaccel:auth:{uid}:{user.id if user.is_authenticated else 'anon'}" + cached = cache.get(cache_key) + if cached is None: + allowed = _decide(uid, user) + cache.set(cache_key, allowed, _ttl()) + else: + allowed = cached + + return HttpResponse(status=204 if allowed else 403)