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:
@@ -31,6 +31,12 @@ Project constraints (do not regress):
|
|||||||
MariaDB/Redis come from docker-compose.yml.
|
MariaDB/Redis come from docker-compose.yml.
|
||||||
- Deployment will be Docker-based (compose); do not add systemd/cron unit
|
- Deployment will be Docker-based (compose); do not add systemd/cron unit
|
||||||
files for scheduling — use the container setup for timers/workers.
|
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.
|
- No server-side media processing: the home server cannot handle it.
|
||||||
Compression/optimization runs client-side (WebCodecs + WASM in a worker)
|
Compression/optimization runs client-side (WebCodecs + WASM in a worker)
|
||||||
and the server only applies the result via POST /api/files/J-x/optimize/.
|
and the server only applies the result via POST /api/files/J-x/optimize/.
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
SECRET_KEY=change-me-to-a-long-random-string
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
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.
|
# Absolute path to your media folder, or relative to the backend/ folder.
|
||||||
WATCHED_FOLDER=media/library
|
WATCHED_FOLDER=media/library
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.contrib.auth.password_validation import validate_password
|
from django.contrib.auth.password_validation import validate_password
|
||||||
from django.core import signing
|
|
||||||
from rest_framework import serializers
|
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
|
from .models import User
|
||||||
|
|
||||||
@@ -22,11 +22,7 @@ def signed_media_url(request, item):
|
|||||||
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
|
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
|
||||||
)
|
)
|
||||||
action = "thumbnail" if is_video else "raw"
|
action = "thumbnail" if is_video else "raw"
|
||||||
signature = signing.dumps(
|
return signed_library_url(item, user, action, request=request)
|
||||||
{"item": item.id, "user": user.id, "action": action},
|
|
||||||
salt=MEDIA_FILE_SALT,
|
|
||||||
)
|
|
||||||
return f"/api/files/J-{item.id}/{action}/?sig={signature}"
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -103,10 +103,17 @@ class MediaItemSerializer(serializers.ModelSerializer):
|
|||||||
return f"J-{obj.id}"
|
return f"J-{obj.id}"
|
||||||
|
|
||||||
def get_raw_url(self, obj):
|
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):
|
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):
|
def get_display_rating(self, obj):
|
||||||
if obj.rating:
|
if obj.rating:
|
||||||
@@ -184,7 +191,9 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
|||||||
{"temp": str(obj.id), "user": user.id},
|
{"temp": str(obj.id), "user": user.id},
|
||||||
salt=UPLOAD_FILE_SALT,
|
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):
|
def get_preview_url(self, obj):
|
||||||
"""A URL an <img>/<video> tag can load: staged file or library item."""
|
"""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
|
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
|
||||||
)
|
)
|
||||||
action = "thumbnail" if is_video else "raw"
|
action = "thumbnail" if is_video else "raw"
|
||||||
signature = signing.dumps(
|
return signed_media_url(
|
||||||
{"item": item.id, "user": user.id, "action": action},
|
item, user, action, request=self.context.get("request")
|
||||||
salt=MEDIA_FILE_SALT,
|
|
||||||
)
|
)
|
||||||
return f"/api/files/J-{item.id}/{action}/?sig={signature}"
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadTaskSerializer(serializers.ModelSerializer):
|
class DownloadTaskSerializer(serializers.ModelSerializer):
|
||||||
@@ -292,7 +299,9 @@ class SimilarityCheckSerializer(serializers.ModelSerializer):
|
|||||||
{"check": str(obj.id), "user": user.id},
|
{"check": str(obj.id), "user": user.id},
|
||||||
salt=UPLOAD_FILE_SALT,
|
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):
|
def get_expires_at(self, obj):
|
||||||
ttl = int(getattr(settings, "SIMILARITY_TTL_MINUTES", 30))
|
ttl = int(getattr(settings, "SIMILARITY_TTL_MINUTES", 30))
|
||||||
|
|||||||
@@ -73,16 +73,22 @@ def index_file(path, folder):
|
|||||||
return item, created_item, location, created_location
|
return item, created_item, location, created_location
|
||||||
|
|
||||||
|
|
||||||
def signed_media_url(item, user, action="raw"):
|
def signed_media_url(item, user, action="raw", request=None):
|
||||||
"""Media URL that <img>/<video> tags can load for a signed-in user."""
|
"""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):
|
With a ``request`` the URL is absolute, so the SPA also works when it is
|
||||||
return base
|
served from a different origin; without one it stays relative.
|
||||||
signature = signing.dumps(
|
"""
|
||||||
{"item": item.id, "user": user.id, "action": action},
|
path = f"/api/files/J-{item.id}/{action}/"
|
||||||
salt=MEDIA_FILE_SALT,
|
if user is not None and getattr(user, "is_authenticated", False):
|
||||||
)
|
signature = signing.dumps(
|
||||||
return f"{base}?sig={signature}"
|
{"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):
|
def rename_location_to_j_id(item, location):
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ class SimilarityCheckViewSet(
|
|||||||
check.md5 = services.compute_md5(check.file.path)
|
check.md5 = services.compute_md5(check.file.path)
|
||||||
|
|
||||||
exact = MediaItem.objects.filter(md5=check.md5).first()
|
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:
|
if exact is not None:
|
||||||
exact_j_id = f"J-{exact.id}"
|
exact_j_id = f"J-{exact.id}"
|
||||||
matches = [
|
matches = [
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ def item_brief(item, request=None):
|
|||||||
"location_count": len(locations),
|
"location_count": len(locations),
|
||||||
"uploaded_by": item.uploaded_by.username if item.uploaded_by else None,
|
"uploaded_by": item.uploaded_by.username if item.uploaded_by else None,
|
||||||
"e621_post_id": item.e621_post_id,
|
"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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from .tools import HASH_FIELDS, hashes_similarity
|
|||||||
logger = logging.getLogger(__name__)
|
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."""
|
"""Library items visually similar to a staged file."""
|
||||||
hashes = services.compute_visual_hashes(path)
|
hashes = services.compute_visual_hashes(path)
|
||||||
if not hashes:
|
if not hashes:
|
||||||
@@ -57,7 +57,9 @@ def find_library_matches(path, limit=10, user=None):
|
|||||||
"j_id": f"J-{item.id}",
|
"j_id": f"J-{item.id}",
|
||||||
"filename": Path(location.rel_path).name if location else item.md5,
|
"filename": Path(location.rel_path).name if location else item.md5,
|
||||||
"similarity": round(similarity * 100, 1),
|
"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)
|
matches.sort(key=lambda entry: entry["similarity"], reverse=True)
|
||||||
@@ -185,7 +187,9 @@ class TempUploadViewSet(
|
|||||||
temp.library_item = existing
|
temp.library_item = existing
|
||||||
temp.file.delete(save=False)
|
temp.file.delete(save=False)
|
||||||
else:
|
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:
|
if matches:
|
||||||
temp.visual_matches = matches
|
temp.visual_matches = matches
|
||||||
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
||||||
|
|||||||
@@ -20,6 +20,38 @@ ALLOWED_HOSTS = [
|
|||||||
if host.strip()
|
if host.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Same-origin access always works; list extra frontend origins in
|
||||||
|
# CORS_ALLOWED_ORIGINS (comma separated, e.g. https://j621.example.com).
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
origin.strip().rstrip("/")
|
||||||
|
for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
|
||||||
|
if origin.strip()
|
||||||
|
]
|
||||||
|
# Escape hatch for local experiments; never enable against a public server.
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = (
|
||||||
|
os.getenv("CORS_ALLOW_ALL_ORIGINS", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
# The SPA authenticates with an Authorization: Token header, not cookies, so
|
||||||
|
# cross-origin requests do not need credentials. Enable this only if a
|
||||||
|
# cross-origin client really relies on cookies (e.g. the Django admin).
|
||||||
|
CORS_ALLOW_CREDENTIALS = (
|
||||||
|
os.getenv("CORS_ALLOW_CREDENTIALS", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
# Same list, for session/CSRF-protected endpoints (Django admin) reached from
|
||||||
|
# another origin.
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
origin.strip().rstrip("/")
|
||||||
|
for origin in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",")
|
||||||
|
if origin.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Behind a TLS-terminating proxy the backend sees plain http; trust the
|
||||||
|
# proxy's X-Forwarded-Proto/Host so absolute media URLs keep https and the
|
||||||
|
# public hostname.
|
||||||
|
if os.getenv("TRUST_PROXY_HEADERS", "false").lower() == "true":
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
|
||||||
|
|
||||||
def _git_commit_hash():
|
def _git_commit_hash():
|
||||||
"""Short git hash of the running build, mirroring the original app."""
|
"""Short git hash of the running build, mirroring the original app."""
|
||||||
@@ -50,6 +82,7 @@ INSTALLED_APPS = [
|
|||||||
"rest_framework",
|
"rest_framework",
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"django_filters",
|
"django_filters",
|
||||||
|
"corsheaders",
|
||||||
"apps.accounts",
|
"apps.accounts",
|
||||||
"apps.library",
|
"apps.library",
|
||||||
"apps.follows",
|
"apps.follows",
|
||||||
@@ -58,6 +91,8 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
# Must sit above CommonMiddleware so preflights are answered early.
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Django>=6.0,<6.2
|
Django>=6.0,<6.2
|
||||||
djangorestframework>=3.17
|
djangorestframework>=3.17
|
||||||
django-filter>=25.1
|
django-filter>=25.1
|
||||||
|
django-cors-headers>=4.9
|
||||||
Pillow>=11.0
|
Pillow>=11.0
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
requests>=2.32
|
requests>=2.32
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, ChevronLeft, ChevronRight, Search } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Button, Spinner, inputClass } from "@/components/ui";
|
import { Button, Spinner, inputClass } from "@/components/ui";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import type { MediaItem, Paginated, User } from "@/lib/types";
|
import type { MediaItem, Paginated, User } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
@@ -112,10 +112,10 @@ function AvatarPickerModal({
|
|||||||
>
|
>
|
||||||
<span className="block aspect-square overflow-hidden">
|
<span className="block aspect-square overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={
|
src={apiUrl(
|
||||||
item.thumbnail_url ??
|
item.thumbnail_url ??
|
||||||
`/api/files/${item.j_id}/thumbnail/`
|
`/api/files/${item.j_id}/thumbnail/`,
|
||||||
}
|
)}
|
||||||
alt={item.filename}
|
alt={item.filename}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, Spinner, inputClass } from "@/components/ui";
|
import { Button, Spinner, inputClass } from "@/components/ui";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
@@ -35,8 +35,9 @@ function MemberCard({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
const preview =
|
const preview = apiUrl(
|
||||||
entry.thumbnail_url ?? `/api/files/${entry.j_id}/thumbnail/`;
|
entry.thumbnail_url ?? `/api/files/${entry.j_id}/thumbnail/`,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -250,10 +251,10 @@ export default function DuplicatesPage() {
|
|||||||
<div className="w-32 shrink-0">
|
<div className="w-32 shrink-0">
|
||||||
<div className="aspect-square overflow-hidden rounded-md bg-ctp-mantle">
|
<div className="aspect-square overflow-hidden rounded-md bg-ctp-mantle">
|
||||||
<img
|
<img
|
||||||
src={
|
src={apiUrl(
|
||||||
group.thumbnail_url ??
|
group.thumbnail_url ??
|
||||||
`/api/files/${group.j_id}/thumbnail/`
|
`/api/files/${group.j_id}/thumbnail/`,
|
||||||
}
|
)}
|
||||||
alt={group.j_id}
|
alt={group.j_id}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TagChip } from "@/components/TagChip";
|
|||||||
import { DetailShell } from "@/features/detail/DetailShell";
|
import { DetailShell } from "@/features/detail/DetailShell";
|
||||||
import { IqdbCard } from "@/features/library/IqdbCard";
|
import { IqdbCard } from "@/features/library/IqdbCard";
|
||||||
import { OptimizeModal } from "@/features/optimize/OptimizeModal";
|
import { OptimizeModal } from "@/features/optimize/OptimizeModal";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { isTypingTarget } from "@/lib/dom";
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
import { renderDText } from "@/lib/dtext";
|
import { renderDText } from "@/lib/dtext";
|
||||||
@@ -468,7 +468,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
|||||||
queryFn: () => api<MediaItem>(`/api/files/${jId}/`),
|
queryFn: () => api<MediaItem>(`/api/files/${jId}/`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawUrl = query.data?.raw_url ?? `/api/files/${jId}/raw/`;
|
const rawUrl = apiUrl(query.data?.raw_url ?? `/api/files/${jId}/raw/`);
|
||||||
const downloadUrl = rawUrl.includes("?")
|
const downloadUrl = rawUrl.includes("?")
|
||||||
? `${rawUrl}&download=1`
|
? `${rawUrl}&download=1`
|
||||||
: `${rawUrl}?download=1`;
|
: `${rawUrl}?download=1`;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
|
||||||
import { TagChip } from "@/components/TagChip";
|
import { TagChip } from "@/components/TagChip";
|
||||||
import { DetailShell } from "@/features/detail/DetailShell";
|
import { DetailShell } from "@/features/detail/DetailShell";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { renderDText } from "@/lib/dtext";
|
import { renderDText } from "@/lib/dtext";
|
||||||
import { isTypingTarget } from "@/lib/dom";
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
@@ -242,9 +242,11 @@ export function OnlineDetail({ postId }: { postId: number }) {
|
|||||||
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
|
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
|
||||||
const canUpload = Boolean(user?.can_upload);
|
const canUpload = Boolean(user?.can_upload);
|
||||||
const clientDownloadUrl = post.file.url
|
const clientDownloadUrl = post.file.url
|
||||||
? `/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
|
? apiUrl(
|
||||||
`${post.file.md5}.${post.file.ext}`,
|
`/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
|
||||||
)}`
|
`${post.file.md5}.${post.file.ext}`,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||||
import { api, authHeaders, errorMessage, uploadFile } from "@/lib/api";
|
import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import {
|
import {
|
||||||
E621Error,
|
E621Error,
|
||||||
@@ -398,10 +398,10 @@ function MetadataModal({
|
|||||||
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2"
|
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={
|
src={apiUrl(
|
||||||
match.thumbnail_url ??
|
match.thumbnail_url ??
|
||||||
`/api/files/${match.j_id}/thumbnail/`
|
`/api/files/${match.j_id}/thumbnail/`,
|
||||||
}
|
)}
|
||||||
alt={match.j_id}
|
alt={match.j_id}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-12 w-12 shrink-0 rounded object-cover"
|
className="h-12 w-12 shrink-0 rounded object-cover"
|
||||||
|
|||||||
+15
-2
@@ -2,6 +2,19 @@ import type { TempUpload } from "@/lib/types";
|
|||||||
|
|
||||||
const TOKEN_KEY = "j621.token";
|
const TOKEN_KEY = "j621.token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend origin when the SPA is served from somewhere else (build-time
|
||||||
|
* `VITE_API_BASE`, e.g. `https://j621-api.example.com`). Empty means
|
||||||
|
* same-origin, which is the default and needs no configuration.
|
||||||
|
*/
|
||||||
|
const API_BASE = String(import.meta.env.VITE_API_BASE ?? "").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
/** Absolutise a backend path; external/absolute URLs pass through. */
|
||||||
|
export function apiUrl(path: string): string {
|
||||||
|
if (!path.startsWith("/")) return path;
|
||||||
|
return `${API_BASE}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return window.localStorage.getItem(TOKEN_KEY);
|
return window.localStorage.getItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,7 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) finalHeaders.set("Authorization", `Token ${token}`);
|
if (token) finalHeaders.set("Authorization", `Token ${token}`);
|
||||||
|
|
||||||
const response = await fetch(path, { ...rest, headers: finalHeaders, body });
|
const response = await fetch(apiUrl(path), { ...rest, headers: finalHeaders, body });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let data: unknown = null;
|
let data: unknown = null;
|
||||||
try {
|
try {
|
||||||
@@ -99,7 +112,7 @@ export function uploadFile<T = TempUpload>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = new XMLHttpRequest();
|
const request = new XMLHttpRequest();
|
||||||
request.open("POST", path);
|
request.open("POST", apiUrl(path));
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
|
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
|
||||||
request.upload.addEventListener("progress", (event) => {
|
request.upload.addEventListener("progress", (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user