diff --git a/AGENTS.md b/AGENTS.md index c8b31cd..083247a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,12 +31,13 @@ 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. +- Same-origin and cross-origin frontends both work: a production build asks + for the backend origin on first start (/setup, stored in localStorage; + DEFAULT_BACKEND_URL in frontend/src/lib/backend.ts is the prefilled + default), 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/frontend/src/App.tsx b/frontend/src/App.tsx index af201da..d14869e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,10 +23,12 @@ import OnlinePage from "@/features/online/OnlinePage"; import PoolDetailPage from "@/features/pools/PoolDetailPage"; import PoolsPage from "@/features/pools/PoolsPage"; import SimilarPage from "@/features/similar/SimilarPage"; +import { SetupPage } from "@/features/setup/SetupPage"; import StatsPage from "@/features/stats/StatsPage"; import UploadPage from "@/features/upload/UploadPage"; import UsersPage from "@/features/users/UsersPage"; import { isAgeVerified, markAgeVerified } from "@/lib/age"; +import { needsBackendSetup } from "@/lib/backend"; import { useAuth } from "@/store/auth"; function RequireAuth({ children }: { children: ReactNode }) { @@ -52,6 +54,11 @@ export default function App() { void loadUser(); }, [loadUser]); + // Production builds ask where the API lives before anything else can work. + if (needsBackendSetup()) { + return ; + } + if (!ageVerified) { return ( } /> } /> + } /> ); } diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx index 134327b..9ec6fc5 100644 --- a/frontend/src/features/account/AccountPage.tsx +++ b/frontend/src/features/account/AccountPage.tsx @@ -14,6 +14,7 @@ import { useE621 } from "@/store/e621"; import { toast } from "@/store/toasts"; import { AvatarCard } from "./AvatarCard"; +import { BackendCard } from "./BackendCard"; import { PreferencesCard } from "./PreferencesCard"; const BASE_URL_OPTIONS = [ @@ -215,6 +216,7 @@ export default function AccountPage() { <> + ) : null} {loading && !credentials ? ( diff --git a/frontend/src/features/account/BackendCard.tsx b/frontend/src/features/account/BackendCard.tsx new file mode 100644 index 0000000..f1836a0 --- /dev/null +++ b/frontend/src/features/account/BackendCard.tsx @@ -0,0 +1,27 @@ +import { Link } from "react-router-dom"; + +import { linkButtonClass } from "@/components/ui"; +import { getBackendUrl } from "@/lib/backend"; + +export function BackendCard() { + const backend = getBackendUrl(); + + return ( +
+

+ Backend connection +

+

+ Where this app sends its API requests. Changing it signs you out. +

+

+ {backend || `${window.location.origin} (same origin)`} +

