import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js"; const base = new URL("..", location.href); // The server writes its status path here, relative to the mount, and "none" // as the login when the browser needs none: no auth, or its mTLS certificate const meta = (name) => document.querySelector(`meta[name="logwisp-${name}"]`).content; const statusPath = meta("status"); const open = meta("login") === "none"; const MAX_LINES = 5000; const log = document.getElementById("log"); const state = document.getElementById("state"); const signOut = document.getElementById("logout"); const form = document.getElementById("login"); const progress = document.getElementById("progress"); const status = document.getElementById("status"); // A page without a login needs no WebCrypto, so plain http works too const unavailable = open ? "" : loginUnavailable(); // Without cookies the session is a token in this variable only: never stored, // so it ends with the page, or earlier with its lifetime or sign-out let tokenMode = !open && !unavailable && !cookiesUsable(); let token = null; // The login page comes back with this fragment: a 401 now means the browser // dropped the session cookie let fresh = location.hash === "#signed-in"; if (fresh) history.replaceState(null, "", location.pathname + location.search); let stopStream = null; let timer = null; function append(line) { const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4; const row = document.createElement("div"); row.textContent = line; log.append(row); while (log.childElementCount > MAX_LINES) log.firstElementChild.remove(); if (atBottom) log.scrollTop = log.scrollHeight; } // The status answer names the stream path and whether the stream has room; its // 401 is the only way to tell an ended session apart, since EventSource hides // the status of a failure. async function connect() { if (tokenMode && !token) return showLogin(""); let res; try { res = await fetch(new URL("./" + statusPath, base), { headers: token ? { Authorization: `Bearer ${token}` } : {}, credentials: "same-origin", cache: "no-store", redirect: "error", }); } catch { return retry("server unreachable"); } // An open page has no session to end: a 401 there is a proxy's, so it retries if (res.status === 401 && !open) return sessionEnded(); fresh = false; const body = res.ok ? await res.json().catch(() => ({})) : {}; const path = body?.endpoints?.stream; if (typeof path !== "string" || !path.startsWith("/")) { return retry(`status unavailable (${res.status})`); } // The stream would answer 503, which EventSource hides const { active_clients: active, max_connections: max } = body?.server ?? {}; if (max > 0 && active >= max) return retry(`server full (${active} of ${max} streams)`); // Stream paths are absolute in logwisp; the proxy may mount it under a prefix const url = new URL("./" + path.replace(/^\/+/, ""), base); if (tokenMode) streamWithToken(url); else streamWithCookie(url); } function onEvent(type, data) { if (type === "connected") state.textContent = "live"; if (type === "disconnect") { stopStream(); retry("server shut down"); } if (type === "message") data.split("\n").forEach(append); } function streamWithCookie(url) { const source = new EventSource(url); stopStream = () => source.close(); for (const type of ["connected", "disconnect", "message"]) { source.addEventListener(type, (event) => onEvent(type, event.data)); } source.onerror = () => { if (source.readyState === EventSource.CLOSED) retry("disconnected"); else state.textContent = "reconnecting…"; }; } async function streamWithToken(url) { const abort = new AbortController(); stopStream = () => abort.abort(); let refused = 0; try { await stream(url, { token, signal: abort.signal, onEvent: (event) => onEvent(event.type, event.data) }); } catch (err) { refused = err.status ?? 0; } if (abort.signal.aborted) return; if (refused === 401) sessionEnded(); else retry(refused ? `stream refused (${refused})` : "disconnected"); } function sessionEnded() { if (!tokenMode && !fresh) { location.replace(new URL("login?next=" + encodeURIComponent("view#signed-in"), location.href).href); return; } const expired = token !== null; tokenMode = true; token = null; showLogin(expired ? "The session ended; sign in again." : ""); } function showLogin(message) { stopStream?.(); clearTimeout(timer); state.textContent = "signed out"; signOut.hidden = true; status.textContent = message; form.hidden = false; form.elements.username.focus(); } function retry(reason) { state.textContent = `${reason}, retrying…`; clearTimeout(timer); timer = setTimeout(connect, 5000); } form.addEventListener("submit", async (event) => { event.preventDefault(); const fields = new FormData(form); const button = form.querySelector("button"); button.disabled = true; progress.removeAttribute("value"); progress.hidden = false; status.textContent = "Deriving the key, this takes a few seconds…"; try { ({ token } = await login(base, fields.get("username"), fields.get("password"), { session: "token", onProgress: (fraction) => { progress.value = fraction; }, })); } catch (err) { status.textContent = err.message; return; } finally { progress.hidden = true; button.disabled = false; } form.elements.password.value = ""; form.hidden = true; signOut.hidden = false; state.textContent = "connecting…"; connect(); }); signOut.addEventListener("click", async () => { stopStream?.(); clearTimeout(timer); try { await logout(base, { token }); } catch (err) { state.textContent = err.message; return; } if (!tokenMode) { location.replace(new URL("login", location.href).href); return; } token = null; showLogin("Signed out."); }); // No session of either kind can start on this page, so it offers no form if (unavailable) { state.textContent = unavailable; signOut.hidden = true; } else { signOut.hidden = open; connect(); }