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();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -259,8 +259,16 @@ async function openPlayer(sid) {
const RENDER = { server, bans, comms, staff };
function route() {
const page = location.hash.slice(1);
const [page, query] = location.hash.slice(1).split("?");
state.page = RENDER[page] ? page : "server";
// A link like #bans?q=<steamid> (from a staff notification) opens the list searching for that
// player, showing every status so lifted and expired ones are there too.
const q = new URLSearchParams(query || "").get("q");
if (q && (state.page === "bans" || state.page === "comms")) {
state.query = q.slice(0, 64);
state.num = 1;
state[state.page === "bans" ? "banFilter" : "commFilter"] = "all";
}
$("#nav-links").innerHTML = PAGES.map((p) => `<li><a href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}</a></li>`).join("");
RENDER[state.page]();
}

View file

@ -676,6 +676,18 @@ td.num, th.num { text-align: right; white-space: nowrap; }
.setting p { margin: 2px 0 0; color: var(--muted); font-size: 14px; }
.setting .control { display: flex; justify-content: flex-end; }
.setting code { font: 13px ui-monospace, "Cascadia Mono", Menlo, monospace; color: var(--faint); }
.setting .control .tag + .switch { margin-left: 10px; }
/* Notifications page. */
.notif-group {
padding: 14px 18px 2px;
color: var(--faint);
font: 600 12px var(--body);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.quiet-times { display: flex; align-items: center; gap: 8px; }
.quiet-times .input { width: 120px; }
.savebar {
position: sticky;

View file

@ -1,5 +1,6 @@
// The service worker is what lets the site be installed as an app. It caches nothing but the
// offline page: every request still goes to the network, so nobody sees stale bans or ranks.
// The service worker lets the site be installed as an app, and shows staff notifications. It caches
// nothing but the offline page: every request still goes to the network, so nobody sees stale bans
// or ranks.
const CACHE = "offline-v1";
const OFFLINE = "/static/offline.html";
@ -21,3 +22,54 @@ self.addEventListener("fetch", (e) => {
if (e.request.mode !== "navigate") return;
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)));
});
// Staff notifications (internal/web/notify.go). The panel sends title, body, icon, tag, url and
// actions; a notification with the same tag replaces the last one quietly.
self.addEventListener("push", (e) => {
let d = {};
try { d = e.data ? e.data.json() : {}; } catch { d = { body: e.data.text() }; }
e.waitUntil(self.registration.showNotification(d.title || "zio.sh", {
body: d.body || "",
icon: d.icon || "/static/icon-192.png",
badge: "/static/badge-96.png",
tag: d.tag,
timestamp: d.timestamp,
actions: d.actions || [],
data: { url: d.url || "/" },
}));
});
// Tapping the notification or its "View player" button opens its page, in an open window if
// there is one.
self.addEventListener("notificationclick", (e) => {
e.notification.close();
const url = new URL(e.notification.data?.url || "/", self.location.origin).href;
e.waitUntil((async () => {
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const win = wins.find((w) => new URL(w.url).origin === self.location.origin);
if (win) {
try {
await win.focus();
await win.navigate(url);
return;
} catch { /* not ours to navigate; open a new one */ }
}
await self.clients.openWindow(url);
})());
});
// Browsers sometimes replace a subscription. Hand the new one to the panel so notifications keep
// coming; this only works while the staff member's session cookie is still valid.
self.addEventListener("pushsubscriptionchange", (e) => {
e.waitUntil((async () => {
const opts = e.oldSubscription?.options;
const sub = e.newSubscription || (opts && await self.registration.pushManager.subscribe(opts));
if (!sub) return;
await fetch("/api/admin/notifications/subscribe", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-Requested-With": "simpleadmin-web" },
body: JSON.stringify({ ...sub.toJSON(), userAgent: navigator.userAgent }),
});
})());
});