From 4a025e0ee654451a3378ea34234de80a912fb1f4 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 10 Aug 2026 17:28:02 +0100 Subject: [PATCH] reroute chat --- frontend/src/lib/stores/chat.svelte.ts | 51 +++++++++++----------- frontend/src/lib/types.ts | 1 + frontend/src/routes/chat/+server.ts | 58 ++++++++++++++++++++++++++ proxy/src/index.ts | 28 +------------ proxy/src/migrate.ts | 20 +++++---- 5 files changed, 99 insertions(+), 59 deletions(-) create mode 100644 frontend/src/routes/chat/+server.ts diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index 2376ed1..ca11107 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -115,7 +115,16 @@ class ChatStore { return; } // create - if (!this.messages.some((m) => m.id === record.id)) { + // If this is our own optimistic temp (same clientId), replace it with + // the server-confirmed record instead of appending a duplicate. + const tempIdx = this.messages.findIndex( + (m) => m.clientId && record.clientId && m.clientId === record.clientId + ); + if (tempIdx !== -1) { + this.messages = this.messages + .map((m, i) => (i === tempIdx ? record : m)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } else if (!this.messages.some((m) => m.id === record.id)) { this.messages = [...this.messages, record].sort((a, b) => a.createdAt.localeCompare(b.createdAt) ); @@ -160,52 +169,42 @@ class ChatStore { this.typingTimers.delete(actorId); } - // ── Writes via proxy ── + // ── Writes via SvelteKit server (forwards session/device auth) ── - private async chatFetch(method: string, path: string, body?: unknown) { - const headers: Record = { 'Content-Type': 'application/json' }; - if (this.actorType === 'member' && this.deviceToken) { - headers['x-device-token'] = this.deviceToken; - headers['x-device-famid'] = this.famId; - } - const res = await fetch(path, { - method, - headers, - body: body ? JSON.stringify(body) : undefined + private async serverChat(payload: Record) { + const res = await fetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) }); const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); + if (!res.ok) throw new Error(data.error || 'chat request failed'); return data; } async send(content: string) { const text = content.trim(); if (!text || !this.famId) return; + const clientId = crypto.randomUUID(); const temp: ChatMessage = { - id: 'temp-' + Date.now(), + id: 'temp-' + clientId, famId: this.famId, authorType: this.actorType, authorId: this.actorId, authorName: this.actorName, authorColor: this.actorColor, content: text, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + clientId }; this.messages = [...this.messages, temp]; this.lastSeenAt = Date.now(); // Signal we stopped typing (message sent). this.setTyping(false).catch(() => {}); try { - const saved = await this.chatFetch('POST', `/api/chat/${this.famId}/messages`, { - content: text - }); - // SSE may have already delivered the real record via onMessage, so - // drop both the temp and any pre-existing copy of the saved id to - // avoid duplicate keys in the keyed each block. - this.messages = this.messages - .filter((m) => m.id !== temp.id && m.id !== saved.id) - .concat([{ ...saved, authorName: temp.authorName, authorColor: temp.authorColor }]) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + // Server writes to PB; the real record arrives via SSE and + // overwrites this optimistic temp (matched by clientId) in onMessage. + await this.serverChat({ action: 'send', famId: this.famId, content: text, clientId }); } catch (e) { this.messages = this.messages.filter((m) => m.id !== temp.id); console.error('Chat send failed:', e); @@ -220,7 +219,7 @@ class ChatStore { if (typing && now - this.typingThrottle < 1500) return; this.typingThrottle = now; try { - await this.chatFetch('POST', `/api/chat/${this.famId}/typing`, { typing }); + await this.serverChat({ action: 'typing', famId: this.famId, typing }); } catch (e) { console.error('Chat typing failed:', e); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e6dac4d..f97b31c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -181,6 +181,7 @@ export interface ChatMessage { authorColor?: string; content: string; createdAt: string; + clientId?: string; } export interface TypingRow { diff --git a/frontend/src/routes/chat/+server.ts b/frontend/src/routes/chat/+server.ts new file mode 100644 index 0000000..1416782 --- /dev/null +++ b/frontend/src/routes/chat/+server.ts @@ -0,0 +1,58 @@ +import { json } from '@sveltejs/kit'; +import { PROXY_URL } from '$app/env/public'; +import type { RequestEvent } from '@sveltejs/kit'; + +const HONO_URL = PROXY_URL; + +type Body = { + action: 'send' | 'typing'; + famId?: string; + content?: string; + clientId?: string; + typing?: boolean; +}; + +export async function POST(event: RequestEvent) { + const body = (await event.request.json().catch(() => null)) as Body | null; + if (!body || !body.action) return json({ error: 'missing action' }, 400); + + const session = event.locals.session; + const deviceToken = event.cookies.get('device_token') || ''; + + const headers: Record = { 'Content-Type': 'application/json' }; + let famId = body.famId || ''; + + if (session?.famId && session?.userId) { + // Admin (parent) — trust the verified session server-side. + headers['x-session-famid'] = session.famId; + headers['x-session-userid'] = session.userId; + famId = session.famId; + } else if (deviceToken) { + // Member (child) — forward the device token; the proxy re-validates. + headers['x-device-token'] = deviceToken; + headers['x-device-famid'] = famId; + } else { + return json({ error: 'Unauthorized' }, 401); + } + + if (!famId) return json({ error: 'famId required' }, 400); + + const path = body.action === 'typing' ? `/api/chat/${famId}/typing` : `/api/chat/${famId}/messages`; + const payload = + body.action === 'typing' + ? { typing: Boolean(body.typing) } + : { content: body.content || '', clientId: body.clientId || '' }; + + try { + const res = await fetch(`${HONO_URL}${path}`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return json({ error: data.error || 'chat request failed' }, res.status); + return json(data); + } catch { + return json({ error: 'chat request failed' }, 502); + } +} diff --git a/proxy/src/index.ts b/proxy/src/index.ts index b022a0c..d347957 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -2346,31 +2346,6 @@ async function resolveChatActor(c: any) { }; } } - const cookieHeader = c.req.header("cookie") || ""; - const cookieMatch = cookieHeader.match(/session=([^;]+)/); - if (cookieMatch) { - try { - const s = JSON.parse(decodeURIComponent(cookieMatch[1])); - if (s?.famId && s?.userId) { - const admins = await pb.getList( - "fam_admins", - `famId = '${s.famId}' && userId = '${s.userId}'`, - ); - const admin = admins.items?.[0]; - if (admin) { - return { - famId: s.famId, - actor: { - id: admin.id, - type: "admin", - name: admin.name, - color: admin.color, - }, - }; - } - } - } catch {} - } const famId = c.req.header("x-device-famid"); const deviceToken = c.req.header("x-device-token"); if (famId && deviceToken) { @@ -2399,7 +2374,7 @@ app.post("/api/chat/:famId/messages", async (c) => { try { const auth = await resolveChatActor(c); if (!auth) return c.json({ error: "Unauthorized" }, 401); - const { content } = await c.req.json(); + const { content, clientId } = await c.req.json(); if (!content || !content.trim()) { return c.json({ error: "content required" }, 400); } @@ -2411,6 +2386,7 @@ app.post("/api/chat/:famId/messages", async (c) => { authorColor: auth.actor.color, content: content.trim(), createdAt: new Date().toISOString(), + clientId: clientId ? String(clientId).slice(0, 64) : "", }); return c.json(record); } catch (err) { diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts index 049a322..ab3c0e9 100644 --- a/proxy/src/migrate.ts +++ b/proxy/src/migrate.ts @@ -1451,6 +1451,7 @@ export async function migrate(): Promise { { name: "authorColor", type: "text", required: false }, { name: "content", type: "text", required: true }, { name: "createdAt", type: "date", required: false }, + { name: "clientId", type: "text", required: false, max: 64 }, ], }); } else { @@ -1459,6 +1460,7 @@ export async function migrate(): Promise { // older stores may predate it. Chat filters/sorts on createdAt, so // ensure the field exists even if the collection was created without it. const msgsFields = msgsCol.fields?.map((f: any) => f.name) || []; + let msgsChanged = false; if (!msgsFields.includes("createdAt")) { console.log(" ↳ adding missing createdAt field to messages..."); msgsCol.fields.push({ @@ -1468,14 +1470,18 @@ export async function migrate(): Promise { min: "", max: "", }); - await updateCollection("messages", { - listRule: "", - viewRule: "", - fields: msgsCol.fields, - }); - } else { - await updateCollection("messages", { listRule: "", viewRule: "" }); + msgsChanged = true; } + if (!msgsFields.includes("clientId")) { + console.log(" ↳ adding missing clientId field to messages..."); + msgsCol.fields.push({ name: "clientId", type: "text", required: false, max: 64 }); + msgsChanged = true; + } + await updateCollection("messages", { + listRule: "", + viewRule: "", + fields: msgsCol.fields, + }); } // 9b. chat_typing (transient presence rows, one per actor)