Give API tokens full tile parity (icons, menu, hide-revoked); fix Groups icon bug (v0.16.21)
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
<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="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="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.20-62E6A7">
|
<img alt="Version" src="https://img.shields.io/badge/version-0.16.21-62E6A7">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||||
|
|||||||
@@ -166,3 +166,5 @@ Roughly in priority order:
|
|||||||
`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.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.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",
|
"name": "site-gateway",
|
||||||
"version": "0.16.20",
|
"version": "0.16.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+6
-5
@@ -773,12 +773,13 @@ $("#icon-search").addEventListener("input", event => {
|
|||||||
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
|
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
|
||||||
}, 280);
|
}, 280);
|
||||||
});
|
});
|
||||||
|
async function refreshIconTargetView() { await refresh(); if (state.iconTarget?.kind === "tokens") await window.loadApiTokens?.(); }
|
||||||
async function saveIcon(slug) {
|
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 = "";
|
$("#icon-error").textContent = "";
|
||||||
try {
|
try {
|
||||||
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
|
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; }
|
} 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); });
|
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
|
||||||
@@ -786,13 +787,13 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
|
|||||||
$("#icon-upload").addEventListener("change", async event => {
|
$("#icon-upload").addEventListener("change", async event => {
|
||||||
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
|
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
|
||||||
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
|
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; }
|
catch (error) { $("#icon-error").textContent = error.message; }
|
||||||
});
|
});
|
||||||
$("#save-icon-url").addEventListener("click", async () => {
|
$("#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; }
|
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";
|
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 refresh(); toast("Icon URL saved."); }
|
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; }
|
catch (error) { $("#icon-error").textContent = error.message; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+32
-6
@@ -356,18 +356,29 @@ async function loadApiTokens() {
|
|||||||
const list = document.querySelector("#api-token-list"); if (!list) return;
|
const list = document.querySelector("#api-token-list"); if (!list) return;
|
||||||
const summary = document.querySelector("#api-token-summary");
|
const summary = document.querySelector("#api-token-summary");
|
||||||
try {
|
try {
|
||||||
const tokens = await api("/api/tokens");
|
const tokens = state.apiTokens = await api("/api/tokens");
|
||||||
if (summary) {
|
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 };
|
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; }
|
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("");
|
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);
|
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>`;
|
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>`;
|
||||||
}).join("") : '<p class="quiet-state padded">No API tokens have been issued yet.</p>';
|
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>`; }
|
} 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() {
|
function renderApiTokensPanel() {
|
||||||
if (state.user?.role !== "administrator") return;
|
if (state.user?.role !== "administrator") return;
|
||||||
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
|
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
|
||||||
@@ -412,6 +423,21 @@ async function openCreateApiTokenDialog() {
|
|||||||
showIssuedApiToken(result);
|
showIssuedApiToken(result);
|
||||||
} catch (error) { toast(error.message, "error"); }
|
} 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 => {
|
document.addEventListener("click", async event => {
|
||||||
const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return;
|
const button = event.target.closest('[data-token-action="revoke"]'); if (!button) return;
|
||||||
const row = button.closest("[data-token-id]"); if (!row) 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{min-height:0}
|
||||||
.api-token-card.revoked{opacity:.6}
|
.api-token-card.revoked{opacity:.6}
|
||||||
.api-token-card .address{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
.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}
|
.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 */
|
/* 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) => {
|
app.get("/api/tokens", (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
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); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
app.post("/api/tokens", async (req, res, next) => {
|
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) => {
|
app.put("/api/:kind/:id/icon", async (req, res, next) => {
|
||||||
try {
|
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;
|
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." });
|
if (!collection) return res.status(404).json({ error: "Entry type not found." });
|
||||||
const item = collection.find(entry => entry.id === req.params.id);
|
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) => {
|
app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => {
|
||||||
try {
|
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;
|
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." });
|
if (!collection) return res.status(404).json({ error: "Entry type not found." });
|
||||||
const item = collection.find(entry => entry.id === req.params.id);
|
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);
|
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 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();
|
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 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);
|
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
|
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
|
||||||
// every authenticated API request looks a token up by its SHA-256 hash.
|
// 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 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 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 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 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
|
// --- Backup history. Independent of what is on disk, so deleted backups and failed
|
||||||
// attempts stay visible in the timeline.
|
// 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); }
|
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; }
|
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(", ")}`); }
|
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