From 044caae447ebeaaec0c120410f0c52e42ac7180e Mon Sep 17 00:00:00 2001 From: th Date: Wed, 15 Apr 2026 20:15:29 +0200 Subject: [PATCH] relay: group chat rooms + persistent chat history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds named group threads (chat_rooms + chat_room_members), mirroring the collab-buckets membership pattern. Group messages share the chat_messages table via a new nullable room_uid column; recipient_id is left 0 for group rows and fan-out happens via the member-join at GET time instead of duplicating per recipient. Chat retention flips from "drop everything after 7 days" to persistent-by-default: a new `persistent` flag on chat_messages marks long-term rows (default 1 — all group messages plus non-disposable 1:1). The 7-day cleanup only targets persistent=0 rows so the sender can still poll final receipt state before they're pruned. New endpoints: POST/GET/PATCH/DELETE /api/chat/rooms[/{uid}] — room CRUD GET /api/chat/backfill — scrollback / cold-start history fetch POST /api/chat — accepts recipient OR room_uid + disposable flag GET /api/chat — returns 1:1 to caller AND rooms caller is in Co-Authored-By: Claude Opus 4.6 (1M context) --- relay/inc/bootstrap.php | 44 ++++++- relay/inc/routes/chat.php | 271 +++++++++++++++++++++++++++++++++++--- 2 files changed, 294 insertions(+), 21 deletions(-) diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index ee87ec2..1f63f58 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -189,6 +189,19 @@ $chatColNames = array_column($chatCols, 'name'); if (!in_array('seen', $chatColNames)) { $db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0'); } +// Group chat rooms: room_uid (nullable — set for group messages, NULL for +// 1:1). recipient_id stays non-null for 1:1; group rooms resolve recipients +// via chat_room_members. `persistent` controls retention: 1 = keep in the +// long-term store so multi-device / late-joiners can pull history, 0 = old +// "disposable" behaviour (prune after fetch window). 1:1 defaults to 1; +// group messages are always 1. +if (!in_array('room_uid', $chatColNames)) { + $db->exec('ALTER TABLE chat_messages ADD COLUMN room_uid TEXT'); + $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)'); +} +if (!in_array('persistent', $chatColNames)) { + $db->exec('ALTER TABLE chat_messages ADD COLUMN persistent INTEGER NOT NULL DEFAULT 1'); +} // Sync-notification cleanup: the bot=1 chat messages turned out to clutter // the chat thread. The feature moved to its own `relay_sync_events` queue // (see below). Drop any leftover bot rows so they stop showing up in @@ -199,6 +212,27 @@ if (in_array('bot', $chatColNames)) { } $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)'); +// Chat rooms: named group threads. Owner + membership mirrors collab +// buckets. Group messages live in chat_messages with room_uid set. +$db->exec('CREATE TABLE IF NOT EXISTS chat_rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE NOT NULL, + owner_id INTEGER NOT NULL, + owner_username TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); +$db->exec('CREATE TABLE IF NOT EXISTS chat_room_members ( + room_uid TEXT NOT NULL, + tenant_id INTEGER NOT NULL, + username TEXT NOT NULL, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (room_uid, tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)'); + // Sync-event notifications: replaces the old bot-chat mechanism. When a // client syncs a card they received, they POST here; the sender polls // GET /api/sync-events and surfaces each as a transient toast. Rows are @@ -315,8 +349,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits ( // Cleanup: 24h ephemeral storage, expired sessions, old rate limits $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"); $db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')"); -// Chat: 7 days, longer than card sync but still ephemeral -$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')"); +// Chat retention: +// - persistent = 1 (default, 1:1 non-disposable + all group messages) +// → never auto-deleted. Relay is the source of truth so late joiners +// and second devices can backfill full history. +// - persistent = 0 (disposable 1:1 threads) → drop after a 7-day grace +// window so the sender's /api/chat/sent receipt poll still sees the +// final state. +$db->exec("DELETE FROM chat_messages WHERE persistent = 0 AND created_at < datetime('now', '-7 days')"); // Sync events are transient toasts — drop fetched ones after an hour, // everything after 24h regardless (sender was offline too long to care). $db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')"); diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php index 56a1be1..3f11c29 100644 --- a/relay/inc/routes/chat.php +++ b/relay/inc/routes/chat.php @@ -154,16 +154,133 @@ if ($route === '/api/sync-events' && $method === 'GET') { } +// ── Chat rooms (group chat) ────────────────────────────────── +// Mirrors the collab-bucket pattern: named container + member list. +// Group messages live in chat_messages with room_uid set (recipient_id +// is left 0 / NULL — fan-out happens via the join on chat_room_members +// at GET time instead of duplicating rows per recipient). Rooms are +// always persistent — message retention ignores the 7-day disposable +// window. + +if ($route === '/api/chat/rooms' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $uid = trim((string)($input['uid'] ?? '')); + $name = trim((string)($input['name'] ?? '')); + $memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : []; + if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400); + $exists = $db->prepare('SELECT 1 FROM chat_rooms WHERE uid = :u'); + $exists->execute(['u' => $uid]); + if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409); + + $resolved = []; + foreach ($memberUsernames as $u) { + $u = trim((string)$u); + if ($u === '' || $u === $clientTenant['username']) continue; + $t = getTenantByUsername($db, $u); + if ($t) $resolved[(int)$t['id']] = $t['username']; + } + $resolved[(int)$clientTenant['id']] = $clientTenant['username']; + + try { + $db->beginTransaction(); + $db->prepare('INSERT INTO chat_rooms (uid, owner_id, owner_username, name) VALUES (:uid, :oid, :ou, :n)') + ->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name]); + $mstmt = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)'); + foreach ($resolved as $tid => $uname) { + $mstmt->execute(['r' => $uid, 't' => $tid, 'u' => $uname]); + } + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500); + } + jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]); +} + +if ($route === '/api/chat/rooms' && $method === 'GET') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $stmt = $db->prepare(' + SELECT r.uid, r.name, r.owner_username, r.created_at + FROM chat_rooms r + INNER JOIN chat_room_members m ON m.room_uid = r.uid + WHERE m.tenant_id = :tid + ORDER BY r.created_at DESC + '); + $stmt->execute(['tid' => $clientTenant['id']]); + $rooms = $stmt->fetchAll(); + $mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :uid ORDER BY username'); + foreach ($rooms as &$r) { + $mstmt->execute(['uid' => $r['uid']]); + $r['members'] = array_column($mstmt->fetchAll(), 'username'); + } + jsonResponse(['ok' => true, 'rooms' => $rooms]); +} + +if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $uid = $m[1]; + $row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u'); + $row->execute(['u' => $uid]); + $room = $row->fetch(); + if (!$room) jsonResponse(['error' => 'room not found'], 404); + if ((int)$room['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403); + + $input = json_decode(file_get_contents('php://input'), true) ?: []; + if (isset($input['name'])) { + $name = trim((string)$input['name']); + if ($name === '') jsonResponse(['error' => 'name cannot be empty'], 400); + $db->prepare('UPDATE chat_rooms SET name = :n WHERE uid = :u')->execute(['n' => $name, 'u' => $uid]); + } + if (isset($input['members']) && is_array($input['members'])) { + $resolved = []; + foreach ($input['members'] as $u) { + $u = trim((string)$u); + if ($u === '') continue; + $t = getTenantByUsername($db, $u); + if ($t) $resolved[(int)$t['id']] = $t['username']; + } + // Owner always stays a member. + $resolved[(int)$clientTenant['id']] = $clientTenant['username']; + $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); + $ins = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)'); + foreach ($resolved as $tid => $uname) { + $ins->execute(['r' => $uid, 't' => $tid, 'u' => $uname]); + } + } + jsonResponse(['ok' => true]); +} + +if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $uid = $m[1]; + $row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u'); + $row->execute(['u' => $uid]); + $room = $row->fetch(); + if (!$room) jsonResponse(['error' => 'room not found'], 404); + + if ((int)$room['owner_id'] === (int)$clientTenant['id']) { + $db->prepare('DELETE FROM chat_messages WHERE room_uid = :u')->execute(['u' => $uid]); + $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); + $db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]); + jsonResponse(['ok' => true, 'action' => 'deleted']); + } else { + $del = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u AND tenant_id = :t'); + $del->execute(['u' => $uid, 't' => $clientTenant['id']]); + jsonResponse(['ok' => true, 'action' => 'left']); + } +} + if ($route === '/api/chat' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $encryptedBody = (string)($input['body'] ?? ''); $recipientName = trim((string)($input['recipient'] ?? '')); + $roomUid = trim((string)($input['room_uid'] ?? '')); + $disposable = !empty($input['disposable']); if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400); - if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); - - $recipient = getTenantByUsername($db, $recipientName); - if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); + if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400); + if ($recipientName !== '' && $roomUid !== '') jsonResponse(['error' => 'pick either recipient or room_uid, not both'], 400); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400); @@ -177,13 +294,27 @@ if ($route === '/api/chat' && $method === 'POST') { if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400); $stored = clientEncrypt($plain, $interKey); - $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)') - ->execute([ - 'r' => $recipient['id'], - 's' => $clientTenant['id'], - 'sn' => $clientTenant['username'], - 'b' => $stored, - ]); + if ($roomUid !== '') { + // Group message: verify membership, single row with room_uid set + // and recipient_id=0 (sentinel — fan-out happens on GET via the + // chat_room_members join). + $check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t'); + $check->execute(['r' => $roomUid, 't' => $clientTenant['id']]); + if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403); + $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (0, :s, :sn, :ru, :b, 1)') + ->execute(['s' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]); + } else { + $recipient = getTenantByUsername($db, $recipientName); + if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); + $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, persistent) VALUES (:r, :s, :sn, :b, :p)') + ->execute([ + 'r' => $recipient['id'], + 's' => $clientTenant['id'], + 'sn' => $clientTenant['username'], + 'b' => $stored, + 'p' => $disposable ? 0 : 1, + ]); + } jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } @@ -194,8 +325,24 @@ if ($route === '/api/chat' && $method === 'GET') { } $since = (int)($_GET['since'] ?? 0); - $stmt = $db->prepare('SELECT id, sender_username, body, created_at FROM chat_messages WHERE recipient_id = :r AND id > :s ORDER BY id ASC LIMIT 500'); - $stmt->execute(['r' => $clientTenant['id'], 's' => $since]); + // Return 1:1 messages addressed to me + group messages for rooms I'm + // in (including my own group sends, so multi-device picks them up — + // the client dedups on relay_id). Exclude my own 1:1 sends — the + // sender has them locally already and the /api/chat/sent endpoint + // covers cross-device state for 1:1. + $stmt = $db->prepare(' + SELECT id, sender_id, sender_username, recipient_id, room_uid, body, created_at + FROM chat_messages + WHERE id > :s + AND ( + (recipient_id = :me AND room_uid IS NULL) + OR (room_uid IS NOT NULL AND room_uid IN ( + SELECT room_uid FROM chat_room_members WHERE tenant_id = :me2 + )) + ) + ORDER BY id ASC LIMIT 500 + '); + $stmt->execute(['me' => $clientTenant['id'], 'me2' => $clientTenant['id'], 's' => $since]); $rows = $stmt->fetchAll(); $interKey = getInterTenantKey($db, $MASTER_KEY); @@ -207,20 +354,106 @@ if ($route === '/api/chat' && $method === 'GET') { $maxId = $since; foreach ($rows as $row) { $plain = clientDecrypt($row['body'] ?? '', $interKey); - $row['body'] = clientEncrypt($plain, $recipientSecret); - $out[] = $row; + $out[] = [ + 'id' => (int)$row['id'], + 'sender_id' => (int)$row['sender_id'], + 'sender_username' => $row['sender_username'], + 'room_uid' => $row['room_uid'], + 'body' => clientEncrypt($plain, $recipientSecret), + 'created_at' => $row['created_at'], + 'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'], + ]; if ((int)$row['id'] > $maxId) $maxId = (int)$row['id']; } - // Mark fetched so cleanup can prune them sooner if desired. We still - // keep them around for the 7-day window so a re-poll with since=0 - // backfills history if a client wipes its local cache. + // Mark fetched on 1:1 messages so disposable ones can be pruned + // sooner. Group messages stay persistent regardless — no per-poll + // fan-out state, so nothing to flag there. if ($out) { - $db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0') + $db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND room_uid IS NULL AND id <= :m AND fetched = 0') ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); } jsonResponse(['messages' => $out, 'max_id' => $maxId]); } +// ── GET /api/chat/backfill ───────────────────────────────────── +// Scrollback / cold-start backfill for a single thread. Pulls older +// messages before a given id, re-encrypted for the caller. Used when +// the client opens a room it's never seen, or when the local cache +// was wiped on a second device. +// ?room_uid=X — group scrollback (membership checked) +// ?peer=X — 1:1 scrollback with that peer +// &before=N — only messages with id < N (default: latest) +// &limit=N — page size (default 100, max 500) + +if ($route === '/api/chat/backfill' && $method === 'GET') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + if (empty($clientTenant['secret_encrypted'])) { + jsonResponse(['error' => 'no client secret set'], 400); + } + $roomUid = trim((string)($_GET['room_uid'] ?? '')); + $peer = trim((string)($_GET['peer'] ?? '')); + $before = (int)($_GET['before'] ?? 0); + $limit = max(1, min(500, (int)($_GET['limit'] ?? 100))); + if ($roomUid === '' && $peer === '') jsonResponse(['error' => 'room_uid or peer required'], 400); + + $interKey = getInterTenantKey($db, $MASTER_KEY); + if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); + $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); + if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); + + $beforeClause = $before > 0 ? 'AND id < :before' : ''; + if ($roomUid !== '') { + $check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t'); + $check->execute(['r' => $roomUid, 't' => $clientTenant['id']]); + if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403); + $sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at + FROM chat_messages WHERE room_uid = :ru $beforeClause + ORDER BY id DESC LIMIT :lim"; + $stmt = $db->prepare($sql); + $stmt->bindValue(':ru', $roomUid); + if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT); + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + } else { + $peerTenant = getTenantByUsername($db, $peer); + if (!$peerTenant) jsonResponse(['error' => "no such user: $peer"], 404); + $sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at + FROM chat_messages + WHERE room_uid IS NULL + AND ( + (recipient_id = :me AND sender_id = :peerid) + OR (recipient_id = :peerid2 AND sender_id = :me2) + ) + $beforeClause + ORDER BY id DESC LIMIT :lim"; + $stmt = $db->prepare($sql); + $stmt->bindValue(':me', $clientTenant['id'], PDO::PARAM_INT); + $stmt->bindValue(':me2', $clientTenant['id'], PDO::PARAM_INT); + $stmt->bindValue(':peerid', $peerTenant['id'], PDO::PARAM_INT); + $stmt->bindValue(':peerid2', $peerTenant['id'], PDO::PARAM_INT); + if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT); + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + } + $rows = $stmt->fetchAll(); + // Flip back to ASC for a natural render order on the client. + $rows = array_reverse($rows); + $out = []; + foreach ($rows as $row) { + $plain = clientDecrypt($row['body'] ?? '', $interKey); + $out[] = [ + 'id' => (int)$row['id'], + 'sender_id' => (int)$row['sender_id'], + 'sender_username' => $row['sender_username'], + 'room_uid' => $row['room_uid'], + 'body' => clientEncrypt($plain, $recipientSecret), + 'created_at' => $row['created_at'], + 'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'], + ]; + } + jsonResponse(['messages' => $out]); +} + // ── GET /api/chat/sent ───────────────────────────────────────── // Returns the receipt state (fetched/seen) for messages the caller has // sent. Body is intentionally NOT included — the sender already has the