+
+ + Change backend + +
+
+ ); +} diff --git a/frontend/src/features/setup/SetupPage.tsx b/frontend/src/features/setup/SetupPage.tsx new file mode 100644 index 0000000..cd8c2d0 --- /dev/null +++ b/frontend/src/features/setup/SetupPage.tsx @@ -0,0 +1,167 @@ +import { Cable, CheckCircle2, XCircle } from "lucide-react"; +import { useState } from "react"; + +import { Button, Spinner, inputClass } from "@/components/ui"; +import { cn } from "@/lib/cn"; +import { + DEFAULT_BACKEND_URL, + getBackendUrl, + getStoredBackendUrl, + normalizeBackendUrl, + setBackendUrl, +} from "@/lib/backend"; +import { queryClient } from "@/lib/queryClient"; +import { setToken } from "@/lib/api"; + +type TestResult = { ok: boolean; text: string } | null; + +async function testBackend(base: string): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 5_000); + try { + const response = await fetch(`${base}/health`, { + signal: controller.signal, + }); + const data = (await response.json().catch(() => null)) as { + status?: string; + database?: boolean; + } | null; + if (response.ok) { + const database = + data?.database === true + ? "database ok" + : data?.database === false + ? "database unreachable" + : "database unknown"; + return { ok: true, text: `Connected — ${data?.status ?? "ok"}, ${database}.` }; + } + return { + ok: false, + text: `The backend answered HTTP ${response.status}.`, + }; + } catch { + return { + ok: false, + text: + "Could not reach the backend. Check the URL, and if it lives on " + + "another domain add this site to CORS_ALLOWED_ORIGINS there.", + }; + } finally { + window.clearTimeout(timeout); + } +} + +/** + * First-start (production) and on-demand screen that stores where the API + * lives. Saving reloads the app so every request uses the new origin. + */ +export function SetupPage() { + const stored = getStoredBackendUrl(); + const [value, setValue] = useState(stored ?? DEFAULT_BACKEND_URL); + const [testing, setTesting] = useState(false); + const [result, setResult] = useState(null); + + async function runTest() { + const base = normalizeBackendUrl(value); + setTesting(true); + setResult(await testBackend(base)); + setTesting(false); + } + + function save(next: string) { + const normalized = normalizeBackendUrl(next); + if (normalized !== getBackendUrl()) { + // The token belongs to the previous backend. + setToken(null); + queryClient.clear(); + } + setBackendUrl(normalized); + window.location.replace("/"); + } + + return ( +
+
+
+ + J621 + + + Self-hosted media archive + +
+ +
+
+ +

+ Where is your backend? +

+
+ +

+ Enter the origin this app should call for its API. Leave it blank + when the app and the API are served from the same domain. +

+ + + + {result ? ( +

+ {result.ok ? ( + + ) : ( + + )} + {result.text} +

+ ) : null} + +
+ + + {value.trim() ? ( + + ) : null} +
+ +

+ Stored in this browser only. Changing the backend signs you out of + the previous one. +

+
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e551eea..fe90d3d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,18 +1,12 @@ import type { TempUpload } from "@/lib/types"; +import { getBackendUrl } from "@/lib/backend"; 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}`; + return `${getBackendUrl()}${path}`; } export function getToken(): string | null { diff --git a/frontend/src/lib/backend.ts b/frontend/src/lib/backend.ts new file mode 100644 index 0000000..13491ec --- /dev/null +++ b/frontend/src/lib/backend.ts @@ -0,0 +1,48 @@ +/** + * Where the J621 API lives. + * + * A production build asks for this on first start (`/setup`) and remembers the + * answer per browser, so one build works same-origin AND against a backend on + * another domain. Same-origin is the empty string. + * + * Self-hosters: set DEFAULT_BACKEND_URL to the origin where your API is + * reachable to prefill the setup screen — the equivalent of the matrix.org + * default in Matrix clients. Leave it empty to default to "this server". + */ +export const DEFAULT_BACKEND_URL = ""; + +const STORAGE_KEY = "j621.backend"; + +/** Trim slashes, add a scheme when missing (http for localhost, else https). */ +export function normalizeBackendUrl(value: string): string { + const trimmed = value.trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(trimmed); + return `${local ? "http" : "https"}://${trimmed}`; +} + +/** null = the user never chose; "" = same-origin. */ +export function getStoredBackendUrl(): string | null { + const raw = window.localStorage.getItem(STORAGE_KEY); + return raw === null ? null : normalizeBackendUrl(raw); +} + +/** Current API origin; empty string means same-origin. */ +export function getBackendUrl(): string { + return getStoredBackendUrl() ?? DEFAULT_BACKEND_URL; +} + +export function setBackendUrl(value: string): void { + window.localStorage.setItem(STORAGE_KEY, normalizeBackendUrl(value)); +} + +/** + * Production builds must be told where the API is on first start; the dev + * server keeps defaulting to the same-origin Vite proxy (visit /setup to + * change it manually). + */ +export function needsBackendSetup(): boolean { + if (getStoredBackendUrl() !== null) return false; + return import.meta.env.PROD; +}