Support cross-origin frontends alongside same-origin setups

- django-cors-headers with env-driven CORS_ALLOWED_ORIGINS,
  CORS_ALLOW_ALL_ORIGINS, CORS_ALLOW_CREDENTIALS and CSRF_TRUSTED_ORIGINS;
  same-origin traffic is unaffected and a disallowed origin gets no CORS
  headers. Token auth needs no cookies, so credentials stay off by default.
- TRUST_PROXY_HEADERS=true lets a TLS-terminating proxy supply
  X-Forwarded-Proto/Host for correct absolute URLs.
- API media URLs (raw/thumbnail/upload/similarity/staged previews) are now
  absolute, built from the request host, so <img>/<video>/fetch() keep
  working when the SPA is served from another origin. Signed URLs are still
  per-user; nothing is stored in the DB.
- The SPA gains VITE_API_BASE (build-time, empty = same-origin) applied by
  a small apiUrl() helper used for XHR/fetch and the few URL fallbacks.

Verified with a throwaway instance: preflight and GET responses carry the
allowed origin, foreign origins get nothing, media GETs include CORS for
cross-origin fetch(), and payload URLs use the request host (dev :8000
unchanged).
This commit is contained in:
2026-09-17 22:50:12 -05:00
parent 98a674d55e
commit 16907c39ca
16 changed files with 139 additions and 52 deletions
+3 -7
View File
@@ -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):
+17 -8
View File
@@ -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 <img>/<video> tag can load: staged file or library item."""
@@ -202,11 +211,9 @@ class TempUploadSerializer(serializers.ModelSerializer):
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 signed_media_url(
item, user, action, request=self.context.get("request")
)
return f"/api/files/J-{item.id}/{action}/?sig={signature}"
class DownloadTaskSerializer(serializers.ModelSerializer):
@@ -292,7 +299,9 @@ class SimilarityCheckSerializer(serializers.ModelSerializer):
{"check": str(obj.id), "user": user.id},
salt=UPLOAD_FILE_SALT,
)
return f"/api/similarity/{obj.id}/file/?sig={signature}"
url = f"/api/similarity/{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_expires_at(self, obj):
ttl = int(getattr(settings, "SIMILARITY_TTL_MINUTES", 30))
+16 -10
View File
@@ -73,16 +73,22 @@ def index_file(path, folder):
return item, created_item, location, created_location
def signed_media_url(item, user, action="raw"):
"""Media URL that <img>/<video> tags can load for a signed-in user."""
base = f"/api/files/J-{item.id}/{action}/"
if user is None or not getattr(user, "is_authenticated", False):
return base
signature = signing.dumps(
{"item": item.id, "user": user.id, "action": action},
salt=MEDIA_FILE_SALT,
)
return f"{base}?sig={signature}"
def signed_media_url(item, user, action="raw", request=None):
"""Media URL that <img>/<video> tags can load for a signed-in user.
With a ``request`` the URL is absolute, so the SPA also works when it is
served from a different origin; without one it stays relative.
"""
path = f"/api/files/J-{item.id}/{action}/"
if user is not None and getattr(user, "is_authenticated", False):
signature = signing.dumps(
{"item": item.id, "user": user.id, "action": action},
salt=MEDIA_FILE_SALT,
)
path = f"{path}?sig={signature}"
if request is None:
return path
return request.build_absolute_uri(path)
def rename_location_to_j_id(item, location):
+3 -1
View File
@@ -119,7 +119,9 @@ class SimilarityCheckViewSet(
check.md5 = services.compute_md5(check.file.path)
exact = MediaItem.objects.filter(md5=check.md5).first()
matches = find_library_matches(check.file.path, limit=12, user=request.user)
matches = find_library_matches(
check.file.path, limit=12, user=request.user, request=request
)
if exact is not None:
exact_j_id = f"J-{exact.id}"
matches = [
+1 -1
View File
@@ -99,7 +99,7 @@ def item_brief(item, request=None):
"location_count": len(locations),
"uploaded_by": item.uploaded_by.username if item.uploaded_by else None,
"e621_post_id": item.e621_post_id,
"thumbnail_url": signed_media_url(item, user, "thumbnail"),
"thumbnail_url": signed_media_url(item, user, "thumbnail", request=request),
}
+7 -3
View File
@@ -34,7 +34,7 @@ from .tools import HASH_FIELDS, hashes_similarity
logger = logging.getLogger(__name__)
def find_library_matches(path, limit=10, user=None):
def find_library_matches(path, limit=10, user=None, request=None):
"""Library items visually similar to a staged file."""
hashes = services.compute_visual_hashes(path)
if not hashes:
@@ -57,7 +57,9 @@ def find_library_matches(path, limit=10, user=None):
"j_id": f"J-{item.id}",
"filename": Path(location.rel_path).name if location else item.md5,
"similarity": round(similarity * 100, 1),
"thumbnail_url": services.signed_media_url(item, user, "thumbnail"),
"thumbnail_url": services.signed_media_url(
item, user, "thumbnail", request=request
),
}
)
matches.sort(key=lambda entry: entry["similarity"], reverse=True)
@@ -185,7 +187,9 @@ class TempUploadViewSet(
temp.library_item = existing
temp.file.delete(save=False)
else:
matches = find_library_matches(temp.file.path, user=request.user)
matches = find_library_matches(
temp.file.path, user=request.user, request=request
)
if matches:
temp.visual_matches = matches
temp.status = TempUpload.STATUS_VISUAL_MATCH