// API client and shared application state. import { toast } from "./ui.js"; export const state = { me: null, // whoami response csrf: "", catalog: null, draft: null, // the open changeset edits are staged into immediate: false, // apply edits immediately instead of staging them }; export class APIError extends Error { constructor(status, body) { const e = (body && body.error) || {}; super(e.message || ("HTTP " + status)); this.status = status; this.code = e.code || ""; this.details = e; } } let onUnauthorized = () => {}; export function setUnauthorizedHandler(fn) { onUnauthorized = fn; } // onChange is told about every applied change (the probation banner), with // the answer (its confirm_token, to confirm it by itself). let onChange = () => {}; export function setChangeHandler(fn) { onChange = fn; } // api calls the REST API. opts: {body, query, stage (a changeset ID), // timeout (ms)}. export async function api(method, path, opts = {}) { const headers = { "Accept": "application/json" }; if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage); let body; if (opts.body !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(opts.body); } if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf; let url = "/api/v1" + path; if (opts.query) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v); const s = q.toString(); if (s) url += (url.includes("?") ? "&" : "?") + s; } let signal; if (opts.timeout) { const ac = new AbortController(); setTimeout(() => ac.abort(), opts.timeout); signal = ac.signal; } const resp = await fetch(url, { method, headers, body, credentials: "same-origin", signal }); if (resp.status === 204) return null; const ct = resp.headers.get("Content-Type") || ""; const data = ct.includes("application/json") ? await resp.json() : await resp.text(); if (!resp.ok) { if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized(); throw new APIError(resp.status, data); } return data; } // get reads; with pending changes, the configuration as they will make it // (objects marked "pending": new, changed, deleted). export const get = (path, query) => api("GET", path, { query: draftQuery(query) }); // draftQuery names the draft changeset in a query, unless edits are // applied immediately. export function draftQuery(query = {}) { if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id }; return query; } // ---- the draft changeset (review mode, as in Jarl) const draftKey = () => "housecarl.draft." + (state.me ? state.me.user.username : ""); const immediateKey = () => "housecarl.immediate." + (state.me ? state.me.user.username : ""); const listeners = new Set(); export function onDraftChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } function notify() { for (const fn of listeners) fn(); } function remember(key, value) { try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { /* private window */ } } function recall(key) { try { return localStorage.getItem(key); } catch { return null; } } // loadDraft restores the user's draft changeset if it is still open, or // picks up their newest open one made in the web UI. export async function loadDraft() { state.immediate = recall(immediateKey()) === "1"; state.draft = null; const id = recall(draftKey()); if (id) { try { const cs = await api("GET", "/changesets/" + id); if (cs.status === "open") state.draft = cs; } catch { /* gone */ } } if (!state.draft) { try { const list = await api("GET", "/changesets", { query: { status: "open", mine: 1, limit: 20 } }); state.draft = list.find((c) => c.via === "web") || null; } catch { /* no config access */ } } remember(draftKey(), state.draft ? String(state.draft.id) : null); notify(); } export function setImmediate(on) { state.immediate = on; remember(immediateKey(), on ? "1" : "0"); notify(); } export async function refreshDraft() { if (!state.draft) return; try { const cs = await api("GET", "/changesets/" + state.draft.id); state.draft = cs.status === "open" ? cs : null; } catch { state.draft = null; } if (!state.draft) remember(draftKey(), null); notify(); } export function useDraft(cs) { state.draft = cs; remember(draftKey(), cs ? String(cs.id) : null); notify(); } async function ensureDraft() { if (state.draft) return state.draft; const cs = await api("POST", "/changesets", { body: { description: "" } }); cs.fresh = true; useDraft(cs); return cs; } // dropFreshDraft discards a draft made for an edit that was then refused // (or only checked), so no empty changeset is left open. async function dropFreshDraft(cs) { if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return; try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ } useDraft(null); } // change sends a configuration change: staged in the draft changeset, or // applied at once in immediate mode (or with opts.immediate). It reports // it (toasts) and returns the response. export async function change(method, path, body, opts = {}) { let r; if (state.immediate || opts.immediate) { r = await api(method, path, { body, query: opts.query }); } else { const cs = await ensureDraft(); try { r = await api(method, path, { body, query: opts.query, stage: cs.id }); } catch (e) { if (e.status === 409 && /is (applied|discarded)/.test(e.message)) { useDraft(null); return change(method, path, body, opts); } await dropFreshDraft(cs); throw e; } if (r && r.staged && r.changeset) useDraft(r.changeset); else await dropFreshDraft(cs); } if (!opts.quiet) report(r); return r; } export function report(r) { if (!r) return; if (r.staged) { const n = ((r.changeset || {}).ops || []).length; toast("Staged: " + ((r.changeset.ops || [])[n - 1] || {}).summary + " — " + (n === 1 ? "1 pending change" : n + " pending changes") + " to review and apply"); } else if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed"); else if (r.applied && r.confirm_by && !r.confirm_token) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000); else if (r.applied && r.confirm_by) toast("Applied (history #" + r.history_id + ")"); else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")"); else if (!(r.changes || []).length) toast("Nothing to change"); for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000); if (r.applied) onChange(r); } // can reports whether the user may read (or write) a resource. export function can(resource, write) { const me = state.me; if (!me) return false; if (me.admin) return true; const perms = me.permissions || []; return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read")); } // catalog returns the option catalog (cached). export async function catalog() { if (!state.catalog) state.catalog = await get("/catalog"); return state.catalog; }