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
/