diff --git a/AGENTS.md b/AGENTS.md index 52352b3..c8b31cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,12 @@ Project constraints (do not regress): MariaDB/Redis come from docker-compose.yml. - Deployment will be Docker-based (compose); do not add systemd/cron unit files for scheduling — use the container setup for timers/workers. +- Same-origin and cross-origin frontends both work: the SPA uses relative + URLs unless built with VITE_API_BASE, the backend allows extra origins + via CORS_ALLOWED_ORIGINS (plus CSRF_TRUSTED_ORIGINS for the admin), and + API media URLs are absolute (built from the request host), so signed + files load cross-origin too. TRUST_PROXY_HEADERS=true is required behind + a TLS-terminating proxy. - No server-side media processing: the home server cannot handle it. Compression/optimization runs client-side (WebCodecs + WASM in a worker) and the server only applies the result via POST /api/files/J-x/optimize/. diff --git a/backend/.env.example b/backend/.env.example index 1ee89e8..1d59693 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,18 @@ SECRET_KEY=change-me-to-a-long-random-string DEBUG=True ALLOWED_HOSTS=localhost,127.0.0.1 + +# Cross-origin frontends (comma separated). Same-origin keeps working with an +# empty list; add e.g. https://j621.example.com when the SPA is served from +# another origin than this API. +# CORS_ALLOWED_ORIGINS=https://j621.example.com +# CORS_ALLOW_ALL_ORIGINS=false +# CORS_ALLOW_CREDENTIALS=false +# CSRF_TRUSTED_ORIGINS=https://j621.example.com +# Trust X-Forwarded-Proto/Host from a TLS-terminating reverse proxy so media +# URLs keep https and the public hostname. +# TRUST_PROXY_HEADERS=true + # Absolute path to your media folder, or relative to the backend/ folder. WATCHED_FOLDER=media/library diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 16489f9..ba2f02a 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -1,10 +1,10 @@ from pathlib import Path from django.contrib.auth.password_validation import validate_password -from django.core import signing from rest_framework import serializers -from apps.library.services import MEDIA_FILE_SALT, VIDEO_EXTENSIONS +from apps.library.services import VIDEO_EXTENSIONS +from apps.library.services import signed_media_url as signed_library_url from .models import User @@ -22,11 +22,7 @@ def signed_media_url(request, item): and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS ) action = "thumbnail" if is_video else "raw" - signature = signing.dumps( - {"item": item.id, "user": user.id, "action": action}, - salt=MEDIA_FILE_SALT, - ) - return f"/api/files/J-{item.id}/{action}/?sig={signature}" + return signed_library_url(item, user, action, request=request) class UserSerializer(serializers.ModelSerializer): diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py index 0dea0df..6f5ee6c 100644 --- a/backend/apps/library/serializers.py +++ b/backend/apps/library/serializers.py @@ -103,10 +103,17 @@ class MediaItemSerializer(serializers.ModelSerializer): return f"J-{obj.id}" def get_raw_url(self, obj): - return signed_media_url(obj, self._request_user(), "raw") + return signed_media_url( + obj, self._request_user(), "raw", request=self.context.get("request") + ) def get_thumbnail_url(self, obj): - return signed_media_url(obj, self._request_user(), "thumbnail") + return signed_media_url( + obj, + self._request_user(), + "thumbnail", + request=self.context.get("request"), + ) def get_display_rating(self, obj): if obj.rating: @@ -184,7 +191,9 @@ class TempUploadSerializer(serializers.ModelSerializer): {"temp": str(obj.id), "user": user.id}, salt=UPLOAD_FILE_SALT, ) - return f"/api/uploads/{obj.id}/file/?sig={signature}" + url = f"/api/uploads/{obj.id}/file/?sig={signature}" + request = self.context.get("request") + return request.build_absolute_uri(url) if request is not None else url def get_preview_url(self, obj): """A URL an /