diff --git a/pb_migrations/1791500000_notices.js b/pb_migrations/1791500000_notices.js new file mode 100644 index 0000000..4c7cf5b --- /dev/null +++ b/pb_migrations/1791500000_notices.js @@ -0,0 +1,73 @@ +/// + +// A short message the owner broadcasts into the app, for breakage he cannot fix. +// +// **Why this exists.** AniList disabled their public GraphQL API with no notice +// and the family's Anime tab stopped working. The app had nothing to say: the +// cause was outside it, no retry would help, and the only honest message was +// "this is broken, it is not your television, and it is not ours to fix today". +// The same shape covers every outage we do not control — prehraj.to down, a +// TorBox key expired, the backend being upgraded this evening — and most of +// those the app cannot detect at all. The owner can. This is how he says so. +// +// **Why read is PUBLIC.** A notice has to survive the case where signing in is +// itself what is broken, which is exactly when an explanation is worth most. A +// rule of `@request.auth.id != ''` would hide the message from the only people +// who need it. The cost is that anyone who finds this backend can read it, so: +// +// NOTHING SENSITIVE GOES IN A NOTICE. No hostnames, no account names, no +// "the TorBox key expired" — that last one names a service the household pays +// for and tells a reader what to go looking for. Write what a viewer needs +// ("anime is not working today, it is an outage at the provider"), not what an +// operator would want. +// +// Writing stays superuser-only. This is the owner's voice, and a compromised +// user account must not be able to put words in front of the family. +// +// **Why `endsAt` as well as `active`.** The predictable failure is not a wrong +// notice, it is a stale one: the outage ends, the message stays, and within a +// week everybody has learned to ignore the banner. `active` is the switch the +// owner flips; `endsAt` is the one he does not have to remember. The client +// enforces both, so a forgotten row expires even if nobody touches it again. +// +// There is deliberately no severity, no title, no dismissal state and no +// targeting. Every one of those is a field to get wrong in exchange for +// something a single sentence already does, and dismissal is per-run in the +// client precisely so it needs no storage here. + +migrate( + (app) => { + const c = new Collection({ + type: "base", + name: "notices", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: "message", + type: "text", + required: true, + max: 400, + }, + { + name: "active", + type: "bool", + required: false, + }, + { + name: "endsAt", + type: "date", + required: false, + }, + ], + }); + app.save(c); + }, + (app) => { + const c = app.findCollectionByNameOrId("notices"); + app.delete(c); + }, +); diff --git a/pb_public/status.html b/pb_public/status.html index d3204a3..160a036 100644 --- a/pb_public/status.html +++ b/pb_public/status.html @@ -598,6 +598,63 @@ function viewDevices(rows){ signup and no invite code behind this; `users.createRule` is null, so only a superuser can do it, which is exactly who is looking at this screen. */ + +/* A message the family sees in the app, for breakage we cannot fix. + * + * Lives here rather than only in the PocketBase admin UI because the moment it + * is needed is the moment something is already going wrong, and a form that + * takes one sentence and a button beats remembering a collection name and a + * rule syntax under pressure. + * + * The warning is not decoration. The collection is PUBLICLY readable so a notice + * still appears when signing in is itself what is broken, which means anything + * typed here is world-readable to whoever finds the backend. */ +function viewNotices(ns){ + var rows = (ns || []).map(function(n){ + var ends = n.endsAt ? String(n.endsAt).substring(0, 16) : "—"; + return "" + esc(n.message) + "" + + "" + (n.active ? 'viditelné' + : 'skryté') + "" + + "" + esc(ends) + "" + + '"; + }).join(""); + + return '

Oznámení v aplikaci

' + + '

Uvidí ho každý v aplikaci, i nepřihlášený. ' + + 'Nepiš sem nic citlivého — jména služeb, adresy ani klíče.

' + + '
' + + '
' + + ' ' + + (rows ? '' + + rows + '
TextStavDo
' : '

zatím žádné

') + + '
'; +} + +async function saveNotice(){ + var msg = (el("nMsg").value || "").trim(); + var out = el("nMsgOut"); + if (!msg) { out.textContent = "napiš text"; return; } + var body = { message: msg, active: true }; + var ends = el("nEnds").value; + if (ends) body.endsAt = new Date(ends).toISOString().replace("T", " ").substring(0, 19) + "Z"; + try { + await api("/api/collections/notices/records", { method: "POST", body: body }); + out.textContent = "zveřejněno"; + await load(); + } catch (e) { out.textContent = "nepovedlo se: " + e.message; } +} + +async function setNoticeActive(id, to){ + try { + await api("/api/collections/notices/records/" + id, + { method: "PATCH", body: { active: to === "1" } }); + await load(); + } catch (e) { alert("nepovedlo se: " + e.message); } +} + function viewAdmin(users, tpls){ var rows = (users || []).map(function(u){ return "" + esc(u.email || u.username || u.id) + "" + @@ -969,6 +1026,10 @@ function wire(){ b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); }; }); if (el("createBtn")) el("createBtn").onclick = createAccount; + if (el("nSave")) el("nSave").onclick = saveNotice; + Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){ + b.onclick = function(){ setNoticeActive(b.getAttribute("data-notice"), b.getAttribute("data-to")); }; + }); if (el("tSave")) el("tSave").onclick = saveTemplate; if (el("tGroup")) el("tGroup").onchange = function(){ // Re-render from the selection. Unsaved edits in the fields are dropped, which @@ -1005,7 +1066,10 @@ async function load(){ if (prefs.view === "admin") { var us = await records("users", { sort: "-created", perPage: 200 }); var tps = await allTemplates(); - el("main").innerHTML = viewAdmin(us.items || [], tps) + viewTemplate(tps); + var ns = await records("notices", { sort: "-created", perPage: 20 }) + .catch(function(){ return { items: [] }; }); + el("main").innerHTML = viewNotices(ns.items || []) + + viewAdmin(us.items || [], tps) + viewTemplate(tps); wire(); return; }