Staff push notifications, and revalidate embedded assets

Notifications: staff turn them on per device from a new Notifications page,
pick each type (bans, gags, mutes and silences added or lifted, server down
and back up) and quiet hours. A watcher polls SimpleAdmin's tables, so in-game
penalties are sent too. Web Push with VAPID and RFC 8291 encryption in
internal/push, standard library only. Tables in sql/push.sql, created at
startup; the VAPID key is made on first start and kept in saw_settings.
Penalty notifications link to the public list searching for the player
(#bans?q=<steamid64>). The Android app delegates notifications (versionCode 3).

Caching: pages, scripts and styles are served with Cache-Control: no-cache
and an ETag, so a deploy shows up at once instead of after Cloudflare's
default 4-hour browser cache.
This commit is contained in:
Astra 2026-10-03 13:49:40 +01:00
parent 7900bf5b3e
commit 17232bd5a2
23 changed files with 2135 additions and 12 deletions

View file

@ -10,6 +10,7 @@ const PAGES = [
// Only when GroupVerify's tables are in the database.
{ id: "verify", label: "Verification", show: () => !!state.me?.verify },
{ id: "settings", label: "Server settings" },
{ id: "notifications", label: "Notifications" },
];
const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
@ -517,6 +518,136 @@ function overrideDialog(sid, name) {
</form>`);
}
/* ---------- Notifications ---------- */
// Push notifications to this staff member's devices (internal/web/notify.go, sw.js). Preferences
// apply to all their devices and save as soon as they change.
const b64urlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
const bytesToB64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const browserTimezone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// This browser's push subscription, or null.
async function pushSubscription() {
const reg = await navigator.serviceWorker.getRegistration();
return reg ? reg.pushManager.getSubscription() : null;
}
async function notifications() {
const v = $("#view");
let d;
try { d = await api("/api/admin/notifications"); } catch (err) { v.innerHTML = errorBox(err); return; }
let here = false;
if (d.available && pushSupported() && window.crypto?.subtle) {
try {
const sub = await pushSubscription();
here = !!sub && d.devices.includes(await sha256Hex(sub.endpoint));
} catch { /* treat as off */ }
}
if (state.page !== "notifications") return;
state.notif = d;
const p = d.prefs;
const others = d.devices.length - (here ? 1 : 0);
const row = (label, help, control) => `<div class="setting">
<div><b>${label}</b>${help ? `<p>${help}</p>` : ""}</div>
<div class="control">${control}</div>
</div>`;
let device;
if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", '<span class="tag red">Unavailable</span>');
else if (!pushSupported()) device = row("This device", "This browser can't get notifications. On an iPhone or iPad, add the site to your home screen and open it from there.", '<span class="tag red">Not supported</span>');
else if (Notification.permission === "denied") device = row("This device", "Notifications are blocked for this site. Allow them in the browser's site settings (or the app's notification settings), then come back.", '<span class="tag red">Blocked</span>');
else if (here) device = row("This device", "Notifications are on here.", '<div style="display:flex;gap:8px"><button class="btn" type="button" data-act="notif-test">Send a test</button><button class="btn" type="button" data-act="notif-off">Turn off</button></div>');
else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", '<button class="btn primary" type="button" data-act="notif-on">Turn on</button>');
const dis = d.available ? "" : "disabled";
const groups = [...new Set(d.types.map((t) => t.group))];
v.innerHTML = `
<div class="page-head">
<div><h1>Notifications</h1><p class="sub">Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.</p></div>
</div>
<div class="settings">
<section class="panel">
<div class="panel-head"><div><h2>Devices</h2></div></div>
${device}
${others > 0 ? row(`${here ? "Other devices" : "Your devices"}`, `${others} ${others === 1 ? "device has" : "devices have"} notifications on.`, '<button class="btn" type="button" data-act="notif-off-all">Turn off everywhere</button>') : ""}
</section>
<section class="panel">
<div class="panel-head"><div><h2>Quiet hours</h2><p>Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).</p></div></div>
${row("Quiet hours", "", `<button class="switch" type="button" role="switch" aria-checked="${p.quiet.enabled}" aria-label="Quiet hours" data-notif="quiet" ${dis}></button>`)}
${row("From and until", "", `<div class="quiet-times"><input class="input" type="time" id="quiet-start" value="${esc(p.quiet.start)}" aria-label="Quiet from" data-notif-time ${dis}><span class="muted">to</span><input class="input" type="time" id="quiet-end" value="${esc(p.quiet.end)}" aria-label="Quiet until" data-notif-time ${dis}></div>`)}
</section>
<section class="panel">
<div class="panel-head"><div><h2>What to send</h2><p>For all your devices.</p></div></div>
${groups.map((g) => `<div class="notif-group">${esc(g)}</div>
${d.types.filter((t) => t.group === g).map((t) => row(esc(t.label), t.help ? esc(t.help) : "",
t.ready
? `<button class="switch" type="button" role="switch" aria-checked="${p.types.includes(t.key)}" aria-label="${esc(t.label)}" data-ntype="${esc(t.key)}" ${dis}></button>`
: `<span class="tag">Coming later</span><button class="switch" type="button" role="switch" aria-checked="false" aria-label="${esc(t.label)}" disabled></button>`)).join("")}`).join("")}
</section>
</div>`;
}
let notifSave;
function saveNotifPrefs() {
const types = [...document.querySelectorAll("[data-ntype]")].filter((s) => s.getAttribute("aria-checked") === "true").map((s) => s.dataset.ntype);
const body = {
types,
quiet: { enabled: $("[data-notif=quiet]").getAttribute("aria-checked") === "true", start: $("#quiet-start").value, end: $("#quiet-end").value },
timezone: browserTimezone(),
};
// Several quick toggles make one save.
clearTimeout(notifSave);
notifSave = setTimeout(async () => {
try { await api("/api/admin/notifications/prefs", { method: "PUT", body }); toast("Saved."); } catch (err) { toast(err.message); }
}, 400);
}
async function enableNotifications() {
const d = state.notif;
try {
if (await Notification.requestPermission() !== "granted") {
toast("Notifications weren't allowed.");
notifications();
return;
}
if (!(await navigator.serviceWorker.getRegistration())) await navigator.serviceWorker.register("/sw.js");
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
// A subscription made for another panel key can't be reused.
if (sub && sub.options?.applicationServerKey && bytesToB64url(sub.options.applicationServerKey) !== d.publicKey) {
await sub.unsubscribe();
sub = null;
}
if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64urlToBytes(d.publicKey) });
await act("/api/admin/notifications/subscribe", "POST", { ...sub.toJSON(), userAgent: navigator.userAgent, timezone: browserTimezone() });
} catch (err) {
toast(`Couldn't turn notifications on: ${err.message}`);
}
}
async function disableNotifications(all) {
let endpoint = "";
try {
const sub = await pushSubscription();
if (sub) {
endpoint = sub.endpoint;
await sub.unsubscribe();
}
} catch { /* the server copy is removed below either way */ }
if (all) return act("/api/admin/notifications/unsubscribe", "POST", { all: true });
if (!endpoint) { notifications(); return; }
return act("/api/admin/notifications/unsubscribe", "POST", { endpoint });
}
/* ---------- Settings ---------- */
async function settings() {
@ -812,7 +943,7 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, verify, settings };
const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications };
function renderNav() {
const c = state.counts;
@ -849,6 +980,7 @@ document.addEventListener("input", (e) => {
document.addEventListener("change", (e) => {
if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); }
if (e.target.matches("[data-notif-time]")) saveNotifPrefs();
});
document.addEventListener("click", (e) => {
@ -888,6 +1020,11 @@ document.addEventListener("click", (e) => {
}
const sw = t.closest(".switch");
if (sw && !sw.disabled && sw.matches("[data-ntype], [data-notif]")) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
saveNotifPrefs();
return;
}
if (sw && !sw.disabled) {
sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true"));
sw.classList.add("dirty");
@ -957,6 +1094,12 @@ document.addEventListener("click", (e) => {
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
}
case "notif-on": return enableNotifications();
case "notif-off": return disableNotifications(false);
case "notif-off-all":
confirmDialog.reason = false;
return confirmDialog("Turn off everywhere", "Turn notifications off on all your devices? Turn them on again on each device you want them on.", "Turn off", () => disableNotifications(true));
case "notif-test": return act("/api/admin/notifications/test", "POST", {});
case "save-settings": return saveSettings();
case "discard": return settings();
}