Ask where the backend lives on first start (runtime setup)
Replaces the build-time VITE_API_BASE knob with a runtime setup screen so one build works same-origin and cross-origin: - frontend/src/lib/backend.ts stores the API origin in localStorage (empty = same origin). DEFAULT_BACKEND_URL is the clearly marked, easily edited prefilled default — the matrix.org equivalent; set it to your public API origin. - Production builds show /setup before anything else on first start, with a connection test against /health (or leave it blank for this server). The route stays reachable from Account -> Backend connection; switching backends clears the previous backend's token and reloads. - Input normalisation: scheme defaulted (https, http for localhost), trailing slashes trimmed; a failed cross-origin test points at CORS_ALLOWED_ORIGINS. - Dev keeps defaulting to the same-origin Vite proxy; /setup can be visited manually. Verified: normalisation cases in Node, /health returns CORS headers for an allowed origin, tsc/oxlint/build clean.
This commit is contained in:
@@ -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/.
|
||||
|
||||
@@ -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 <SetupPage />;
|
||||
}
|
||||
|
||||
if (!ageVerified) {
|
||||
return (
|
||||
<AgeGate
|
||||
@@ -145,6 +152,7 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<>
|
||||
<AvatarCard />
|
||||
<PreferencesCard />
|
||||
<BackendCard />
|
||||
</>
|
||||
) : null}
|
||||
{loading && !credentials ? (
|
||||
|
||||
@@ -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 (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Backend connection
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-ctp-overlay0">
|
||||
Where this app sends its API requests. Changing it signs you out.
|
||||
</p>
|
||||
<p className="mt-3 break-all font-mono text-xs text-ctp-subtext0">
|
||||
{backend || `${window.location.origin} (same origin)`}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link to="/setup" className={linkButtonClass}>
|
||||
Change backend
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<TestResult> {
|
||||
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<TestResult>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-ctp-crust px-4">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded bg-ctp-mauve px-2.5 py-1.5 font-mono text-sm font-bold tracking-wide text-ctp-crust">
|
||||
J621
|
||||
</span>
|
||||
<span className="font-mono text-[11px] uppercase tracking-widest text-ctp-overlay0">
|
||||
Self-hosted media archive
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-ctp-surface0 bg-ctp-base p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Cable className="mt-0.5 h-5 w-5 shrink-0 text-ctp-mauve" />
|
||||
<h1 className="text-lg font-semibold leading-snug text-ctp-text">
|
||||
Where is your backend?
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-ctp-subtext0">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<label className="mt-5 flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Backend URL
|
||||
</span>
|
||||
<input
|
||||
className={cn(inputClass, "font-mono")}
|
||||
placeholder="https://j621.example.com — blank for this server"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setResult(null);
|
||||
}}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{result ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-sm",
|
||||
result.ok
|
||||
? "border-ctp-green/30 bg-ctp-green/10 text-ctp-green"
|
||||
: "border-ctp-red/30 bg-ctp-red/10 text-ctp-red",
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{result.text}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => save(value)}>Save and continue</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={testing}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{testing ? <Spinner className="h-3.5 w-3.5" /> : null}
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
{value.trim() ? (
|
||||
<Button variant="ghost" onClick={() => save("")}>
|
||||
Use this server
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs leading-relaxed text-ctp-overlay0">
|
||||
Stored in this browser only. Changing the backend signs you out of
|
||||
the previous one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user