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:
@@ -3,7 +3,7 @@ import { Check, ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
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 type { MediaItem, Paginated, User } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
@@ -112,10 +112,10 @@ function AvatarPickerModal({
|
||||
>
|
||||
<span className="block aspect-square overflow-hidden">
|
||||
<img
|
||||
src={
|
||||
src={apiUrl(
|
||||
item.thumbnail_url ??
|
||||
`/api/files/${item.j_id}/thumbnail/`
|
||||
}
|
||||
`/api/files/${item.j_id}/thumbnail/`,
|
||||
)}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
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 { formatBytes } from "@/lib/format";
|
||||
import type {
|
||||
@@ -35,8 +35,9 @@ function MemberCard({
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const preview =
|
||||
entry.thumbnail_url ?? `/api/files/${entry.j_id}/thumbnail/`;
|
||||
const preview = apiUrl(
|
||||
entry.thumbnail_url ?? `/api/files/${entry.j_id}/thumbnail/`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -250,10 +251,10 @@ export default function DuplicatesPage() {
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="aspect-square overflow-hidden rounded-md bg-ctp-mantle">
|
||||
<img
|
||||
src={
|
||||
src={apiUrl(
|
||||
group.thumbnail_url ??
|
||||
`/api/files/${group.j_id}/thumbnail/`
|
||||
}
|
||||
`/api/files/${group.j_id}/thumbnail/`,
|
||||
)}
|
||||
alt={group.j_id}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TagChip } from "@/components/TagChip";
|
||||
import { DetailShell } from "@/features/detail/DetailShell";
|
||||
import { IqdbCard } from "@/features/library/IqdbCard";
|
||||
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 { isTypingTarget } from "@/lib/dom";
|
||||
import { renderDText } from "@/lib/dtext";
|
||||
@@ -468,7 +468,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
||||
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("?")
|
||||
? `${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 { TagChip } from "@/components/TagChip";
|
||||
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 { renderDText } from "@/lib/dtext";
|
||||
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;
|
||||
const canUpload = Boolean(user?.can_upload);
|
||||
const clientDownloadUrl = post.file.url
|
||||
? `/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
|
||||
`${post.file.md5}.${post.file.ext}`,
|
||||
)}`
|
||||
? apiUrl(
|
||||
`/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
|
||||
`${post.file.md5}.${post.file.ext}`,
|
||||
)}`,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
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 {
|
||||
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"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
src={apiUrl(
|
||||
match.thumbnail_url ??
|
||||
`/api/files/${match.j_id}/thumbnail/`
|
||||
}
|
||||
`/api/files/${match.j_id}/thumbnail/`,
|
||||
)}
|
||||
alt={match.j_id}
|
||||
loading="lazy"
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
@@ -69,7 +82,7 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
||||
const token = getToken();
|
||||
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) {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
@@ -99,7 +112,7 @@ export function uploadFile<T = TempUpload>(
|
||||
}
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", path);
|
||||
request.open("POST", apiUrl(path));
|
||||
const token = getToken();
|
||||
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user