8fa66c9911
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from document.cookie, null on desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had ever reached app_logs. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
// Desktop-only: the operator-configured backend origin (host:port of the
|
|
// Fastify server this install talks to), persisted across restarts.
|
|
//
|
|
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
|
|
// specific booth, so the backend address can't be baked in at build time
|
|
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
|
|
// same installer at a different appliance). Instead the operator enters it
|
|
// once in the ConnectScreen (shown before login whenever nothing usable is
|
|
// stored yet) and it's saved to a JSON file in the OS config dir via
|
|
// tauri-plugin-store, read back on every launch before any API call.
|
|
//
|
|
// Browser build: this module is never reached (inTauri() gates every call
|
|
// site — see origin.ts), so there is no browser equivalent or fallback here.
|
|
|
|
import type { Store } from "@tauri-apps/plugin-store";
|
|
|
|
const STORE_FILE = "backend-config.json";
|
|
const KEY = "backendUrl";
|
|
|
|
let storeHandle: Store | null = null;
|
|
async function getStore(): Promise<Store> {
|
|
if (!storeHandle) {
|
|
const { load } = await import("@tauri-apps/plugin-store");
|
|
storeHandle = await load(STORE_FILE, { autoSave: true });
|
|
}
|
|
return storeHandle;
|
|
}
|
|
|
|
/** The saved backend origin (no trailing slash), or null if never configured.
|
|
* Desktop only — throws if called from a browser build. */
|
|
export async function loadBackendUrl(): Promise<string | null> {
|
|
const store = await getStore();
|
|
const v = await store.get<string>(KEY);
|
|
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
|
|
}
|
|
|
|
/** Persist a new backend origin (validated + reachable — call testBackendUrl
|
|
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
|
|
export async function saveBackendUrl(url: string): Promise<void> {
|
|
const store = await getStore();
|
|
await store.set(KEY, url.replace(/\/$/, ""));
|
|
await store.save();
|
|
}
|
|
|
|
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
|
|
export async function clearBackendUrl(): Promise<void> {
|
|
const store = await getStore();
|
|
await store.delete(KEY);
|
|
await store.save();
|
|
}
|
|
|
|
export interface BackendCheck {
|
|
ok: boolean;
|
|
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
|
|
reason?: "unreachable" | "bad_response";
|
|
detail?: string;
|
|
}
|
|
|
|
/** Probe a candidate origin via GET /health — the server's one unauthenticated
|
|
* route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We
|
|
* require BOTH a 2xx and that `app` value: the previous probe hit an
|
|
* auth-guarded route and accepted 401/403 as "ours", which any password-
|
|
* protected service on the LAN would also have passed. Uses the same
|
|
* tauri-plugin-http path platformFetch does (raw fetch from the webview can't
|
|
* reach an arbitrary LAN host — mixed content, see origin.ts). */
|
|
export async function testBackendUrl(url: string): Promise<BackendCheck> {
|
|
const origin = url.replace(/\/$/, "");
|
|
try {
|
|
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
|
const res = await tauriFetch(`${origin}/health`, {
|
|
method: "GET",
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!res.ok) {
|
|
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
|
|
}
|
|
const body = (await res.json().catch(() => null)) as { app?: unknown } | null;
|
|
if (body?.app !== "parking-system") {
|
|
return { ok: false, reason: "bad_response", detail: "unexpected /health body" };
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
reason: "unreachable",
|
|
detail: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|