Add structure-scoped notifications
This commit is contained in:
parent
e7064a7c6a
commit
464774ef4b
4 changed files with 460 additions and 1 deletions
99
db.js
99
db.js
|
|
@ -296,6 +296,100 @@ function setStructurePublic(structureId, isPublic) {
|
|||
);
|
||||
}
|
||||
|
||||
// ---- kernel notifications -------------------------------------------------
|
||||
//
|
||||
// Subscriptions are owned by the platform, not by structure databases. Each row
|
||||
// is tied to exactly one structure and one logged-in Bliss user; send-time code
|
||||
// re-checks canAccessStructure before handing anything to a push service.
|
||||
|
||||
function upsertNotificationSubscription({
|
||||
structureId,
|
||||
userId,
|
||||
endpoint,
|
||||
clientId,
|
||||
subscription,
|
||||
contentEncoding,
|
||||
}) {
|
||||
return db
|
||||
.prepare(
|
||||
`INSERT INTO notification_subscriptions
|
||||
(structure_id, user_id, endpoint, client_id, subscription, content_encoding, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(structure_id, endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
client_id = excluded.client_id,
|
||||
subscription = excluded.subscription,
|
||||
content_encoding = excluded.content_encoding,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.run(
|
||||
structureId,
|
||||
userId,
|
||||
endpoint,
|
||||
clientId == null ? null : String(clientId),
|
||||
JSON.stringify(subscription),
|
||||
contentEncoding === "aesgcm" ? "aesgcm" : "aes128gcm",
|
||||
);
|
||||
}
|
||||
|
||||
function deleteNotificationSubscription(structureId, endpoint) {
|
||||
return db
|
||||
.prepare(
|
||||
`DELETE FROM notification_subscriptions
|
||||
WHERE structure_id = ? AND endpoint = ?`,
|
||||
)
|
||||
.run(structureId, endpoint);
|
||||
}
|
||||
|
||||
function getNotificationSubscriptions(structureId, userIds = null) {
|
||||
if (userIds && userIds.length === 0) return [];
|
||||
const rows = userIds
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM notification_subscriptions
|
||||
WHERE structure_id = ?
|
||||
AND user_id IN (${userIds.map(() => "?").join(",")})`,
|
||||
)
|
||||
.all(structureId, ...userIds)
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM notification_subscriptions
|
||||
WHERE structure_id = ?`,
|
||||
)
|
||||
.all(structureId);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
subscription: JSON.parse(row.subscription),
|
||||
}));
|
||||
}
|
||||
|
||||
function getSubscribedNotificationUserIds(structureId) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT DISTINCT user_id
|
||||
FROM notification_subscriptions
|
||||
WHERE structure_id = ?
|
||||
ORDER BY user_id`,
|
||||
)
|
||||
.all(structureId)
|
||||
.map((row) => row.user_id);
|
||||
}
|
||||
|
||||
function getStructureMemberUserIds(structureId) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT user_id
|
||||
FROM structure_members
|
||||
WHERE structure_id = ?
|
||||
ORDER BY user_id`,
|
||||
)
|
||||
.all(structureId)
|
||||
.map((row) => row.user_id);
|
||||
}
|
||||
|
||||
function createRoute(verb, path, structureId, handler) {
|
||||
path = encodeURI(path);
|
||||
|
||||
|
|
@ -890,6 +984,11 @@ module.exports = {
|
|||
removeMember,
|
||||
setStructurePrivacy,
|
||||
setStructurePublic,
|
||||
upsertNotificationSubscription,
|
||||
deleteNotificationSubscription,
|
||||
getNotificationSubscriptions,
|
||||
getSubscribedNotificationUserIds,
|
||||
getStructureMemberUserIds,
|
||||
createRoute,
|
||||
getRoutes,
|
||||
getRoute,
|
||||
|
|
|
|||
212
index.js
212
index.js
|
|
@ -210,6 +210,7 @@ function makeLibs(structureId, console, memberUserId) {
|
|||
return dbs[alias];
|
||||
},
|
||||
push: push,
|
||||
notifications: makeNotifications(structureId, memberUserId),
|
||||
files: { saveFile: (...args) => saveFile(structureId, ...args) },
|
||||
};
|
||||
}
|
||||
|
|
@ -228,6 +229,126 @@ function currentUserFor(req) {
|
|||
}
|
||||
}
|
||||
|
||||
function userIdFrom(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "object" && value.id != null) return Number(value.id);
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) ? n : null;
|
||||
}
|
||||
|
||||
function normalizeNotificationUrl(structure, rawUrl) {
|
||||
const route = makeRoute(structure);
|
||||
const url = rawUrl == null || rawUrl === "" ? "/" : String(rawUrl);
|
||||
if (!url.startsWith("/") || url.startsWith("//")) {
|
||||
throw new Error("notification url must be a same-site path");
|
||||
}
|
||||
const prefix = structure.route_prefix || "";
|
||||
const scoped = prefix && !url.startsWith(prefix) ? route(url) : url;
|
||||
if (prefix && scoped !== prefix && !scoped.startsWith(prefix + "/")) {
|
||||
throw new Error("notification url must stay inside this structure");
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
function normalizeNotificationRecipients(structureId, target) {
|
||||
const explicit = target !== "viewers" && target !== "subscribers";
|
||||
let ids;
|
||||
|
||||
if (target == null || target === "viewers" || target === "subscribers") {
|
||||
ids = model.getSubscribedNotificationUserIds(structureId);
|
||||
} else if (target === "members") {
|
||||
ids = model.getStructureMemberUserIds(structureId);
|
||||
} else if (Array.isArray(target)) {
|
||||
ids = target.map(userIdFrom);
|
||||
} else {
|
||||
ids = [userIdFrom(target)];
|
||||
}
|
||||
|
||||
if (ids.some((id) => !Number.isInteger(id))) {
|
||||
throw new Error("notification recipient must be a Bliss user");
|
||||
}
|
||||
|
||||
const unique = [...new Set(ids)];
|
||||
const forbidden = unique.filter(
|
||||
(id) => !model.canAccessStructure(id, structureId),
|
||||
);
|
||||
if (forbidden.length && explicit) {
|
||||
throw new Error("notification recipient cannot access this structure");
|
||||
}
|
||||
return unique.filter((id) => !forbidden.includes(id));
|
||||
}
|
||||
|
||||
function normalizeNotificationExclusions(options = {}) {
|
||||
const ids = [];
|
||||
const collect = (value) => {
|
||||
if (Array.isArray(value)) return value.forEach(collect);
|
||||
const id = userIdFrom(value);
|
||||
if (id != null) ids.push(id);
|
||||
};
|
||||
collect(options.except);
|
||||
collect(options.exceptUser);
|
||||
collect(options.exceptUserId);
|
||||
return {
|
||||
userIds: new Set(ids),
|
||||
clientId:
|
||||
options.exceptClientId == null ? null : String(options.exceptClientId),
|
||||
};
|
||||
}
|
||||
|
||||
function makeNotifications(structureId, actorUserId) {
|
||||
const structure = model.getStructure(structureId);
|
||||
return {
|
||||
async send(options = {}) {
|
||||
const target = options.to ?? options.users ?? options.user ?? "viewers";
|
||||
const recipientIds = normalizeNotificationRecipients(structureId, target);
|
||||
const except = normalizeNotificationExclusions(options);
|
||||
const eligibleIds = recipientIds.filter((id) => !except.userIds.has(id));
|
||||
const url = normalizeNotificationUrl(structure, options.url || "/");
|
||||
const rows = model.getNotificationSubscriptions(structureId, eligibleIds);
|
||||
const payload = JSON.stringify({
|
||||
structureId: String(structureId),
|
||||
title: String(options.title || structure.name || "Bliss"),
|
||||
body: String(options.body || ""),
|
||||
url,
|
||||
});
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
|
||||
await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
if (except.clientId && row.client_id === except.clientId) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
if (!model.canAccessStructure(row.user_id, structureId)) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await push.sendNotification(row.subscription, payload, {
|
||||
contentEncoding: row.content_encoding || undefined,
|
||||
});
|
||||
sent += 1;
|
||||
} catch (err) {
|
||||
if (err && (err.statusCode === 404 || err.statusCode === 410)) {
|
||||
model.deleteNotificationSubscription(structureId, row.endpoint);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sent,
|
||||
skipped,
|
||||
structureId,
|
||||
actorUserId: actorUserId ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapContext(structureId, routeId, initContext, memberUserId) {
|
||||
const structure = model.getStructure(structureId);
|
||||
const console = makeConsole(structureId, routeId);
|
||||
|
|
@ -363,6 +484,10 @@ wsRouter.ws("*", (ws, req) => {
|
|||
}),
|
||||
);
|
||||
};
|
||||
req.user = req.currentUser;
|
||||
req.notifications = makeNotifications(route.structure_id, req.session.userId);
|
||||
ws.user = req.currentUser;
|
||||
ws.notifications = req.notifications;
|
||||
|
||||
try {
|
||||
return found.handler(ws, req);
|
||||
|
|
@ -450,6 +575,85 @@ app.all("/logout", async (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.get("/_bliss/notifications/key", (req, res) => {
|
||||
if (!vapidPublicKey) return res.status(503).json({ error: "not configured" });
|
||||
res.json({ publicKey: vapidPublicKey });
|
||||
});
|
||||
|
||||
app.post("/_bliss/notifications/subscribe", (req, res) => {
|
||||
const structureId = Number(req.body?.structure_id);
|
||||
const userId = req.session && req.session.userId;
|
||||
const subscription = req.body?.subscription;
|
||||
if (!userId)
|
||||
return res.status(401).send("log in before enabling notifications");
|
||||
if (!Number.isInteger(structureId) || !model.getStructure(structureId)) {
|
||||
return res.status(400).send("unknown structure");
|
||||
}
|
||||
if (!model.canAccessStructure(userId, structureId)) {
|
||||
return res.status(403).send("you cannot access this structure");
|
||||
}
|
||||
if (!subscription || !subscription.endpoint) {
|
||||
return res.status(400).send("missing push subscription");
|
||||
}
|
||||
|
||||
model.upsertNotificationSubscription({
|
||||
structureId,
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
clientId: req.body?.client_id,
|
||||
subscription,
|
||||
contentEncoding: req.body?.content_encoding,
|
||||
});
|
||||
res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/_bliss/notification-sw.js", (req, res) => {
|
||||
res.type("application/javascript").set("Cache-Control", "no-cache").send(`
|
||||
const openStructures = new Set();
|
||||
|
||||
self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
|
||||
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type !== "bliss:visibility" || !data.structureId) return;
|
||||
const id = String(data.structureId);
|
||||
if (data.state === "open") {
|
||||
openStructures.add(id);
|
||||
self.registration.getNotifications({ tag: "bliss:" + id }).then((items) => items.forEach((n) => n.close()));
|
||||
} else {
|
||||
openStructures.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = {};
|
||||
try { data = event.data ? event.data.json() : {}; } catch (_) {}
|
||||
const structureId = String(data.structureId || "");
|
||||
if (structureId && openStructures.has(structureId)) return;
|
||||
event.waitUntil(self.registration.showNotification(data.title || "Bliss", {
|
||||
body: data.body || "",
|
||||
tag: structureId ? "bliss:" + structureId : "bliss",
|
||||
data: { url: data.url || "/" },
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const target = event.notification.data && event.notification.data.url || "/";
|
||||
event.waitUntil(clients.matchAll({ type: "window", includeUncontrolled: true }).then((items) => {
|
||||
for (const client of items) {
|
||||
try {
|
||||
const url = new URL(client.url);
|
||||
if (url.pathname === target && "focus" in client) return client.focus();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(target);
|
||||
}));
|
||||
});
|
||||
`);
|
||||
});
|
||||
|
||||
// _ _ _______ ______ ___ _ _______ __ __ _______ _______
|
||||
// | | _ | || || _ | | | | || || | | || || |
|
||||
// | || || || _ || | || | |_| || _____|| |_| || _ || _ |
|
||||
|
|
@ -467,6 +671,7 @@ function headChrome(headInjection) {
|
|||
<script src="/js/htmx.js"></script>
|
||||
<script src="/js/ace/ace.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
|
||||
<script src="/js/bliss.js"></script>
|
||||
<script src="/js/bliss_inspector.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
|
|
@ -1119,6 +1324,11 @@ app.all("*", async (req, res) => {
|
|||
// Deliberately NOT the raw session/user object — just {id, username} —
|
||||
// so structures can greet whoever is logged in without exposing internals.
|
||||
req.currentUser = currentUserFor(req);
|
||||
req.user = req.currentUser;
|
||||
req.notifications = makeNotifications(
|
||||
route.structure_id,
|
||||
req.session.userId,
|
||||
);
|
||||
|
||||
const structure = model.getStructure(route.structure_id);
|
||||
|
||||
|
|
@ -1128,7 +1338,7 @@ app.all("*", async (req, res) => {
|
|||
let context = bootstrapContext(
|
||||
route.structure_id,
|
||||
route.id,
|
||||
{ req, res, eta },
|
||||
{ req, res, eta, notifications: req.notifications },
|
||||
req.session.userId,
|
||||
);
|
||||
|
||||
|
|
|
|||
20
migrations/007_add_notifications.sql
Normal file
20
migrations/007_add_notifications.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Kernel-owned web-push subscriptions. A subscription is always scoped to the
|
||||
-- structure that registered it, and send-time fanout re-checks structure
|
||||
-- visibility before delivery.
|
||||
CREATE TABLE notification_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
structure_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
client_id TEXT,
|
||||
subscription TEXT NOT NULL,
|
||||
content_encoding TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(structure_id) REFERENCES structures(id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
UNIQUE(structure_id, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_subscriptions_structure
|
||||
ON notification_subscriptions (structure_id, user_id);
|
||||
130
public/js/bliss.js
Normal file
130
public/js/bliss.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
(function () {
|
||||
if (window.Bliss) return;
|
||||
|
||||
function pageStructureId() {
|
||||
return (
|
||||
document.body?.dataset?.blissStructureId ||
|
||||
document.querySelector("[data-bliss-structure-id]")?.dataset
|
||||
?.blissStructureId ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function clientId() {
|
||||
const key = "bliss:client_id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id =
|
||||
crypto.randomUUID?.() ||
|
||||
String(Date.now()) + "-" + Math.random().toString(36).slice(2);
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function b64ToU8(s) {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4);
|
||||
const raw = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/"));
|
||||
const a = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) a[i] = raw.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
|
||||
async function registration() {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
throw new Error("Service workers are not supported here.");
|
||||
}
|
||||
return navigator.serviceWorker.register("/_bliss/notification-sw.js");
|
||||
}
|
||||
|
||||
async function postVisibility(state) {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const reg = await registration().catch(() => null);
|
||||
reg?.active?.postMessage({
|
||||
type: "bliss:visibility",
|
||||
state,
|
||||
structureId: pageStructureId(),
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
postVisibility(document.visibilityState === "visible" ? "open" : "closed");
|
||||
});
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => postVisibility("open"),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
postVisibility("open");
|
||||
}
|
||||
|
||||
window.Bliss = {
|
||||
structureId: pageStructureId,
|
||||
clientId,
|
||||
notifications: {
|
||||
supported() {
|
||||
return (
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
},
|
||||
permission() {
|
||||
return "Notification" in window ? Notification.permission : "denied";
|
||||
},
|
||||
async register(options = {}) {
|
||||
const structureId = options.structureId || pageStructureId();
|
||||
if (!structureId) throw new Error("No Bliss structure is active.");
|
||||
if (!this.supported()) {
|
||||
throw new Error("Push notifications are not supported here.");
|
||||
}
|
||||
|
||||
const reg = await registration();
|
||||
if (Notification.permission !== "granted") {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
throw new Error("Notification permission was not granted.");
|
||||
}
|
||||
}
|
||||
|
||||
const vapidResponse = await fetch("/_bliss/notifications/key");
|
||||
if (!vapidResponse.ok) {
|
||||
throw new Error("Could not load notification key.");
|
||||
}
|
||||
const { publicKey } = await vapidResponse.json();
|
||||
const sub =
|
||||
(await reg.pushManager.getSubscription()) ||
|
||||
(await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: b64ToU8(publicKey),
|
||||
}));
|
||||
|
||||
const ua =
|
||||
String(navigator.userAgent || "") +
|
||||
" " +
|
||||
String(navigator.platform || "");
|
||||
const isKaiOS = /kaios/i.test(ua) || !!navigator.b2g;
|
||||
const response = await fetch("/_bliss/notifications/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
structure_id: structureId,
|
||||
client_id: clientId(),
|
||||
subscription: sub,
|
||||
content_encoding: isKaiOS ? "aesgcm" : "aes128gcm",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(detail || "Could not register notifications.");
|
||||
}
|
||||
await postVisibility("open");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue