Add structure-scoped notifications
This commit is contained in:
parent
e7064a7c6a
commit
464774ef4b
4 changed files with 460 additions and 1 deletions
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,
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue