diff --git a/db.js b/db.js index db795c3..f9ecd64 100644 --- a/db.js +++ b/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, diff --git a/index.js b/index.js index 3fa42ff..cb8ceea 100644 --- a/index.js +++ b/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) { +