mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* fix(desktop): support a server hosted under a sub-path `normalize_server_url` rebuilt whatever you typed as `scheme://host[:port]` and dropped the path, so a self-hosted Sure behind a reverse proxy that mounts it under a prefix — `https://home.example.com/sure` — could never be added: the health check hit `{origin}/up`, where something else (or nothing) answers, and the app reported "Couldn't reach a Sure server at that address." The same bare origin was then used for the navigation, the SSO hand-off and the IPC capability grant, so even a passing check would have loaded the wrong site. The path was being dropped for a reason — people paste the URL they have in the clipboard, which is usually a deep link like `/sessions/new`. So rather than guess, let the server say where it is: keep the path in the canonical form, and have `check_server` probe `/up` from the address as typed back up to the origin, returning the first base that answers 200. A root-hosted server still resolves to its origin (one extra candidate, tried only after the specific one 404s), and a transport-level failure returns immediately instead of retrying a host that is not there. `check_server` now answers with that resolved base instead of a bool, and both callers save it rather than the raw input. Tests: the sub-path base, trailing slash / query / fragment trimming, the walk-up candidate list, ports preserved on every candidate, and the cap that still keeps the origin. * fix(desktop): carry the mounted base through SSO and deep links Follow-up to the sub-path support in the previous commit: saving a base with a path fixed adding and loading such a server, but left two flows resolving against the bare origin. The injected bridge only intercepted a form action shaped exactly `/auth/{provider}`, so a mounted server's `/sure/auth/{provider}` was never intercepted, and it emitted `location.origin`, which `begin_sso` would then reject as an unknown server. Match the prefix that precedes `/auth/` and emit the base it implies — empty at a domain root, so root-hosted deployments are unaffected. Deep links were gated on `target.server`, which `deep_link::parse` builds from host and port alone. Gate on the destination instead, and let `is_known_server` accept a URL that a saved base covers. `base_covers` matches only at a path boundary, so a saved `https://sure.example.com` does not cover `https://sure.example.com.evil.test`. Reported by automated review on the PR. * fix(desktop): cap candidates from the deep middle, not the shallow end The cap kept the deepest MAX_BASE_CANDIDATES - 1 candidates plus the origin, which discards the shallow ones in between — and a mount point is shallow. A server mounted at /sure, reached by a link pasted three segments into the app (`/sure/transactions/123/edit`), produced `.../edit`, `.../123`, `/sure/transactions`, origin: `/sure`, the only base that answers, was never probed, so the connection failed with "Couldn't reach a Sure server at that address" against a live server. Keep the address as typed plus the shallowest bases instead. Both readings survive the cap: the base typed exactly, and the mount a deep link sits under. Reported by @jjmata in review. * fix(desktop): bound discovery by mount depth, not candidate count Capping the candidate list drops whole mount depths silently: with a budget of four, `https://host/a/b/c/d/e` never probed `https://host/a/b/c`, so a server mounted there was undiscoverable. Bound the supported mount depth instead. The address as typed is always probed first, so a base entered exactly still works at any depth; the rest are every depth up to MAX_MOUNT_DEPTH, which keeps a pasted deep link finite (typed + 4 probes) without skipping a depth in between. Reported by coderabbitai in review.
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { S } from "./strings";
|
|
import { serverErrorMessage } from "./status";
|
|
|
|
interface ServerEntry { url: string; label: string; }
|
|
|
|
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
|
|
// Navigate to a server's login page exactly once. connect() and the
|
|
// active-server-changed listener(s) can all request navigation for the same
|
|
// server; without this guard they fire multiple concurrent GET /sessions/new
|
|
// requests, each minting a new session + CSRF token, which race and cause
|
|
// "Can't verify CSRF token authenticity" on the POST.
|
|
function goToServer(url: string) {
|
|
const w = window as unknown as { __sureNav?: string };
|
|
if (w.__sureNav) return;
|
|
w.__sureNav = url;
|
|
// Navigate to the server root: Rails serves the dashboard if the session
|
|
// cookie is still valid, or redirects to the login page if not — so a
|
|
// relaunch with a live session resumes without re-logging in.
|
|
window.location.assign(`${url}/`);
|
|
}
|
|
|
|
function fill() {
|
|
($("logo") as HTMLImageElement).src = new URL("./assets/logomark.svg", import.meta.url).href;
|
|
$("title").textContent = S.title;
|
|
$("url-label").textContent = S.serverLabel;
|
|
($("server-url") as HTMLInputElement).placeholder = S.urlPlaceholder;
|
|
$("connect").textContent = S.connect;
|
|
$("remembered-title").textContent = S.remembered;
|
|
}
|
|
|
|
function setStatus(msg: string, kind: "info" | "error" = "info") {
|
|
const el = $("status");
|
|
el.textContent = msg;
|
|
el.dataset.kind = kind;
|
|
}
|
|
|
|
async function connect(rawUrl: string) {
|
|
setStatus(S.checking, "info");
|
|
// Remember the base check_server resolved to, not the raw input.
|
|
let base: string | null;
|
|
try {
|
|
base = await invoke<string | null>("check_server", { url: rawUrl });
|
|
} catch (e) {
|
|
setStatus(serverErrorMessage(e), "error");
|
|
return;
|
|
}
|
|
if (!base) { setStatus(S.unreachable, "error"); return; }
|
|
try {
|
|
const list = await invoke<ServerEntry[]>("add_server", { url: base, label: "" });
|
|
const canonical = list.find((s) => s.url === base)?.url ?? list[0].url;
|
|
await invoke("set_active_server", { url: canonical });
|
|
goToServer(canonical);
|
|
} catch (e) {
|
|
setStatus(serverErrorMessage(e), "error");
|
|
}
|
|
}
|
|
|
|
async function renderRemembered() {
|
|
const servers = await invoke<ServerEntry[]>("list_servers");
|
|
const section = $("remembered");
|
|
const listEl = $("server-list");
|
|
listEl.innerHTML = "";
|
|
if (servers.length === 0) { section.classList.add("hidden"); return; }
|
|
section.classList.remove("hidden");
|
|
for (const s of servers) {
|
|
const li = document.createElement("li");
|
|
const open = document.createElement("button");
|
|
open.className = "server-open";
|
|
open.textContent = s.label;
|
|
open.addEventListener("click", () => connect(s.url));
|
|
const rm = document.createElement("button");
|
|
rm.className = "server-remove";
|
|
rm.textContent = S.remove;
|
|
rm.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
await invoke("remove_server", { url: s.url });
|
|
renderRemembered();
|
|
});
|
|
li.append(open, rm);
|
|
listEl.append(li);
|
|
}
|
|
}
|
|
|
|
$("server-form").addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const url = ($("server-url") as HTMLInputElement).value.trim();
|
|
if (url) connect(url);
|
|
});
|
|
|
|
// On launch, resume straight to the last server if there is one; otherwise
|
|
// show the picker.
|
|
async function boot() {
|
|
let active: string | null = null;
|
|
try {
|
|
active = await invoke<string | null>("active_server");
|
|
} catch (e) {
|
|
// Don't let a failed read abort startup and leave a blank onboarding window.
|
|
// eslint-disable-next-line no-console
|
|
console.error("[sure] failed to read active server", e);
|
|
}
|
|
if (active) {
|
|
goToServer(active);
|
|
return;
|
|
}
|
|
fill();
|
|
renderRemembered();
|
|
}
|
|
boot();
|
|
|
|
// Navigate when the active server changes (e.g. picked from Preferences).
|
|
listen<string>("active-server-changed", (e) => goToServer(e.payload));
|