Compare commits
No commits in common. "464774ef4b3c3ac90e64597abd1b241f2c9e9321" and "02cdca748d592e6cf7b3c76b56c350b40c4b8983" have entirely different histories.
464774ef4b
...
02cdca748d
6 changed files with 4 additions and 483 deletions
|
|
@ -68,14 +68,6 @@ const commands = {
|
||||||
out(c.BASE);
|
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) ----
|
// ---- reads (JSON via /plumbing) ----
|
||||||
async structures() {
|
async structures() {
|
||||||
out(await c.getJSON("/plumbing/structures"));
|
out(await c.getJSON("/plumbing/structures"));
|
||||||
|
|
|
||||||
|
|
@ -79,21 +79,10 @@ async function login() {
|
||||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
body: new URLSearchParams({ username: USER, password: PASS }).toString(),
|
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 cookie = extractCookie(res);
|
||||||
const redirected = res.status >= 300 && res.status < 400;
|
if (!cookie) {
|
||||||
if (!redirected || !cookie) {
|
// A 200 back from /login means the login form re-rendered => bad creds.
|
||||||
throw new Error(
|
throw new Error("login failed for user '" + USER + "' (check BLISS_PASS)");
|
||||||
"login failed for user '" +
|
|
||||||
USER +
|
|
||||||
"' — server rejected the credentials (HTTP " +
|
|
||||||
res.status +
|
|
||||||
"). Check BLISS_USER / BLISS_PASS.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
saveCookie(cookie);
|
saveCookie(cookie);
|
||||||
return cookie;
|
return cookie;
|
||||||
|
|
@ -179,7 +168,6 @@ async function getText(urlPath) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BASE,
|
BASE,
|
||||||
setTarget,
|
setTarget,
|
||||||
login,
|
|
||||||
request,
|
request,
|
||||||
getJSON,
|
getJSON,
|
||||||
getText,
|
getText,
|
||||||
|
|
|
||||||
99
db.js
99
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) {
|
function createRoute(verb, path, structureId, handler) {
|
||||||
path = encodeURI(path);
|
path = encodeURI(path);
|
||||||
|
|
||||||
|
|
@ -984,11 +890,6 @@ module.exports = {
|
||||||
removeMember,
|
removeMember,
|
||||||
setStructurePrivacy,
|
setStructurePrivacy,
|
||||||
setStructurePublic,
|
setStructurePublic,
|
||||||
upsertNotificationSubscription,
|
|
||||||
deleteNotificationSubscription,
|
|
||||||
getNotificationSubscriptions,
|
|
||||||
getSubscribedNotificationUserIds,
|
|
||||||
getStructureMemberUserIds,
|
|
||||||
createRoute,
|
createRoute,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
getRoute,
|
getRoute,
|
||||||
|
|
|
||||||
212
index.js
212
index.js
|
|
@ -210,7 +210,6 @@ function makeLibs(structureId, console, memberUserId) {
|
||||||
return dbs[alias];
|
return dbs[alias];
|
||||||
},
|
},
|
||||||
push: push,
|
push: push,
|
||||||
notifications: makeNotifications(structureId, memberUserId),
|
|
||||||
files: { saveFile: (...args) => saveFile(structureId, ...args) },
|
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) {
|
function bootstrapContext(structureId, routeId, initContext, memberUserId) {
|
||||||
const structure = model.getStructure(structureId);
|
const structure = model.getStructure(structureId);
|
||||||
const console = makeConsole(structureId, routeId);
|
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 {
|
try {
|
||||||
return found.handler(ws, req);
|
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) {
|
||||||
<script src="/js/htmx.js"></script>
|
<script src="/js/htmx.js"></script>
|
||||||
<script src="/js/ace/ace.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="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 src="/js/bliss_inspector.js"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
|
|
@ -1324,11 +1119,6 @@ app.all("*", async (req, res) => {
|
||||||
// Deliberately NOT the raw session/user object — just {id, username} —
|
// Deliberately NOT the raw session/user object — just {id, username} —
|
||||||
// so structures can greet whoever is logged in without exposing internals.
|
// so structures can greet whoever is logged in without exposing internals.
|
||||||
req.currentUser = currentUserFor(req);
|
req.currentUser = currentUserFor(req);
|
||||||
req.user = req.currentUser;
|
|
||||||
req.notifications = makeNotifications(
|
|
||||||
route.structure_id,
|
|
||||||
req.session.userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const structure = model.getStructure(route.structure_id);
|
const structure = model.getStructure(route.structure_id);
|
||||||
|
|
||||||
|
|
@ -1338,7 +1128,7 @@ app.all("*", async (req, res) => {
|
||||||
let context = bootstrapContext(
|
let context = bootstrapContext(
|
||||||
route.structure_id,
|
route.structure_id,
|
||||||
route.id,
|
route.id,
|
||||||
{ req, res, eta, notifications: req.notifications },
|
{ req, res, eta },
|
||||||
req.session.userId,
|
req.session.userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
(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