diff --git a/bun.lock b/bun.lock index 70f99a8..3c56205 100644 --- a/bun.lock +++ b/bun.lock @@ -40,12 +40,14 @@ "perfect-freehand": "^1.2.2", "postgres": "^3.4.5", "sharp": "^0.33.0", + "web-push": "^3.6.7", "yaml": "^2.8.2", }, "devDependencies": { "@types/mailparser": "^3.4.0", "@types/node": "^22.10.1", "@types/nodemailer": "^6.4.0", + "@types/web-push": "^3.6.4", "@x402/fetch": "^2.5.0", "bun-types": "^1.1.38", "typescript": "^5.7.2", @@ -561,6 +563,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@x402/core": ["@x402/core@2.5.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-nUr8HW8WhkU1DvrpUfsRvALy5NF8UWKoFezZOtX61mohxp2lWZpJ2GnvscxDM8nmBAbtIollmksd5z5pj8InXw=="], "@x402/evm": ["@x402/evm@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "@x402/extensions": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-MBSTQZwLobMVcmYO7itOMJRkxfHstsDyr7F94o9Rk/Oinz0kjvCe4DFgZmFXyz3nQUgQFmDVgTK5KIzfYR5uIA=="], @@ -587,6 +591,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], @@ -603,6 +609,8 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -741,6 +749,8 @@ "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -815,8 +825,12 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1013,6 +1027,8 @@ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/package.json b/package.json index 12e144c..f81d6a8 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "@google/generative-ai": "^0.24.1", "@lit/reactive-element": "^2.0.4", "@noble/curves": "^1.8.0", - "@openfort/openfort-node": "^0.7.0", "@noble/hashes": "^1.7.0", + "@openfort/openfort-node": "^0.7.0", "@tiptap/core": "^3.20.0", "@tiptap/extension-code-block-lowlight": "^3.20.0", "@tiptap/extension-image": "^3.20.0", @@ -48,12 +48,14 @@ "perfect-freehand": "^1.2.2", "postgres": "^3.4.5", "sharp": "^0.33.0", + "web-push": "^3.6.7", "yaml": "^2.8.2" }, "devDependencies": { "@types/mailparser": "^3.4.0", "@types/node": "^22.10.1", "@types/nodemailer": "^6.4.0", + "@types/web-push": "^3.6.4", "@x402/fetch": "^2.5.0", "bun-types": "^1.1.38", "typescript": "^5.7.2", diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts new file mode 100644 index 0000000..c0da0c0 --- /dev/null +++ b/scripts/generate-icons.ts @@ -0,0 +1,38 @@ +/** + * One-off script to generate PWA icons from logo.png. + * Run: bun run scripts/generate-icons.ts + */ +import sharp from "sharp"; +import { resolve } from "node:path"; + +const src = resolve(import.meta.dir, "../website/public/logo.png"); +const outDir = resolve(import.meta.dir, "../website/public/icons"); + +// 192x192 +await sharp(src).resize(192, 192).png().toFile(resolve(outDir, "icon-192.png")); +console.log("✓ icon-192.png"); + +// 512x512 +await sharp(src).resize(512, 512).png().toFile(resolve(outDir, "icon-512.png")); +console.log("✓ icon-512.png"); + +// 180x180 apple-touch-icon +await sharp(src).resize(180, 180).png().toFile(resolve(outDir, "apple-touch-icon.png")); +console.log("✓ apple-touch-icon.png"); + +// Maskable 512x512: logo centered in inner 80% (410px) on #0f172a background +const logoForMask = await sharp(src).resize(410, 410).png().toBuffer(); +await sharp({ + create: { + width: 512, + height: 512, + channels: 4, + background: { r: 15, g: 23, b: 42, alpha: 1 }, // #0f172a + }, +}) + .composite([{ input: logoForMask, gravity: "centre" }]) + .png() + .toFile(resolve(outDir, "icon-maskable-512.png")); +console.log("✓ icon-maskable-512.png"); + +console.log("Done! Icons written to website/public/icons/"); diff --git a/server/notification-routes.ts b/server/notification-routes.ts index 5664767..d73dd07 100644 --- a/server/notification-routes.ts +++ b/server/notification-routes.ts @@ -17,6 +17,8 @@ import { dismissNotification, getNotificationPreferences, upsertNotificationPreferences, + savePushSubscription, + deletePushSubscriptionByEndpoint, } from "../src/encryptid/db"; export const notificationRouter = new Hono(); @@ -139,3 +141,49 @@ notificationRouter.patch("/preferences", async (c) => { const prefs = await upsertNotificationPreferences(claims.sub, body); return c.json({ preferences: prefs }); }); + +// ============================================================================ +// WEB PUSH ENDPOINTS +// ============================================================================ + +// ── GET /push/vapid-public-key — Return public VAPID key (no auth) ── +notificationRouter.get("/push/vapid-public-key", (c) => { + const key = process.env.VAPID_PUBLIC_KEY; + if (!key) return c.json({ error: "Push not configured" }, 503); + return c.json({ publicKey: key }); +}); + +// ── POST /push/subscribe — Save push subscription (auth required) ── +notificationRouter.post("/push/subscribe", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const body = await c.req.json(); + const { endpoint, keys } = body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return c.json({ error: "Invalid subscription object" }, 400); + } + + await savePushSubscription({ + id: crypto.randomUUID(), + userDid: claims.sub, + endpoint, + keyP256dh: keys.p256dh, + keyAuth: keys.auth, + userAgent: c.req.header("user-agent"), + }); + + return c.json({ ok: true }); +}); + +// ── POST /push/unsubscribe — Remove push subscription (auth required) ── +notificationRouter.post("/push/unsubscribe", async (c) => { + const claims = await requireAuth(c.req.raw); + if (!claims) return c.json({ error: "Authentication required" }, 401); + + const body = await c.req.json(); + if (!body.endpoint) return c.json({ error: "endpoint required" }, 400); + + await deletePushSubscriptionByEndpoint(body.endpoint); + return c.json({ ok: true }); +}); diff --git a/server/notification-service.ts b/server/notification-service.ts index a01b904..c409ba0 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -7,14 +7,31 @@ */ import type { ServerWebSocket } from "bun"; +import webpush from "web-push"; import { createNotification, getUnreadCount, markNotificationDelivered, listSpaceMembers, + getUserPushSubscriptions, + deletePushSubscriptionByEndpoint, + updatePushSubscriptionLastUsed, + getNotificationPreferences, type StoredNotification, } from "../src/encryptid/db"; +// ── VAPID setup ── +const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY; +const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY; +const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:admin@rspace.online"; + +if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { + webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); + console.log("[push] VAPID configured"); +} else { + console.warn("[push] VAPID keys not set — Web Push disabled"); +} + // ============================================================================ // TYPES // ============================================================================ @@ -133,9 +150,62 @@ export async function notify(opts: NotifyOptions): Promise { } } + // 3. Attempt Web Push delivery (non-blocking) + if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { + sendWebPush(stored, opts).catch(() => {}); + } + return stored; } +/** Send Web Push to all of a user's subscriptions. */ +async function sendWebPush(stored: StoredNotification, opts: NotifyOptions): Promise { + // Check if user has push enabled + const prefs = await getNotificationPreferences(opts.userDid); + if (prefs && !prefs.pushEnabled) return; + + const subs = await getUserPushSubscriptions(opts.userDid); + if (subs.length === 0) return; + + const pushPayload = JSON.stringify({ + title: stored.title, + body: stored.body || "", + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + tag: `${stored.category}-${stored.eventType}`, + data: { + url: stored.actionUrl || "/", + notificationId: stored.id, + }, + }); + + let anyDelivered = false; + await Promise.allSettled( + subs.map(async (sub) => { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { p256dh: sub.keyP256dh, auth: sub.keyAuth }, + }, + pushPayload, + ); + anyDelivered = true; + updatePushSubscriptionLastUsed(sub.id).catch(() => {}); + } catch (err: any) { + // 404/410 = subscription expired, clean up + if (err?.statusCode === 404 || err?.statusCode === 410) { + await deletePushSubscriptionByEndpoint(sub.endpoint); + } + } + }), + ); + + if (anyDelivered) { + await markNotificationDelivered(stored.id, 'push'); + } +} + // ============================================================================ // CONVENIENCE: NOTIFY SPACE ADMINS/MODS // ============================================================================ diff --git a/server/shell.ts b/server/shell.ts index b368c71..df55b0f 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -104,6 +104,12 @@ export function renderShell(opts: ShellOptions): string { + + + + + + ${escapeHtml(title)} @@ -155,6 +161,16 @@ export function renderShell(opts: ShellOptions): string { @@ -1015,6 +1037,9 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { ${bodyContent} @@ -1349,6 +1380,9 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string { ${bodyContent} diff --git a/website/create-space.html b/website/create-space.html index 981605b..87e4b79 100644 --- a/website/create-space.html +++ b/website/create-space.html @@ -4,6 +4,12 @@ + + + + + + Create a Space — rSpace @@ -291,6 +297,9 @@