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.
|
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
|
- Same-origin and cross-origin frontends both work: a production build asks
|
||||||
URLs unless built with VITE_API_BASE, the backend allows extra origins
|
for the backend origin on first start (/setup, stored in localStorage;
|
||||||
via CORS_ALLOWED_ORIGINS (plus CSRF_TRUSTED_ORIGINS for the admin), and
|
DEFAULT_BACKEND_URL in frontend/src/lib/backend.ts is the prefilled
|
||||||
API media URLs are absolute (built from the request host), so signed
|
default), the backend allows extra origins via CORS_ALLOWED_ORIGINS (plus
|
||||||
files load cross-origin too. TRUST_PROXY_HEADERS=true is required behind
|
CSRF_TRUSTED_ORIGINS for the admin), and API media URLs are absolute
|
||||||
a TLS-terminating proxy.
|
(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/.
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import OnlinePage from "@/features/online/OnlinePage";
|
|||||||
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
||||||
import PoolsPage from "@/features/pools/PoolsPage";
|
import PoolsPage from "@/features/pools/PoolsPage";
|
||||||
import SimilarPage from "@/features/similar/SimilarPage";
|
import SimilarPage from "@/features/similar/SimilarPage";
|
||||||
|
import { SetupPage } from "@/features/setup/SetupPage";
|
||||||
import StatsPage from "@/features/stats/StatsPage";
|
import StatsPage from "@/features/stats/StatsPage";
|
||||||
import UploadPage from "@/features/upload/UploadPage";
|
import UploadPage from "@/features/upload/UploadPage";
|
||||||
import UsersPage from "@/features/users/UsersPage";
|
import UsersPage from "@/features/users/UsersPage";
|
||||||
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
||||||
|
import { needsBackendSetup } from "@/lib/backend";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
@@ -52,6 +54,11 @@ export default function App() {
|
|||||||
void loadUser();
|
void loadUser();
|
||||||
}, [loadUser]);
|
}, [loadUser]);
|
||||||
|
|
||||||
|
// Production builds ask where the API lives before anything else can work.
|
||||||
|
if (needsBackendSetup()) {
|
||||||
|
return <SetupPage />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ageVerified) {
|
if (!ageVerified) {
|
||||||
return (
|
return (
|
||||||
<AgeGate
|
<AgeGate
|
||||||
@@ -145,6 +152,7 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useE621 } from "@/store/e621";
|
|||||||
import { toast } from "@/store/toasts";
|
import { toast } from "@/store/toasts";
|
||||||
|
|
||||||
import { AvatarCard } from "./AvatarCard";
|
import { AvatarCard } from "./AvatarCard";
|
||||||
|
import { BackendCard } from "./BackendCard";
|
||||||
import { PreferencesCard } from "./PreferencesCard";
|
import { PreferencesCard } from "./PreferencesCard";
|
||||||
|
|
||||||
const BASE_URL_OPTIONS = [
|
const BASE_URL_OPTIONS = [
|
||||||
@@ -215,6 +216,7 @@ export default function AccountPage() {
|
|||||||
<>
|
<>
|
||||||
<AvatarCard />
|
<AvatarCard />
|
||||||
<PreferencesCard />
|
<PreferencesCard />
|
||||||
|
<BackendCard />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{loading && !credentials ? (
|
{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 type { TempUpload } from "@/lib/types";
|
||||||
|
import { getBackendUrl } from "@/lib/backend";
|
||||||
|
|
||||||
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. */
|
/** Absolutise a backend path; external/absolute URLs pass through. */
|
||||||
export function apiUrl(path: string): string {
|
export function apiUrl(path: string): string {
|
||||||
if (!path.startsWith("/")) return path;
|
if (!path.startsWith("/")) return path;
|
||||||
return `${API_BASE}${path}`;
|
return `${getBackendUrl()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
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