Let the owner tell the family what is broken
AniList disabled their public API with no warning and the family's Anime tab stopped working. The app had nothing to say: the cause was outside it, no retry would have helped, and the honest message was "this is broken, it is not your television, and it is not ours to fix today". Nothing could deliver that. A `notices` collection the owner writes and every app reads, plus a composer in the dashboard's Správa view, which already holds a superuser token. Read is PUBLIC on purpose: a notice has to survive the case where signing in is itself what is broken, which is exactly when an explanation is worth most. The cost is that it is world-readable, so both the migration and the composer say in so many words that nothing sensitive goes in one. Writing stays superuser-only. `endsAt` exists because the predictable failure is not a wrong notice but a stale one: the outage ends, the row stays, and within a week everybody has learned to ignore the banner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
dd37b36f6e
commit
7f6a72dd43
2 changed files with 138 additions and 1 deletions
73
pb_migrations/1791500000_notices.js
Normal file
73
pb_migrations/1791500000_notices.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// 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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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 "<tr><td>" + esc(n.message) + "</td>" +
|
||||
"<td>" + (n.active ? '<span class="pill wn">viditelné</span>'
|
||||
: '<span class="pill ok">skryté</span>') + "</td>" +
|
||||
"<td class='mono'>" + esc(ends) + "</td>" +
|
||||
'<td><button data-notice="' + esc(n.id) + '" data-to="' +
|
||||
(n.active ? "0" : "1") + '">' +
|
||||
(n.active ? "skrýt" : "zobrazit") + "</button></td></tr>";
|
||||
}).join("");
|
||||
|
||||
return '<section class="wide"><h2>Oznámení v aplikaci</h2>' +
|
||||
'<p class="note">Uvidí ho každý v aplikaci, i nepřihlášený. ' +
|
||||
'Nepiš sem nic citlivého — jména služeb, adresy ani klíče.</p>' +
|
||||
'<div class="kv"><label>Text<input id="nMsg" maxlength="400" ' +
|
||||
'placeholder="Anime dnes nefunguje, je to výpadek u poskytovatele."></label>' +
|
||||
'<label>Skrýt po (nepovinné)<input id="nEnds" type="datetime-local"></label></div>' +
|
||||
'<button id="nSave">Zveřejnit</button> <span id="nMsgOut" class="note"></span>' +
|
||||
(rows ? '<table><tr><th>Text</th><th>Stav</th><th>Do</th><th></th></tr>' +
|
||||
rows + '</table>' : '<p class="note">zatím žádné</p>') +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
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 "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue