184 lines
6.0 KiB
JavaScript
184 lines
6.0 KiB
JavaScript
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();
|
|
}
|