Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b0884dcc | |||
| aeff4d5773 | |||
| 3df8f5db7f |
@@ -12,7 +12,7 @@
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
||||
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
|
||||
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.18-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.21-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
@@ -44,9 +44,10 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
|
||||
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
||||
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
|
||||
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
||||
- **API access tokens** — issue scoped (full-access or read-only), optionally expiring bearer tokens for scripts and integrations, revocable at any time.
|
||||
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
||||
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
||||
- **Performance and logs** — request throughput, response times, and rotating access/activity logs per host.
|
||||
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page with environment/integration status, gateway sync, scheduled jobs, and storage usage.
|
||||
- **SQLite-backed persistence** — no external database container; everything lives under one `/data` volume.
|
||||
|
||||
Hosted uploads remain static-only (HTML, CSS, JS, images, fonts, downloads). Dynamic applications are connected as Proxy Hosts instead — Site Gateway does not execute uploaded PHP, Node, Python, or database code.
|
||||
|
||||
@@ -162,3 +162,9 @@ Roughly in priority order:
|
||||
`v0.16.17` fixes the Backup type helper text showing both the Complete and Configuration-only explanations stacked on top of each other on page load or refresh, instead of just the one matching the currently selected option. Root cause: the help text only ever updated on the select's `change` event -- but `renderBackups()` sets the select's value from saved settings on every render without firing a `change` event, so the static placeholder text (which briefly held both sentences as a v0.16.16 authoring mistake) never got replaced until you manually touched the dropdown. Factored the text-selection logic into its own function and call it both on `change` and every time `renderBackups()` runs, so it always matches the select's actual current value.
|
||||
|
||||
`v0.16.18` reworks the API Access tab to match the Users and Groups tabs' layout instead of the old plain data-row list: tokens are now shown as tiles in the same card grid Hosted Sites/Users/Groups use, and a stat bar above them breaks down Active/Revoked and Full access/Read-only counts at a glance. No behavior changed -- Revoke still works the same way it always has (a one-way action; there is no re-enable, since a revoked token's secret is treated as compromised). An earlier idea of adding an enable/disable toggle was dropped once it became clear that would require adding real token-reactivation support on the backend, a deliberate security-posture change rather than a layout fix.
|
||||
|
||||
`v0.16.19` finishes the API Access tab's alignment with Users and Groups: the "Create token" button now lives in the shared top-right header button used by every other create action instead of its own row inside the panel, and the panel-heading text ("Programmatic access / API access tokens / Issue bearer tokens...") has been removed the same way it was for Groups in v0.16.15, since the tab button's own label already says what the section is -- the stat bar is now the first thing in the panel. Also walked the in-app Documentation view and brought it current with everything shipped since it was last substantively updated: added a full API Access section (creating a token, scope, expiry, the one-time reveal, revoking, and automatic revocation when an issuing administrator's password changes or account is disabled), corrected the Performance section's per-route table description to drop the removed per-row error-count badge and instead document the pinned column headers and the "Not configured" chip added in v0.16.16, and added an API Access entry to the documentation sidebar's contents list.
|
||||
|
||||
`v0.16.20` audits role enforcement across the app after a run of Administration changes and fixes three places where the frontend showed a control the backend would actually reject for Standard Users and Viewers: the Dashboard's "Resync now" button (Needs Attention drift tile) and the Certificates page's "Run certificate check" button are now hidden for anyone who isn't an administrator, since both call administrator-only endpoints. The Access List editor's "Allowed groups" section -- previously always rendered with an empty `state.groups`, so a Standard User just saw a false "No groups have been created yet." -- now shows an accurate note pointing to an administrator instead, both when creating a new Access List and editing an existing one. Also corrected the in-app documentation: the Users & Groups role summary previously said Viewer "can inspect everything," which wasn't true -- Administration (System, Users, Groups, Backups, API Access, Logs & Retention, Danger Zone) is completely invisible to Viewer, the same as Standard, not merely read-only. The role summary, the Access Lists doc's Groups field, the Certificates doc's Check now section, and the Dashboard doc's Resync now section all now say plainly which actions are administrator-only.
|
||||
|
||||
`v0.16.21` gives API Access tokens full parity with every other tile type. Tokens now get a real, persistent custom icon -- a new `icon`/`icon_slug` column pair on the `api_tokens` table (added via an idempotent `ALTER TABLE`, safe on existing installs), matching storage functions, and a `tokens` branch in the shared icon-upload/search/URL routes -- plus the same "•••" card menu every other tile has, with Change icon and Revoke token moved into it. While wiring this up, found and fixed a real pre-existing bug: Groups' own "Change icon" menu item has been broken since it shipped, because the frontend code that actually saves an icon never mapped the `groups` kind to anything and silently fell through to the Hosted Sites endpoint, which always 404'd. Also finished the rest of the API Access fix list: the Full access/Read-only counts in the summary bar now only tally active tokens, so they stay consistent with the Active/Revoked split instead of quietly including tokens that can no longer authenticate; a "Hide revoked" toggle sits at the right of that same summary bar for anyone who's revoked enough tokens over time that the tile grid gets cluttered; and the documentation now explains why a revoked token can't be deleted outright -- the record stays for the same accountability reasons the Audit log is never editable.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.16.18",
|
||||
"version": "0.16.21",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
+11
-9
@@ -228,7 +228,7 @@ function renderDashboard() {
|
||||
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
|
||||
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
|
||||
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift"
|
||||
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span><button type="button" class="button secondary" data-drift-resync>Resync now</button></div>`
|
||||
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span>${canAdmin() ? '<button type="button" class="button secondary" data-drift-resync>Resync now</button>' : ""}</div>`
|
||||
: `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`
|
||||
).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
|
||||
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
||||
@@ -501,8 +501,8 @@ function render() {
|
||||
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
|
||||
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
|
||||
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups";
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups", adminApiActive = activeAdminTab === "api";
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || adminApiActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates" || !canAdmin()); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
if (overview) {
|
||||
$("#page-title").textContent = "Dashboard";
|
||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||
@@ -512,7 +512,7 @@ function render() {
|
||||
if (!management) {
|
||||
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
|
||||
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
|
||||
$("#open-create").textContent = state.view === "administration" ? (adminGroupsActive ? "+ Create group" : "+ Create user") : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
$("#open-create").textContent = state.view === "administration" ? (adminGroupsActive ? "+ Create group" : adminApiActive ? "+ Create token" : "+ Create user") : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||
if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
|
||||
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
|
||||
@@ -657,6 +657,7 @@ $("#event-category").addEventListener("change", renderLogs);
|
||||
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
|
||||
function openCreate() {
|
||||
if (state.view === "administration" && state.adminTab === "groups") { openNewGroupEditor(); return; }
|
||||
if (state.view === "administration" && state.adminTab === "api") { openCreateApiTokenDialog(); return; }
|
||||
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
|
||||
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
|
||||
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
|
||||
@@ -772,12 +773,13 @@ $("#icon-search").addEventListener("input", event => {
|
||||
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
|
||||
}, 280);
|
||||
});
|
||||
async function refreshIconTargetView() { await refresh(); if (state.iconTarget?.kind === "tokens") await window.loadApiTokens?.(); }
|
||||
async function saveIcon(slug) {
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites";
|
||||
$("#icon-error").textContent = "";
|
||||
try {
|
||||
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
|
||||
$("#icon-dialog").close(); await refresh(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
|
||||
$("#icon-dialog").close(); await refreshIconTargetView(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
|
||||
} catch (error) { $("#icon-error").textContent = error.message; }
|
||||
}
|
||||
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
|
||||
@@ -785,13 +787,13 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
|
||||
$("#icon-upload").addEventListener("change", async event => {
|
||||
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
|
||||
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
|
||||
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
|
||||
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Custom icon saved locally."); }
|
||||
catch (error) { $("#icon-error").textContent = error.message; }
|
||||
});
|
||||
$("#save-icon-url").addEventListener("click", async () => {
|
||||
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); }
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : state.iconTarget.kind === "groups" ? "groups" : state.iconTarget.kind === "tokens" ? "tokens" : "sites";
|
||||
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refreshIconTargetView(); toast("Icon URL saved."); }
|
||||
catch (error) { $("#icon-error").textContent = error.message; }
|
||||
});
|
||||
|
||||
|
||||
+40
-12
@@ -269,12 +269,12 @@ function openGroupEditor(group) { let dialog = document.querySelector("#group-di
|
||||
document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true);
|
||||
function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
|
||||
document.addEventListener("click", async event => { const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
|
||||
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
|
||||
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } if (state.user?.role !== "administrator") { field.innerHTML = '<strong>Allowed groups</strong><p class="access-group-help">Group-based access is managed by an administrator, under Administration \u2192 Groups.</p>'; return; } if (!state.groups) return; const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
|
||||
document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true);
|
||||
document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); });
|
||||
document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAccessGroupSelector(row.dataset.accessId), 10); });
|
||||
function decorateAccessGroups() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item) return; if (item.groups?.length && !card.querySelector(".access-group-preview")) { const names = item.groups.map(id => state.groups.find(group => group.id === id)?.name).filter(Boolean); if (names.length) { const preview = document.createElement("p"); preview.className = "access-group-preview"; preview.textContent = "Groups: " + names.join(" · "); card.querySelector(".card-footer")?.before(preview); } } if (!card.querySelector("[data-access-action=toggle]")) { const footer = card.querySelector(".card-footer"); const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", item.enabled !== false ? "Disable Access List" : "Enable Access List"); toggle.innerHTML = "<span></span>"; footer?.querySelector(".card-actions")?.append(toggle); } }); }
|
||||
function renderNewAccessGuidance() { const form = document.querySelector("#access-form"); if (!form || form.dataset.editing || form.querySelector(".access-create-guidance")) return; const assignmentSummary = document.querySelector("#access-assignment-summary"); if (assignmentSummary) { assignmentSummary.classList.add("hidden"); assignmentSummary.innerHTML = ""; } const guidance = document.createElement("p"); guidance.className = "access-create-guidance"; guidance.textContent = "After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later."; document.querySelector("#access-credential-editor")?.after(guidance); const groupField = document.createElement("section"); groupField.id = "access-create-groups"; groupField.className = "access-create-groups"; groupField.innerHTML = `<strong>Allowed groups <span class="optional">Optional</span></strong><p class="access-group-help">Members of enabled groups can sign in with their Site Gateway credentials.</p>${state.groups?.length ? `<div class="access-group-options">${state.groups.filter(group => group.enabled !== false).map(group => `<label class="check-control access-group-option"><input type="checkbox" data-create-group="${extendedEscape(group.id)}"><span>${extendedEscape(group.name)} <small>${group.members?.length || 0} members</small></span></label>`).join("")}</div>` : '<p class="access-group-empty">No groups have been created yet. Create one under Administration → Groups.</p>'}`; guidance.after(groupField); }
|
||||
function renderNewAccessGuidance() { const form = document.querySelector("#access-form"); if (!form || form.dataset.editing || form.querySelector(".access-create-guidance")) return; const isAdmin = state.user?.role === "administrator"; const assignmentSummary = document.querySelector("#access-assignment-summary"); if (assignmentSummary) { assignmentSummary.classList.add("hidden"); assignmentSummary.innerHTML = ""; } const guidance = document.createElement("p"); guidance.className = "access-create-guidance"; guidance.textContent = isAdmin ? "After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later." : "After saving, edit this Access List to assign protected hosts."; document.querySelector("#access-credential-editor")?.after(guidance); const groupField = document.createElement("section"); groupField.id = "access-create-groups"; groupField.className = "access-create-groups"; groupField.innerHTML = !isAdmin ? '<strong>Allowed groups</strong><p class="access-group-help">Group-based access is managed by an administrator, under Administration → Groups.</p>' : `<strong>Allowed groups <span class="optional">Optional</span></strong><p class="access-group-help">Members of enabled groups can sign in with their Site Gateway credentials.</p>${state.groups?.length ? `<div class="access-group-options">${state.groups.filter(group => group.enabled !== false).map(group => `<label class="check-control access-group-option"><input type="checkbox" data-create-group="${extendedEscape(group.id)}"><span>${extendedEscape(group.name)} <small>${group.members?.length || 0} members</small></span></label>`).join("")}</div>` : '<p class="access-group-empty">No groups have been created yet. Create one under Administration → Groups.</p>'}`; guidance.after(groupField); }
|
||||
document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(renderNewAccessGuidance, 0));
|
||||
document.addEventListener("click", event => { if (event.target.closest(".create-trigger") && state.view === "access") setTimeout(renderNewAccessGuidance, 0); });
|
||||
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = "<span></span>"; actions.append(toggle); }); }
|
||||
@@ -356,18 +356,29 @@ async function loadApiTokens() {
|
||||
const list = document.querySelector("#api-token-list"); if (!list) return;
|
||||
const summary = document.querySelector("#api-token-summary");
|
||||
try {
|
||||
const tokens = await api("/api/tokens");
|
||||
const tokens = state.apiTokens = await api("/api/tokens");
|
||||
if (summary) {
|
||||
// Full access / Read-only only count ACTIVE tokens -- a revoked token's scope no longer
|
||||
// means anything operationally, so folding it into these counts would make them disagree
|
||||
// with Active + Revoked, which already account for every token issued.
|
||||
const counts = { active: 0, revoked: 0, full: 0, readOnly: 0 };
|
||||
for (const token of tokens) { if (token.revoked) counts.revoked += 1; else counts.active += 1; if (token.scope === "read-only") counts.readOnly += 1; else counts.full += 1; }
|
||||
summary.innerHTML = [["Active", counts.active, "#62e6a7"], ["Revoked", counts.revoked, "#ff7185"], ["Full access", counts.full, "#6ea8ff"], ["Read-only", counts.readOnly, "#b58cff"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("");
|
||||
for (const token of tokens) { if (token.revoked) { counts.revoked += 1; continue; } counts.active += 1; if (token.scope === "read-only") counts.readOnly += 1; else counts.full += 1; }
|
||||
summary.innerHTML = [["Active", counts.active, "#62e6a7"], ["Revoked", counts.revoked, "#ff7185"], ["Full access", counts.full, "#6ea8ff"], ["Read-only", counts.readOnly, "#b58cff"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("") + `<label class="check-control api-token-hide-revoked"><input type="checkbox" id="api-token-hide-revoked"${state.hideRevokedTokens ? " checked" : ""}><span>Hide revoked</span></label>`;
|
||||
}
|
||||
list.innerHTML = tokens.length ? tokens.map(token => {
|
||||
const visibleTokens = state.hideRevokedTokens ? tokens.filter(token => !token.revoked) : tokens;
|
||||
list.innerHTML = visibleTokens.length ? visibleTokens.map(token => {
|
||||
const status = apiTokenStatus(token);
|
||||
return `<article class="site-card api-token-card ${token.revoked ? "revoked" : ""}" data-token-id="${extendedEscape(token.id)}"><div class="card-top"><div class="site-icon">TK</div></div><h2>${extendedEscape(token.name)}</h2><p class="address">${extendedEscape(token.prefix)}… · ${token.scope === "read-only" ? "Read-only" : "Full access"}</p><p class="gateway-address">${extendedEscape(token.ownerUsername || "unknown")} · created ${extendedEscape(formatTime(token.createdAt))}</p><p class="gateway-address">${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status.dot}"></span>${extendedEscape(status.label)}</span><div class="card-actions">${token.revoked ? "" : '<button class="button secondary danger-text" data-token-action="revoke">Revoke</button>'}</div></div></article>`;
|
||||
}).join("") : '<p class="quiet-state padded">No API tokens have been issued yet.</p>';
|
||||
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="API token options" aria-expanded="false">•••</button><div class="menu"><button data-token-action="icon">Change icon</button>${token.revoked ? "" : '<button data-token-action="revoke" class="danger-text">Revoke token</button>'}</div></div>`;
|
||||
return `<article class="site-card api-token-card ${token.revoked ? "revoked" : ""}" data-token-id="${extendedEscape(token.id)}"><div class="card-top"><div class="site-icon">${featureIcon(token, "TK")}</div>${menu}</div><h2>${extendedEscape(token.name)}</h2><p class="address">${extendedEscape(token.prefix)}… · ${token.scope === "read-only" ? "Read-only" : "Full access"}</p><p class="gateway-address">${extendedEscape(token.ownerUsername || "unknown")} · created ${extendedEscape(formatTime(token.createdAt))}</p><p class="gateway-address">${token.lastUsedAt ? `Last used ${extendedEscape(formatTime(token.lastUsedAt))}` : "Never used"}${token.expiresAt ? ` · expires ${extendedEscape(formatTime(token.expiresAt))}` : ""}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status.dot}"></span>${extendedEscape(status.label)}</span></div></article>`;
|
||||
}).join("") : `<p class="quiet-state padded">${tokens.length ? "No active tokens — uncheck \u201cHide revoked\u201d to see revoked tokens." : "No API tokens have been issued yet."}</p>`;
|
||||
} catch (error) { list.innerHTML = `<p class="quiet-state padded">${extendedEscape(error.message)}</p>`; }
|
||||
}
|
||||
document.addEventListener("change", event => {
|
||||
const checkbox = event.target.closest("#api-token-hide-revoked"); if (!checkbox) return;
|
||||
state.hideRevokedTokens = checkbox.checked;
|
||||
loadApiTokens();
|
||||
});
|
||||
window.loadApiTokens = loadApiTokens;
|
||||
function renderApiTokensPanel() {
|
||||
if (state.user?.role !== "administrator") return;
|
||||
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
|
||||
@@ -378,7 +389,7 @@ function renderApiTokensPanel() {
|
||||
if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "api"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); }
|
||||
if (panel.dataset.ready) return;
|
||||
panel.dataset.ready = "1";
|
||||
panel.innerHTML = '<div class="panel-heading"><div><p class="eyebrow">Programmatic access</p><h2>API access tokens</h2><p class="muted">Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrator’s password, or disabling their account, revokes every token they issued.</p></div><div class="row-actions"><button id="create-api-token" class="button primary" type="button">Create token</button></div></div><div id="api-token-summary" class="summary"></div><div id="api-token-list" class="user-grid"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
|
||||
panel.innerHTML = '<div id="api-token-summary" class="summary"></div><div id="api-token-list" class="user-grid"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
|
||||
tab.addEventListener("click", async () => {
|
||||
document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab));
|
||||
document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel));
|
||||
@@ -395,8 +406,10 @@ function showIssuedApiToken(result) {
|
||||
});
|
||||
dialog.showModal();
|
||||
}
|
||||
document.addEventListener("click", async event => {
|
||||
if (!event.target.closest("#create-api-token")) return;
|
||||
// Called from the shared global "+ Create" header button (see openCreate() in app.js) when the API
|
||||
// Access tab is active -- there is no dedicated "Create token" button in the panel itself anymore,
|
||||
// matching how Groups and Users route their create actions through the same shared button.
|
||||
async function openCreateApiTokenDialog() {
|
||||
const dialog = featureDialog("create-api-token-dialog");
|
||||
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">API access</p><h2>Create an API token</h2></div></div><p class="muted">Re-enter your administrator credentials to confirm. The token inherits your role.</p><label>Token name<input name="name" maxlength="60" required placeholder="Home Assistant integration"></label><label>Scope<select name="scope"><option value="full">Full access — read and change configuration</option><option value="read-only">Read-only — GET requests only</option></select></label><label>Expires after <span class="optional">Optional</span><input name="expiresInDays" type="number" min="1" max="3650" placeholder="Leave empty for no expiry"><small>Number of days. Leave empty for a token that never expires.</small></label><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><p class="error" id="api-token-error"></p><div class="dialog-actions"><button value="cancel" formnovalidate class="button secondary">Cancel</button><button value="confirm" class="button primary">Create token</button></div></form>';
|
||||
dialog.showModal();
|
||||
@@ -409,7 +422,22 @@ document.addEventListener("click", async event => {
|
||||
await loadApiTokens();
|
||||
showIssuedApiToken(result);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
});
|
||||
}
|
||||
// Menu-open/close toggle for API token cards, matching the same pattern used for Groups' menu.
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest("#api-token-list .api-token-card .menu-button"); if (!button) return;
|
||||
const card = button.closest(".api-token-card"); const opening = !card.classList.contains("menu-open");
|
||||
document.querySelectorAll("#api-token-list .api-token-card.menu-open").forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); });
|
||||
card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening));
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
}, true);
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest('#api-token-list [data-token-action="icon"]'); if (!button) return;
|
||||
const row = button.closest("[data-token-id]"); if (!row) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
row.classList.remove("menu-open");
|
||||
openIconPicker("tokens", row.dataset.tokenId);
|
||||
}, true);
|
||||
document.addEventListener("click", async event => {
|
||||
const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return;
|
||||
const row = button.closest("[data-token-id]"); if (!row) return;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -963,6 +963,7 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.api-token-card{min-height:0}
|
||||
.api-token-card.revoked{opacity:.6}
|
||||
.api-token-card .address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
.api-token-hide-revoked{margin-left:auto;padding:6px 12px;font-size:.85rem}
|
||||
.api-token-secret{display:block;margin-top:var(--space-3);padding:var(--space-3) var(--space-4);border:1px solid var(--line);border-radius:var(--radius-sm);background:rgba(var(--bg-rgb),.4);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:var(--font-size-sm);line-height:1.6;word-break:break-all}
|
||||
|
||||
/* Backup history timeline */
|
||||
|
||||
+32
-1
@@ -1654,7 +1654,7 @@ app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json
|
||||
app.get("/api/tokens", (req, res, next) => {
|
||||
try {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
|
||||
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, icon: token.icon, iconSlug: token.iconSlug, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/tokens", async (req, res, next) => {
|
||||
@@ -1879,6 +1879,24 @@ function entryLabel(item) {
|
||||
}
|
||||
app.put("/api/:kind/:id/icon", async (req, res, next) => {
|
||||
try {
|
||||
if (req.params.kind === "tokens") {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: "API token not found." });
|
||||
let updated;
|
||||
if (req.body.url !== undefined) {
|
||||
const url = String(req.body.url || "").trim();
|
||||
if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." });
|
||||
updated = storage.setApiTokenIcon(req.params.id, { icon: url, iconSlug: null });
|
||||
recordActivity(`Icon URL updated for “${entryLabel(existing)}”.`);
|
||||
} else {
|
||||
const slug = String(req.body.slug || "").trim();
|
||||
const icon = slug ? await cacheIcon(slug) : null;
|
||||
updated = storage.setApiTokenIcon(req.params.id, { icon, iconSlug: slug || null });
|
||||
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(existing)}”.`);
|
||||
}
|
||||
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
|
||||
}
|
||||
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
|
||||
if (!collection) return res.status(404).json({ error: "Entry type not found." });
|
||||
const item = collection.find(entry => entry.id === req.params.id);
|
||||
@@ -1902,6 +1920,19 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
|
||||
});
|
||||
app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => {
|
||||
try {
|
||||
if (req.params.kind === "tokens") {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: "API token not found." });
|
||||
if (!req.file) return res.status(400).json({ error: "Choose an icon image." });
|
||||
if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(req.file.mimetype)) return res.status(400).json({ error: "Use PNG, JPEG, WebP, GIF, or SVG." });
|
||||
const extension = req.file.mimetype === "image/svg+xml" ? "svg" : req.file.mimetype.split("/")[1].replace("jpeg", "jpg");
|
||||
const filename = `${req.params.kind}-${existing.id}.${extension}`;
|
||||
await fsp.rename(req.file.path, path.join(iconsDir, filename));
|
||||
const updated = storage.setApiTokenIcon(req.params.id, { icon: `/site-icons/${filename}`, iconSlug: null });
|
||||
recordActivity(`Custom icon uploaded for “${entryLabel(existing)}”.`);
|
||||
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
|
||||
}
|
||||
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
|
||||
if (!collection) return res.status(404).json({ error: "Entry type not found." });
|
||||
const item = collection.find(entry => entry.id === req.params.id);
|
||||
|
||||
+5
-2
@@ -75,6 +75,8 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC);
|
||||
`);
|
||||
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
||||
try { db.exec("ALTER TABLE api_tokens ADD COLUMN icon TEXT"); } catch { /* Column already exists. */ }
|
||||
try { db.exec("ALTER TABLE api_tokens ADD COLUMN icon_slug TEXT"); } catch { /* Column already exists. */ }
|
||||
const timestamp = now();
|
||||
db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp);
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
|
||||
@@ -178,11 +180,12 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
}
|
||||
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
|
||||
// every authenticated API request looks a token up by its SHA-256 hash.
|
||||
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
|
||||
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,icon,icon_slug AS iconSlug,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
|
||||
function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; }
|
||||
function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; }
|
||||
function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; }
|
||||
function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); }
|
||||
function setApiTokenIcon(id, { icon, iconSlug }, instanceId = LOCAL_INSTANCE_ID) { const changes = db.prepare("UPDATE api_tokens SET icon=?, icon_slug=? WHERE instance_id=? AND id=?").run(icon || null, iconSlug || null, instanceId, id).changes; return changes > 0 ? listApiTokens(instanceId).find(item => item.id === id) || null : null; }
|
||||
// --- Backup history. Independent of what is on disk, so deleted backups and failed
|
||||
// attempts stay visible in the timeline.
|
||||
function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); }
|
||||
@@ -212,5 +215,5 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
}
|
||||
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
|
||||
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
|
||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, recordBackupEvent, listBackupEvents, close: () => db.close() };
|
||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, setApiTokenIcon, recordBackupEvent, listBackupEvents, close: () => db.close() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user