This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 12:21:26 +02:00
1 parent 0facfe6f5c
commit 95c55da2ee
39 files changed
+715 -151

No files matched your search

+6 -28
View File
@@ -25,7 +25,7 @@ function panelFromLocation() { if (typeof location === "undefined") return "over
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
let lastApiLatencyMs = null;
let lastApiLatencyMs = null, sessionCheckPromise = null;
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
// Language content is discovered by build.rs from langs/*.json, never compiled
// into a two-language switch here. Locale metadata also drives Intl and RTL.
@@ -388,7 +388,7 @@ async function activate(info) {
document.documentElement.classList.remove("app-startup-failed");
rustProxyRefresh.start();
const preferences = await loadAdminPreferences();
if (!authenticated || epoch !== authEpoch) return;
if (!authenticated || epoch !== authEpoch) return false;
window.rustProxyLogs?.applyPreferences(preferences);
window.rustProxyFileLogs?.applyPreferences(preferences);
window.rustProxyVhosts?.applyPreferences(preferences);
@@ -399,17 +399,18 @@ async function activate(info) {
for (let attempt = 0; attempt < 3 && authenticated && epoch === authEpoch; attempt++) {
try { await loadConfig(); break; }
catch (error) {
if (!authenticated || epoch !== authEpoch || error?.status === 401) return;
if (attempt === 2) { showStartupFailure(error); return; }
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
if (attempt === 2) { showStartupFailure(error); return false; }
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 400));
}
}
if (!authenticated || epoch !== authEpoch) return;
if (!authenticated || epoch !== authEpoch) return false;
// A single full snapshot seeds non-live diagnostics; subsequent updates
// continue over SSE rather than polling full status on a timer.
void refresh(true).catch(error => {
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
});
return true;
}
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
@@ -475,26 +476,3 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
async function revalidateSession() {
const epoch = authEpoch;
try {
const info = await api("/api/auth/session");
clearInvalidOriginRecovery();
if (epoch !== authEpoch) return;
if (!authenticated) { await activate(info); return; }
sessionInfo = info; csrf = info.csrf_token; renderSession(); window.rustProxyRefresh?.sync();
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
}
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
function showStartupFailure(error) {
// Never expose a blank page or an incomplete workspace after startup fails.
// The authenticated shell keeps its own retry link; 401 still redirects to login.
$("login").hidden = true;
$("workspace").hidden = true;
$("logout").hidden = !authenticated;
document.documentElement.classList.add("app-loading", "app-startup-failed");
$("app-startup-title").textContent = languagePacks.size ? t("startupFailed") : "Could not load RustProxy";
const detail = $("app-startup-detail");
detail.textContent = error?.message || String(error);
detail.hidden = false;
}
+49 -13
View File
@@ -21,9 +21,10 @@ const rustProxyRefresh = (() => {
const tasks = new Map();
let ready = false, options = { ...REFRESH_DEFAULTS }, panel = currentPanel;
let settingsTab = "listeners", watchingVisibility = false;
let sessionReady = true, visibleResumeHandled = false;
const visible = () => !document.hidden;
function allowed(task) {
return ready && authenticated && visible() && options.live_enabled &&
return ready && authenticated && sessionReady && visible() && options.live_enabled &&
task.interval() > 0 && (!task.active || task.active());
}
function clear(task, abort = false) {
@@ -39,7 +40,7 @@ const rustProxyRefresh = (() => {
task.timer = setTimeout(() => { task.timer = null; void execute(task, false); }, delay);
}
function execute(task, manual) {
if (!authenticated || (!manual && !allowed(task))) return Promise.resolve();
if (!authenticated || !sessionReady || (!manual && !allowed(task))) return Promise.resolve();
if (task.inFlight) {
if (manual) task.queued = true;
return task.inFlight;
@@ -92,22 +93,56 @@ const rustProxyRefresh = (() => {
}
task.seconds = seconds;
}
if (ready && authenticated && visible() && options.live_enabled) startLive();
if (ready && authenticated && sessionReady && visible() && options.live_enabled) startLive();
else stopLive();
}
function onVisibility() { sync(); if (visible() && ready && authenticated) void revalidateSession(); }
function start() {
ready = true; panel = currentPanel;
settingsTab = window.rustProxySettings?.current?.() || "listeners";
if (!watchingVisibility) { document.addEventListener("visibilitychange", onVisibility); watchingVisibility = true; }
sync();
}
function stop() {
ready = false;
function suspendForSessionValidation() {
sessionReady = false;
for (const task of tasks.values()) {
clear(task, true); task.queued = false; task.enabled = false;
}
if (watchingVisibility) { document.removeEventListener("visibilitychange", onVisibility); watchingVisibility = false; }
sync();
}
function completeSessionValidation(success) {
sessionReady = !!success;
sync();
}
function requestSessionResume() {
if (!ready || !authenticated || !visible() || visibleResumeHandled) return;
visibleResumeHandled = true;
void revalidateSession();
}
function onVisibility() {
if (visible()) requestSessionResume();
else { visibleResumeHandled = false; sync(); }
}
function onPageHide() {
visibleResumeHandled = false;
suspendForSessionValidation();
}
function onPageShow(event) { if (event.persisted) requestSessionResume(); }
function start() {
ready = true; sessionReady = true; visibleResumeHandled = false; panel = currentPanel;
settingsTab = window.rustProxySettings?.current?.() || "listeners";
if (!watchingVisibility) {
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pagehide", onPageHide);
window.addEventListener("pageshow", onPageShow);
watchingVisibility = true;
}
sync();
}
function stop() {
ready = false; sessionReady = false; visibleResumeHandled = false;
for (const task of tasks.values()) {
clear(task, true); task.queued = false; task.enabled = false;
}
if (watchingVisibility) {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("pageshow", onPageShow);
watchingVisibility = false;
}
stopLive();
}
function update(value) { options = normalizeRefreshPreferences(value); sync(); }
@@ -151,6 +186,7 @@ const rustProxyRefresh = (() => {
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
run: async context => { await loadTunnels(context); } });
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
suspendForSessionValidation, completeSessionValidation,
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
})();
window.rustProxyRefresh = rustProxyRefresh;
+1 -2
View File
@@ -21,12 +21,11 @@ async function startAdminPanel() {
// app.js belongs to the authenticated shell and must not fall back to its
// legacy embedded sign-in form when another startup step fails.
const info = await api("/api/auth/session");
clearInvalidOriginRecovery();
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
await activate(info);
if (await activate(info)) clearInvalidOriginRecovery();
} catch (e) {
if (recoverInvalidOrigin(e)) return;
if (e.status === 401) {
+74
View File
@@ -0,0 +1,74 @@
// One in-flight validation per authenticated document. Neither BFCache restore nor
// visibility resume may trigger a reload: a 403 is a recoverable, visible error.
function revalidateSession() {
if (!authenticated) return Promise.resolve(false);
if (sessionCheckPromise) return sessionCheckPromise;
const epoch = authEpoch;
window.rustProxyRefresh?.suspendForSessionValidation();
const check = (async () => {
try {
const info = await api("/api/auth/session");
if (!authenticated || epoch !== authEpoch) return false;
sessionInfo = info;
csrf = info.csrf_token;
renderSession();
hideSessionValidationFailure();
window.rustProxyRefresh?.completeSessionValidation(true);
return true;
} catch (error) {
// api() locks and navigates to the login document on a 401.
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
window.rustProxyRefresh?.completeSessionValidation(false);
showSessionValidationFailure(error);
return false;
}
})();
sessionCheckPromise = check;
void check.then(
() => { if (sessionCheckPromise === check) sessionCheckPromise = null; },
() => { if (sessionCheckPromise === check) sessionCheckPromise = null; }
);
return check;
}
function displayStartupError(error, sessionResume = false) {
$("login").hidden = true;
$("workspace").hidden = true;
$("logout").hidden = !authenticated;
document.documentElement.classList.add("app-loading", "app-startup-failed");
document.documentElement.classList.toggle("app-session-failed", sessionResume);
$("app-startup-title").textContent = sessionResume
? t("sessionValidationFailed")
: (languagePacks.size ? t("startupFailed") : "Could not load RustProxy");
const detail = $("app-startup-detail");
detail.textContent = error?.message || String(error);
detail.hidden = false;
$("app-startup-reload").hidden = sessionResume;
$("app-session-retry").hidden = !sessionResume;
}
function showSessionValidationFailure(error) {
// Keep the workspace inaccessible while its session cannot be verified.
displayStartupError(error, true);
}
function hideSessionValidationFailure() {
if (!document.documentElement.classList.contains("app-session-failed")) return;
document.documentElement.classList.remove("app-session-failed", "app-loading", "app-startup-failed");
$("app-startup-detail").hidden = true;
$("app-startup-detail").textContent = "";
$("app-startup-reload").hidden = false;
$("app-session-retry").hidden = true;
$("workspace").hidden = false;
$("logout").hidden = false;
}
$("app-session-retry").addEventListener("click", () => {
const button = $("app-session-retry");
button.disabled = true;
void revalidateSession().then(
() => { button.disabled = false; },
() => { button.disabled = false; }
);
});
function showStartupFailure(error) {
// All startup failure paths stop background tasks and show visible recovery.
window.rustProxyRefresh?.stop();
displayStartupError(error);
}