186 lines
14 KiB
JavaScript
186 lines
14 KiB
JavaScript
"use strict";
|
|
(() => {
|
|
const element = id => document.getElementById(id);
|
|
let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
|
|
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
|
|
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
|
|
const t = key => translations[key] ?? fallback[key] ?? key;
|
|
|
|
function docsConfirm(message) {
|
|
return new Promise(resolve => {
|
|
const dialog = document.createElement("dialog"); dialog.className = "docs-confirm";
|
|
const title = document.createElement("h2"); title.textContent = t("confirmTitle");
|
|
const body = document.createElement("p"); body.textContent = message;
|
|
const actions = document.createElement("div"); actions.className = "docs-confirm-actions";
|
|
const cancel = document.createElement("button"); cancel.type = "button"; cancel.textContent = t("cancelAction");
|
|
const accept = document.createElement("button"); accept.type = "button"; accept.className = "docs-confirm-primary"; accept.textContent = t("continueAction");
|
|
actions.append(cancel, accept); dialog.append(title, body, actions); document.body.append(dialog);
|
|
let settled = false; const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); };
|
|
cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true)); dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); }); dialog.addEventListener("close", () => finish(false));
|
|
dialog.showModal(); cancel.focus();
|
|
});
|
|
}
|
|
|
|
function clear() {
|
|
epoch++; session = null; spec = null; ui = null; documents = []; docsByPath = new Map(); currentDocument = null;
|
|
element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-reader-list").replaceChildren(); element("docs-reader-content").replaceChildren(); element("docs-session").textContent = "";
|
|
}
|
|
function expired() { clear(); element("docs-error").replaceChildren(); const link = document.createElement("a"); link.href = "/?next=docs"; link.textContent = t("docsExpired"); element("docs-error").append(link); }
|
|
channel?.addEventListener("message", event => { if (event.data === "locked") expired(); });
|
|
async function get(path) { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", redirect: "error" }); if (!response.ok) { if (response.status === 401) expired(); throw new Error(t("docsError")); } return response.json(); }
|
|
async function checkSession() { const mine = epoch; const info = await get("/api/auth/session"); if (mine !== epoch || !session || info.csrf_token !== session.csrf_token || info.username !== session.username) { expired(); throw new Error(t("docsExpired")); } return info; }
|
|
|
|
async function requestInterceptor(request) {
|
|
if (!session || !spec) throw new Error(t("docsExpired"));
|
|
const url = new URL(request.url, location.origin), method = (request.method || "GET").toLowerCase();
|
|
if (url.origin !== location.origin || url.username || url.password || !Object.hasOwn(spec.paths, url.pathname) || !Object.hasOwn(spec.paths[url.pathname], method)) throw new Error("Only documented same-origin operations are allowed.");
|
|
if (["/api/auth/login", "/api/auth/setup", "/api/events"].includes(url.pathname)) throw new Error(t("docsReadOnly"));
|
|
await checkSession();
|
|
request.credentials = "same-origin"; request.headers = { ...request.headers, "X-RustProxy-Request": "1" }; request.cache = "no-store"; request.redirect = "error";
|
|
if (!["get", "head"].includes(method)) {
|
|
if (!element("docs-writes").checked || !(await docsConfirm(t("docsWriteConfirm")))) throw new Error(t("docsReadOnly"));
|
|
request.headers["X-CSRF-Token"] = session.csrf_token;
|
|
}
|
|
return request;
|
|
}
|
|
|
|
function renderApi() {
|
|
if (!session || !spec || typeof SwaggerUIBundle !== "function") return;
|
|
const displaySpec = structuredClone(spec);
|
|
for (const path of Object.values(displaySpec.paths)) for (const [method, operation] of Object.entries(path)) {
|
|
if (["get", "post", "put", "delete", "patch", "head", "options"].includes(method) && operation.parameters)
|
|
operation.parameters = operation.parameters.filter(p => !(p.in === "header" && ["origin", "x-rustproxy-request"].includes(p.name.toLowerCase())));
|
|
}
|
|
ui = SwaggerUIBundle({
|
|
spec: displaySpec, dom_id: "#swagger-ui", layout: "BaseLayout", presets: [SwaggerUIBundle.presets.apis],
|
|
validatorUrl: null, queryConfigEnabled: false, persistAuthorization: false, withCredentials: true,
|
|
supportedSubmitMethods: element("docs-writes").checked ? ["get", "post", "put"] : ["get"],
|
|
docExpansion: "none", filter: true, displayRequestDuration: true, tryItOutEnabled: false, showMutatedRequest: false,
|
|
requestInterceptor, responseInterceptor: response => { if (response.status === 401) expired(); return response; }
|
|
});
|
|
}
|
|
|
|
function normalizeDocPath(base, target) {
|
|
const clean = target.split("#", 1)[0].split("?", 1)[0];
|
|
if (!clean) return base;
|
|
const stack = clean.startsWith("/") ? [] : base.split("/").slice(0, -1);
|
|
for (const part of clean.replace(/^\//, "").split("/")) {
|
|
if (!part || part === ".") continue;
|
|
if (part === "..") { if (stack.length) stack.pop(); continue; }
|
|
stack.push(part);
|
|
}
|
|
return stack.join("/");
|
|
}
|
|
|
|
function appendInline(parent, text, basePath) {
|
|
const pattern = /(`[^`]+`|\[[^\]]+\]\([^)]+\))/g; let offset = 0;
|
|
for (const match of text.matchAll(pattern)) {
|
|
if (match.index > offset) parent.append(document.createTextNode(text.slice(offset, match.index)));
|
|
const token = match[0];
|
|
if (token.startsWith("`")) {
|
|
const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code);
|
|
} else {
|
|
const parsed = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); const anchor = document.createElement("a");
|
|
anchor.textContent = parsed[1]; const target = parsed[2].trim();
|
|
if (/^https?:\/\//i.test(target)) { anchor.href = target; anchor.target = "_blank"; anchor.rel = "noreferrer"; }
|
|
else if (target.startsWith("#")) { anchor.href = target; }
|
|
else {
|
|
const resolved = normalizeDocPath(basePath, target);
|
|
if (docsByPath.has(resolved)) {
|
|
anchor.href = `#doc=${encodeURIComponent(resolved)}`;
|
|
anchor.addEventListener("click", event => { event.preventDefault(); selectDocument(resolved, true); });
|
|
} else { anchor.href = target; }
|
|
}
|
|
parent.append(anchor);
|
|
}
|
|
offset = match.index + token.length;
|
|
}
|
|
if (offset < text.length) parent.append(document.createTextNode(text.slice(offset)));
|
|
}
|
|
|
|
function renderMarkdown(documentInfo) {
|
|
const target = element("docs-reader-content"); target.replaceChildren();
|
|
const lines = documentInfo.content.replaceAll("\r\n", "\n").split("\n");
|
|
let code = null, list = null, paragraph = [];
|
|
const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInline(p, paragraph.join(" ").trim(), documentInfo.path); target.append(p); paragraph = []; };
|
|
const closeList = () => { list = null; };
|
|
for (const line of lines) {
|
|
if (line.startsWith("```")) {
|
|
flushParagraph(); closeList();
|
|
if (code) { target.append(code.pre); code = null; }
|
|
else { const pre = document.createElement("pre"), node = document.createElement("code"); pre.append(node); code = { pre, node, lines: [] }; }
|
|
continue;
|
|
}
|
|
if (code) { code.lines.push(line); code.node.textContent = code.lines.join("\n"); continue; }
|
|
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
|
|
if (heading) { flushParagraph(); closeList(); const h = document.createElement(`h${heading[1].length}`); appendInline(h, heading[2], documentInfo.path); target.append(h); continue; }
|
|
const bullet = /^\s*[-*]\s+(.+)$/.exec(line), ordered = /^\s*\d+\.\s+(.+)$/.exec(line);
|
|
if (bullet || ordered) {
|
|
flushParagraph(); const tag = ordered ? "ol" : "ul";
|
|
if (!list || list.tagName.toLowerCase() !== tag) { list = document.createElement(tag); target.append(list); }
|
|
const item = document.createElement("li"); appendInline(item, (bullet || ordered)[1], documentInfo.path); list.append(item); continue;
|
|
}
|
|
if (/^\s*>\s?/.test(line)) { flushParagraph(); closeList(); const q = document.createElement("blockquote"); appendInline(q, line.replace(/^\s*>\s?/, ""), documentInfo.path); target.append(q); continue; }
|
|
if (/^\s*---+\s*$/.test(line)) { flushParagraph(); closeList(); target.append(document.createElement("hr")); continue; }
|
|
if (!line.trim()) { flushParagraph(); closeList(); continue; }
|
|
closeList(); paragraph.push(line.trim());
|
|
}
|
|
flushParagraph(); if (code) target.append(code.pre);
|
|
}
|
|
|
|
const categoryLabel = category => t({ project: "docsCategoryProject", guides: "docsCategoryGuides", technical: "docsCategoryTechnical", audits: "docsCategoryAudits", historical: "docsCategoryHistorical" }[category] || category);
|
|
function renderReaderList() {
|
|
const query = (element("docs-reader-search").value || "").trim().toLowerCase();
|
|
const root = element("docs-reader-list"); root.replaceChildren();
|
|
const matches = documents.filter(item => !query || `${item.title} ${item.path}`.toLowerCase().includes(query));
|
|
for (const category of ["project", "guides", "technical", "audits", "historical"]) {
|
|
const group = matches.filter(item => item.category === category); if (!group.length) continue;
|
|
const heading = document.createElement("div"); heading.className = "reader-group"; heading.textContent = categoryLabel(category); root.append(heading);
|
|
for (const item of group) {
|
|
const button = document.createElement("button"); button.type = "button"; button.classList.toggle("active", item.path === currentDocument?.path);
|
|
const title = document.createElement("span"); title.textContent = item.title; const path = document.createElement("small"); path.textContent = item.path;
|
|
button.append(title, path); button.addEventListener("click", () => selectDocument(item.path, true)); root.append(button);
|
|
}
|
|
}
|
|
if (!root.children.length) { const empty = document.createElement("div"); empty.className = "reader-empty"; empty.textContent = t("docsReaderEmpty"); root.append(empty); }
|
|
}
|
|
function selectDocument(path, updateHash = false) {
|
|
const item = docsByPath.get(path) || docsByPath.get("docs/README.md") || documents[0]; if (!item) return;
|
|
currentDocument = item; renderReaderList(); renderMarkdown(item);
|
|
if (updateHash) history.replaceState(null, "", `#doc=${encodeURIComponent(item.path)}`);
|
|
}
|
|
|
|
function selectTab(tab) {
|
|
activeTab = tab === "api" ? "api" : "reader";
|
|
element("docs-reader-pane").hidden = activeTab !== "reader"; element("docs-api-pane").hidden = activeTab !== "api";
|
|
element("docs-tab-reader").classList.toggle("active", activeTab === "reader"); element("docs-tab-api").classList.toggle("active", activeTab === "api");
|
|
if (activeTab === "api") renderApi();
|
|
}
|
|
|
|
element("docs-writes").addEventListener("change", renderApi);
|
|
element("docs-reader-search").addEventListener("input", renderReaderList);
|
|
element("docs-tab-reader").addEventListener("click", () => selectTab("reader"));
|
|
element("docs-tab-api").addEventListener("click", () => selectTab("api"));
|
|
element("docs-logout").addEventListener("click", async () => { if (!session) return; try { await checkSession(); const response = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "X-RustProxy-Request": "1", "X-CSRF-Token": session.csrf_token } }); if (!response.ok && response.status !== 401) throw new Error(t("docsError")); channel?.postMessage("locked"); expired(); } catch (error) { element("docs-error").textContent = error.message; } });
|
|
|
|
async function boot() {
|
|
const mine = ++epoch; try {
|
|
const info = await get("/api/auth/session"); if (mine !== epoch) return;
|
|
const [documentSpec, bundle, docBundle, preferences] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json"), get("/docs/content.json"), get("/api/preferences")]); if (mine !== epoch) return;
|
|
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {};
|
|
const selected = preferences?.language || "auto", candidates = selected === "auto" ? (navigator.languages || [navigator.language]) : [selected];
|
|
const normalized = candidates.filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
|
|
const pack = normalized.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
|
|
window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr";
|
|
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); window.document.querySelectorAll("[data-i18n-placeholder]").forEach(n => n.placeholder = t(n.dataset.i18nPlaceholder));
|
|
session = info; spec = documentSpec; documents = Array.isArray(docBundle.documents) ? docBundle.documents : []; docsByPath = new Map(documents.map(item => [item.path, item]));
|
|
element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = "";
|
|
const hash = new URLSearchParams(location.hash.replace(/^#/, "")).get("doc"); selectDocument(hash && docsByPath.has(hash) ? hash : "docs/README.md"); selectTab("reader");
|
|
} catch (error) { if (mine === epoch) { clear(); element("docs-error").textContent = error.message; } }
|
|
}
|
|
document.addEventListener("visibilitychange", () => { if (!document.hidden && session) void checkSession().catch(() => expired()); });
|
|
window.addEventListener("pageshow", event => { if (event.persisted) { clear(); void boot(); } });
|
|
setInterval(() => { if (!document.hidden && session) void checkSession().catch(() => expired()); }, 30000);
|
|
void boot();
|
|
})();
|