diff --git a/server/notification-routes.ts b/server/notification-routes.ts index 33044da..4562c07 100644 --- a/server/notification-routes.ts +++ b/server/notification-routes.ts @@ -1,186 +1,38 @@ /** * Notification REST API — mounted at /api/notifications * - * All endpoints require Bearer auth (same pattern as spaces.ts). + * Proxies all requests to the encryptid service which has direct DB access. + * The rspace container cannot reach the encryptid database directly. */ import { Hono } from "hono"; -import { verifyToken, extractToken } from "./auth"; -import { - getUserNotifications, - getUnreadCount, - markNotificationRead, - markAllNotificationsRead, - dismissNotification, - getNotificationPreferences, - upsertNotificationPreferences, - savePushSubscription, - deletePushSubscriptionByEndpoint, -} from "../src/encryptid/db"; + +const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; export const notificationRouter = new Hono(); -// ── Auth helper ── -async function requireAuth(req: Request) { - const token = extractToken(req.headers); - if (!token) return null; +// Proxy all notification requests to encryptid +notificationRouter.all("/*", async (c) => { + const url = new URL(c.req.url); + const targetUrl = `${ENCRYPTID_URL}${url.pathname}${url.search}`; + + const headers = new Headers(c.req.raw.headers); + headers.delete("host"); + try { - return await verifyToken(token); - } catch { - return null; - } -} - -// ── GET / — Paginated notification list ── -notificationRouter.get("/", async (c) => { - try { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const url = new URL(c.req.url); - const unreadOnly = url.searchParams.get("unread") === "true"; - const category = url.searchParams.get("category") || undefined; - const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 100); - const offset = Number(url.searchParams.get("offset")) || 0; - - const notifications = await getUserNotifications(claims.sub, { - unreadOnly, - category, - limit, - offset, + const res = await fetch(targetUrl, { + method: c.req.method, + headers, + body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined, + // @ts-ignore duplex needed for streaming request bodies + duplex: "half", }); - - return c.json({ notifications }); - } catch (err) { - console.error("[notifications] GET / failed:", err instanceof Error ? err.message : err); - return c.json({ notifications: [] }); + return new Response(res.body, { status: res.status, headers: res.headers }); + } catch (e: any) { + console.error("[notifications proxy] Failed to reach encryptid:", e?.message); + // Return safe fallbacks instead of hanging + if (url.pathname.endsWith("/count")) return c.json({ unreadCount: 0 }); + if (url.pathname === "/api/notifications") return c.json({ notifications: [] }); + return c.json({ error: "Notification service unavailable" }, 503); } }); - -// ── GET /count — Lightweight unread count (polling fallback) ── -notificationRouter.get("/count", async (c) => { - try { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const count = await getUnreadCount(claims.sub); - return c.json({ unreadCount: count }); - } catch (err) { - console.error("[notifications] GET /count failed:", err instanceof Error ? err.message : err); - return c.json({ unreadCount: 0 }); - } -}); - -// ── PATCH /:id/read — Mark one notification as read ── -notificationRouter.patch("/:id/read", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const id = c.req.param("id"); - const ok = await markNotificationRead(id, claims.sub); - if (!ok) return c.json({ error: "Notification not found" }, 404); - return c.json({ ok: true }); -}); - -// ── POST /read-all — Mark all read (optional scope) ── -notificationRouter.post("/read-all", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - let spaceSlug: string | undefined; - let category: string | undefined; - try { - const body = await c.req.json(); - spaceSlug = body.spaceSlug; - category = body.category; - } catch { - // No body is fine — mark everything read - } - - const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category }); - return c.json({ ok: true, markedRead: count }); -}); - -// ── DELETE /:id — Dismiss/archive a notification ── -notificationRouter.delete("/:id", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const id = c.req.param("id"); - const ok = await dismissNotification(id, claims.sub); - if (!ok) return c.json({ error: "Notification not found" }, 404); - return c.json({ ok: true }); -}); - -// ── GET /preferences — Get notification preferences ── -notificationRouter.get("/preferences", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const prefs = await getNotificationPreferences(claims.sub); - return c.json({ preferences: prefs || { - emailEnabled: true, - pushEnabled: true, - quietHoursStart: null, - quietHoursEnd: null, - mutedSpaces: [], - mutedCategories: [], - digestFrequency: 'none', - }}); -}); - -// ── PATCH /preferences — Update notification preferences ── -notificationRouter.patch("/preferences", 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 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/shell.ts b/server/shell.ts index b131b84..cb5e75e 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -797,11 +797,13 @@ export function renderShell(opts: ShellOptions): string { if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); } // Broadcast to other same-browser tabs if (_tabChannel) { - _tabChannel.postMessage({ - type: 'tabs-sync', - layers: layers, - closed: [..._closedModuleIds], - }); + try { + _tabChannel.postMessage({ + type: 'tabs-sync', + layers: layers, + closed: [..._closedModuleIds], + }); + } catch(e) { /* channel may be closed during navigation */ } } } diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index b89ff18..7719fc0 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -120,6 +120,15 @@ import { getTrustScoresByAuthority, listAllUsersWithTrust, sql, + getUserNotifications, + getUnreadCount, + markNotificationRead, + markAllNotificationsRead, + dismissNotification, + getNotificationPreferences, + upsertNotificationPreferences, + savePushSubscription, + deletePushSubscriptionByEndpoint, getUserUPAddress, setUserUPAddress, getUserByUPAddress, @@ -1245,6 +1254,146 @@ app.put('/api/user/tabs/:spaceSlug', async (c) => { return c.json({ success: true }); }); +// ============================================================================ +// NOTIFICATION API (read/manage — used by rspace proxy) +// ============================================================================ + +/** GET /api/notifications — paginated notification list */ +app.get('/api/notifications', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + try { + const url = new URL(c.req.url); + const notifications = await getUserNotifications(claims.sub, { + unreadOnly: url.searchParams.get('unread') === 'true', + category: url.searchParams.get('category') || undefined, + limit: Math.min(Number(url.searchParams.get('limit')) || 50, 100), + offset: Number(url.searchParams.get('offset')) || 0, + }); + return c.json({ notifications }); + } catch (err) { + console.error('[notifications] GET / failed:', err instanceof Error ? err.message : err); + return c.json({ notifications: [] }); + } +}); + +/** GET /api/notifications/count — lightweight unread count */ +app.get('/api/notifications/count', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + try { + const count = await getUnreadCount(claims.sub); + return c.json({ unreadCount: count }); + } catch (err) { + console.error('[notifications] GET /count failed:', err instanceof Error ? err.message : err); + return c.json({ unreadCount: 0 }); + } +}); + +/** PATCH /api/notifications/:id/read — mark one notification as read */ +app.patch('/api/notifications/:id/read', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const ok = await markNotificationRead(c.req.param('id'), claims.sub); + if (!ok) return c.json({ error: 'Notification not found' }, 404); + return c.json({ ok: true }); +}); + +/** POST /api/notifications/read-all — mark all read */ +app.post('/api/notifications/read-all', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + let spaceSlug: string | undefined; + let category: string | undefined; + try { + const body = await c.req.json(); + spaceSlug = body.spaceSlug; + category = body.category; + } catch { /* no body is fine */ } + + const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category }); + return c.json({ ok: true, markedRead: count }); +}); + +/** DELETE /api/notifications/:id — dismiss/archive */ +app.delete('/api/notifications/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const ok = await dismissNotification(c.req.param('id'), claims.sub); + if (!ok) return c.json({ error: 'Notification not found' }, 404); + return c.json({ ok: true }); +}); + +/** GET /api/notifications/preferences */ +app.get('/api/notifications/preferences', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const prefs = await getNotificationPreferences(claims.sub); + return c.json({ preferences: prefs || { + emailEnabled: true, pushEnabled: true, + quietHoursStart: null, quietHoursEnd: null, + mutedSpaces: [], mutedCategories: [], + digestFrequency: 'none', + }}); +}); + +/** PATCH /api/notifications/preferences */ +app.patch('/api/notifications/preferences', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const body = await c.req.json(); + const prefs = await upsertNotificationPreferences(claims.sub, body); + return c.json({ preferences: prefs }); +}); + +/** GET /api/notifications/push/vapid-public-key */ +app.get('/api/notifications/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 /api/notifications/push/subscribe */ +app.post('/api/notifications/push/subscribe', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + 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 /api/notifications/push/unsubscribe */ +app.post('/api/notifications/push/unsubscribe', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + 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 }); +}); + // ============================================================================ // ACCOUNT SETTINGS ENDPOINTS // ============================================================================ diff --git a/website/canvas.html b/website/canvas.html index 618af72..443d31e 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -982,29 +982,6 @@ background: var(--rs-bg-hover, rgba(255,255,255,0.08)); } - #people-conn-status { - display: none; - align-items: center; - gap: 4px; - font-size: 11px; - padding-left: 6px; - border-left: 1px solid var(--rs-border, rgba(255,255,255,0.1)); - } - - #people-conn-status.visible { - display: inline-flex; - } - - #people-conn-status .conn-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - } - - #people-conn-status .conn-dot.pulse { - animation: pulse 1.2s ease-in-out infinite; - } #people-dots { display: flex; @@ -2000,7 +1977,6 @@
1 online -
@@ -3392,7 +3368,6 @@ const peopleBadgeText = document.getElementById("people-badge-text"); const peopleCount = document.getElementById("people-count"); const peopleList = document.getElementById("people-list"); - const peopleConnStatus = document.getElementById("people-conn-status"); let connState = "connecting"; // "connected" | "offline" | "reconnecting" | "connecting" const pingToast = document.getElementById("ping-toast"); const pingToastText = document.getElementById("ping-toast-text"); @@ -3439,15 +3414,26 @@ function renderPeopleBadge() { const totalCount = onlinePeers.size + 1; // +1 for self peopleDots.innerHTML = ""; - if (!isMultiplayer) { - // Offline / solo mode + + if (connState === "offline") { + // Actually disconnected from internet peopleBadgeText.textContent = "Offline"; + peopleBadge.title = "You\u2019re offline \u2014 your changes are saved locally and will resync when you reconnect to the internet."; const selfDot = document.createElement("span"); selfDot.className = "dot"; - selfDot.style.background = "#64748b"; + selfDot.style.background = "#f59e0b"; + peopleDots.appendChild(selfDot); + } else if (connState === "reconnecting" || connState === "connecting") { + peopleBadgeText.textContent = "Reconnecting\u2026"; + peopleBadge.title = "Reconnecting to the server \u2014 your changes are saved locally and will resync automatically."; + const selfDot = document.createElement("span"); + selfDot.className = "dot"; + selfDot.style.background = "#3b82f6"; peopleDots.appendChild(selfDot); } else { + // Connected peopleBadgeText.textContent = totalCount === 1 ? "1 online" : `${totalCount} online`; + peopleBadge.title = ""; // Self dot const selfDot = document.createElement("span"); selfDot.className = "dot"; @@ -3464,30 +3450,28 @@ dotCount++; } } - peopleCount.textContent = isMultiplayer ? totalCount : "—"; - // Connection status indicator - if (connState === "connected" || !isMultiplayer) { - peopleConnStatus.classList.remove("visible"); - peopleConnStatus.innerHTML = ""; - } else { - const color = connState === "offline" ? "#f59e0b" : "#3b82f6"; - const label = connState === "offline" ? "Offline" : "Reconnecting…"; - const pulse = connState !== "offline" ? " pulse" : ""; - peopleConnStatus.innerHTML = `${label}`; - peopleConnStatus.classList.add("visible"); - } + peopleCount.textContent = connState === "connected" ? totalCount : "\u2014"; } function renderPeoplePanel() { peopleList.innerHTML = ""; - // Self row with online/offline toggle + // Show offline notice if disconnected + if (connState === "offline" || connState === "reconnecting" || connState === "connecting") { + const notice = document.createElement("div"); + notice.style.cssText = "padding:8px 16px;font-size:12px;color:var(--rs-text-muted);background:var(--rs-bg-surface-raised,rgba(255,255,255,0.04));border-bottom:1px solid var(--rs-border-subtle,rgba(255,255,255,0.06))"; + notice.textContent = connState === "offline" + ? "\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect." + : "Reconnecting to server\u2026"; + peopleList.appendChild(notice); + } + // Self row with cursor visibility toggle const selfRow = document.createElement("div"); selfRow.className = "people-row"; - selfRow.innerHTML = ` + selfRow.innerHTML = ` ${escapeHtml(storedUsername)} (you) - - - + + + `; selfRow.querySelector(".mode-solo").addEventListener("click", () => setMultiplayerMode(false)); selfRow.querySelector(".mode-multi").addEventListener("click", () => setMultiplayerMode(true)); @@ -6628,10 +6612,18 @@ updateCanvasTransform(); }, { passive: false }); + // ── Shadow-DOM-aware text input check ── + // e.target is retargeted to the shadow host, so we must walk composedPath() + function isInTextInput(e) { + return e.composedPath().some(el => + el instanceof HTMLElement && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable) + ); + } + // ── Space key tracking for space+drag pan ── let spaceHeld = false; document.addEventListener("keydown", (e) => { - if (e.code === "Space" && !e.target.closest("input, textarea, [contenteditable]")) { + if (e.code === "Space" && !isInTextInput(e)) { e.preventDefault(); spaceHeld = true; canvas.style.cursor = "grab"; @@ -6663,7 +6655,7 @@ document.addEventListener("keydown", (e) => { if ((e.key === "Delete" || e.key === "Backspace") && - !e.target.closest("input, textarea, [contenteditable]") && + !isInTextInput(e) && !bulkDeleteOverlay && selectedShapeIds.size > 0) { if (selectedShapeIds.size > 5) { @@ -6677,7 +6669,7 @@ // ── Undo / Redo (Ctrl+Z / Ctrl+Shift+Z) ── document.addEventListener("keydown", (e) => { if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey) && - !e.target.closest("input, textarea, [contenteditable]")) { + !isInTextInput(e)) { e.preventDefault(); if (e.shiftKey) { sync.redo();