diff --git a/bliss-cli/bliss b/bliss-cli/bliss index 17c6b18..2fe9bc5 100755 --- a/bliss-cli/bliss +++ b/bliss-cli/bliss @@ -68,14 +68,6 @@ const commands = { out(c.BASE); } }, - // Force a fresh login with BLISS_USER / BLISS_PASS and report the result. - // Unlike normal commands (which reuse a cached cookie and only re-auth on a - // 401 the server never sends), this always hits /login, so it's the way to - // (re)authenticate after changing creds or clearing a stale anonymous cookie. - async login() { - await c.login(); // throws with a clear message if the server rejects creds - out("logged in as '" + (process.env.BLISS_USER || "?") + "' -> " + c.BASE); - }, // ---- reads (JSON via /plumbing) ---- async structures() { out(await c.getJSON("/plumbing/structures")); diff --git a/bliss-cli/client.js b/bliss-cli/client.js index c99b47a..a82c14f 100644 --- a/bliss-cli/client.js +++ b/bliss-cli/client.js @@ -79,21 +79,10 @@ async function login() { headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ username: USER, password: PASS }).toString(), }); - // The server issues an (anonymous) connect.sid on EVERY response — including a - // failed login, which re-renders the form as a 200. So the presence of a - // cookie says nothing about success. The real signal is the redirect: a good - // login 302s to `next`/"/", a rejected one stays 2xx. Trust the status, not - // the cookie, or we'd cache an anonymous session and silently stay logged out. const cookie = extractCookie(res); - const redirected = res.status >= 300 && res.status < 400; - if (!redirected || !cookie) { - throw new Error( - "login failed for user '" + - USER + - "' — server rejected the credentials (HTTP " + - res.status + - "). Check BLISS_USER / BLISS_PASS.", - ); + if (!cookie) { + // A 200 back from /login means the login form re-rendered => bad creds. + throw new Error("login failed for user '" + USER + "' (check BLISS_PASS)"); } saveCookie(cookie); return cookie; @@ -179,7 +168,6 @@ async function getText(urlPath) { module.exports = { BASE, setTarget, - login, request, getJSON, getText, diff --git a/db.js b/db.js index f9ecd64..db795c3 100644 --- a/db.js +++ b/db.js @@ -296,100 +296,6 @@ 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); @@ -984,11 +890,6 @@ module.exports = { removeMember, setStructurePrivacy, setStructurePublic, - upsertNotificationSubscription, - deleteNotificationSubscription, - getNotificationSubscriptions, - getSubscribedNotificationUserIds, - getStructureMemberUserIds, createRoute, getRoutes, getRoute, diff --git a/index.js b/index.js index cb8ceea..3fa42ff 100644 --- a/index.js +++ b/index.js @@ -210,7 +210,6 @@ function makeLibs(structureId, console, memberUserId) { return dbs[alias]; }, push: push, - notifications: makeNotifications(structureId, memberUserId), files: { saveFile: (...args) => saveFile(structureId, ...args) }, }; } @@ -229,126 +228,6 @@ 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); @@ -484,10 +363,6 @@ 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); @@ -575,85 +450,6 @@ 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); - })); -}); -`); -}); - // _ _ _______ ______ ___ _ _______ __ __ _______ _______ // | | _ | || || _ | | | | || || | | || || | // | || || || _ || | || | |_| || _____|| |_| || _ || _ | @@ -671,7 +467,6 @@ function headChrome(headInjection) { -