exec(" CREATE TABLE IF NOT EXISTS chat_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, peer TEXT NOT NULL, direction TEXT NOT NULL CHECK(direction IN ('in','out')), body TEXT NOT NULL, relay_id INTEGER, sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, is_read INTEGER NOT NULL DEFAULT 0 ) "); $db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)"); $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL"); // Read receipts for outgoing messages: `delivered` = recipient polled // (single check), `seen` = recipient opened the thread (double check). // Both filled in via /api/chat/sent receipt-poll on the relay side. $cols = $db->query("PRAGMA table_info(chat_messages)")->fetchAll(); $colNames = array_column($cols, 'name'); if (!in_array('delivered', $colNames)) { $db->exec("ALTER TABLE chat_messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0"); } if (!in_array('seen', $colNames)) { $db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0"); } // Group chat: `room_uid` set → message belongs to a room (group // thread), and `peer` holds the author's username (always — 1:1 // messages use peer for the other party, group messages reuse the // column for the sender). NULL room_uid = classic 1:1. if (!in_array('room_uid', $colNames)) { $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)"); } // Chat rooms + membership, mirrored from the relay on poll. The // client is the read-through cache; relay remains the source of // truth so a second device can join a room just by polling. $db->exec(" CREATE TABLE IF NOT EXISTS chat_rooms ( uid TEXT PRIMARY KEY, name TEXT NOT NULL, owner_username TEXT NOT NULL, created_at TEXT NOT NULL, synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) "); $db->exec(" CREATE TABLE IF NOT EXISTS chat_room_members ( room_uid TEXT NOT NULL, username TEXT NOT NULL, PRIMARY KEY (room_uid, username) ) "); // Per-thread settings. Currently: `disposable` flag for 1:1 threads // — when true, the relay drops the messages after the 7-day window // instead of keeping them in the long-term store. Rooms are always // persistent (enforced relay-side), so this only applies to peers. $db->exec(" CREATE TABLE IF NOT EXISTS chat_thread_settings ( peer TEXT PRIMARY KEY, disposable INTEGER NOT NULL DEFAULT 0 ) "); // Track the largest relay msg id we've ever seen (so the next poll uses since=that) $db->exec(" CREATE TABLE IF NOT EXISTS chat_state ( key TEXT PRIMARY KEY, value TEXT ) "); })(); function chatJson($data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; } function chatRelaySettings(): array { $db = Database::connect(); return [ 'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '', 'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '', 'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '', ]; } /** Dual-format envelope shared with the relay sync path. Writes V2 * (AES-GCM + HKDF), reads V2 + legacy V1 (CBC). Delegates to the central * helpers so the format stays in lock-step with src/bootstrap.php and * the relay's crypto.php. */ function chatEncrypt(string $plain, string $relayKey): string { return hyprcrdRelayEncrypt($plain, $relayKey); } function chatDecrypt(string $envelope, string $relayKey): string { // Old behaviour returned '' for non-envelope strings; preserve that. if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) { return ''; } return hyprcrdRelayDecrypt($envelope, $relayKey); } function chatGetState(string $key, $default = null) { $db = Database::connect(); $stmt = $db->prepare('SELECT value FROM chat_state WHERE key = :k'); $stmt->execute(['k' => $key]); $v = $stmt->fetchColumn(); return $v === false ? $default : $v; } function chatSetState(string $key, $value): void { $db = Database::connect(); $db->prepare('INSERT OR REPLACE INTO chat_state (key, value) VALUES (:k, :v)') ->execute(['k' => $key, 'v' => (string)$value]); } PluginManager::instance()->onApiRoute(function (string $route, string $method) { if ($route === '/api/chat/conversations' && $method === 'GET') { $db = Database::connect(); // 1:1 threads — one row per peer (room_uid IS NULL). $oneOnOne = $db->query(" SELECT 'peer' AS kind, peer AS key, peer AS label, MAX(sent_at) AS last_at, (SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer AND c2.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_body, (SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer AND c3.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_direction, SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread FROM chat_messages c1 WHERE room_uid IS NULL GROUP BY peer ")->fetchAll(); // Group rooms — join rooms to their newest message (if any); rooms // with no messages yet still render so the user can post there. $rooms = $db->query(" SELECT 'room' AS kind, r.uid AS key, r.name AS label, r.owner_username AS owner, (SELECT MAX(sent_at) FROM chat_messages WHERE room_uid = r.uid) AS last_at, (SELECT body FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_body, (SELECT peer FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_author, (SELECT COUNT(*) FROM chat_messages WHERE room_uid = r.uid AND direction = 'in' AND is_read = 0) AS unread FROM chat_rooms r ")->fetchAll(); $rows = array_merge($oneOnOne, $rooms); // Sort newest-first, nulls (empty rooms) to the bottom. usort($rows, function ($a, $b) { $al = $a['last_at'] ?? ''; $bl = $b['last_at'] ?? ''; if ($al === $bl) return 0; if ($al === '') return 1; if ($bl === '') return -1; return strcmp($bl, $al); }); $totalUnread = array_sum(array_map(fn($r) => (int)($r['unread'] ?? 0), $rows)); chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]); } if ($route === '/api/chat/messages' && $method === 'GET') { $peer = trim((string)($_GET['peer'] ?? '')); $roomUid = trim((string)($_GET['room_uid'] ?? '')); if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400); $db = Database::connect(); if ($roomUid !== '') { // Group thread: return all messages for the room. `peer` is the // author username; UI uses author === self to decide bubble // alignment instead of the direction flag (direction is set // from THIS device's perspective, so a message sent from // another device comes in as direction='in' while peer==self). $stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen, room_uid FROM chat_messages WHERE room_uid = :r ORDER BY id ASC LIMIT 500'); $stmt->execute(['r' => $roomUid]); } else { $stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p AND room_uid IS NULL ORDER BY id ASC LIMIT 500'); $stmt->execute(['p' => $peer]); } chatJson(['messages' => $stmt->fetchAll()]); } if ($route === '/api/chat/send' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); $roomUid = trim((string)($input['room_uid'] ?? '')); $body = (string)($input['body'] ?? ''); if ($body === '') chatJson(['error' => 'body required'], 400); if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400); $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) { chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400); } $db = Database::connect(); // Look up per-thread disposable flag (1:1 only; rooms are always // persistent on the relay). $disposable = 0; if ($peer !== '') { $stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p'); $stmt->execute(['p' => $peer]); $disposable = (int)($stmt->fetchColumn() ?: 0); } $envelope = chatEncrypt($body, $r['key']); $payload = $roomUid !== '' ? ['room_uid' => $roomUid, 'body' => $envelope] : ['recipient' => $peer, 'body' => $envelope, 'disposable' => $disposable]; $relayChatUrl = rtrim($r['url'], '/') . '/api/chat'; $ch = curl_init($relayChatUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { $relayErr = ''; $decoded = json_decode($response, true); if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error']; chatJson([ 'ok' => false, 'error' => "relay returned HTTP $status$relayErr", 'url' => $relayChatUrl, 'response_body' => is_string($response) ? substr($response, 0, 500) : null, ]); } $resData = json_decode($response, true); $relayId = isset($resData['id']) ? (int)$resData['id'] : null; // Store the outgoing message locally so the UI can render it // immediately. For group messages `peer` holds the author // (= self); for 1:1 it holds the other party as before. $localPeer = $roomUid !== '' ? $r['user'] : $peer; $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen, room_uid) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0, :ru)') ->execute([ 'p' => $localPeer, 'b' => $body, 'rid' => $relayId, 'ru' => $roomUid !== '' ? $roomUid : null, ]); chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/chat/poll' && $method === 'POST') { $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) { chatJson(['ok' => true, 'new' => 0, 'note' => 'relay not configured']); } $since = (int)chatGetState('last_relay_id', 0); $ch = curl_init(rtrim($r['url'], '/') . '/api/chat?since=' . $since); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { $relayErr = ''; $decoded = json_decode($response, true); if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error']; chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]); } $data = json_decode($response, true) ?: []; $messages = $data['messages'] ?? []; $maxId = (int)($data['max_id'] ?? $since); $db = Database::connect(); $newCount = 0; // `mine` on group messages (== came back from the relay because // another device of ours sent it) gets direction='out' so the UI // renders it aligned right; relay_id uniqueness dedupes against // the local insert we did on send. $insert = $db->prepare(' INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, room_uid) VALUES (:p, :dir, :b, :rid, :ts, 0, :ru) '); foreach ($messages as $m) { $plain = chatDecrypt($m['body'] ?? '', $r['key']); if ($plain === '') continue; $isGroup = !empty($m['room_uid']); $mine = !empty($m['mine']); $insert->execute([ 'p' => $m['sender_username'] ?? '?', 'dir' => $mine ? 'out' : 'in', 'b' => $plain, 'rid' => (int)$m['id'], 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'), 'ru' => $isGroup ? $m['room_uid'] : null, ]); if ($insert->rowCount() > 0) $newCount++; } if ($maxId > $since) chatSetState('last_relay_id', $maxId); // Mirror rooms + membership into the local cache on every poll. // Cheap (one extra GET), and it lets a second device see a room // it was just added to without a separate fetch. $ch3 = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms'); curl_setopt_array($ch3, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']], CURLOPT_TIMEOUT => 10, ]); $resp3 = curl_exec($ch3); $st3 = curl_getinfo($ch3, CURLINFO_HTTP_CODE); curl_close($ch3); if ($st3 === 200) { $roomsData = json_decode($resp3, true) ?: []; $rooms = $roomsData['rooms'] ?? []; $remoteUids = []; $upsert = $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, :c, datetime("now"))'); $delMem = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u'); $insMem = $db->prepare('INSERT OR IGNORE INTO chat_room_members (room_uid, username) VALUES (:u, :un)'); foreach ($rooms as $room) { $uid = (string)($room['uid'] ?? ''); if ($uid === '') continue; $remoteUids[] = $uid; $upsert->execute([ 'u' => $uid, 'n' => (string)($room['name'] ?? $uid), 'o' => (string)($room['owner_username'] ?? ''), 'c' => (string)($room['created_at'] ?? date('Y-m-d H:i:s')), ]); $delMem->execute(['u' => $uid]); foreach (($room['members'] ?? []) as $member) { $insMem->execute(['u' => $uid, 'un' => (string)$member]); } } // Drop local rooms we were removed from (owner kick / self-leave // on another device). Messages for them stay on disk — harmless, // UI won't list them without a room entry. $existing = $db->query('SELECT uid FROM chat_rooms')->fetchAll(PDO::FETCH_COLUMN); foreach ($existing as $uid) { if (!in_array($uid, $remoteUids, true)) { $db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]); $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); } } } // ── Receipt poll for our SENT messages ────────────────── // Pulls fetched/seen state for everything we've sent. Updates // local outgoing rows by relay_id. Uses an independent `since` // cursor so we keep getting state changes even on rows we // already saw before. $sentSince = (int)chatGetState('last_sent_relay_id', 0); $ch2 = curl_init(rtrim($r['url'], '/') . '/api/chat/sent?since=' . $sentSince); curl_setopt_array($ch2, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]); $resp2 = curl_exec($ch2); $st2 = curl_getinfo($ch2, CURLINFO_HTTP_CODE); curl_close($ch2); $receiptUpdates = 0; if ($st2 === 200) { $sentData = json_decode($resp2, true) ?: []; $receipts = $sentData['receipts'] ?? []; $sentMaxId = (int)($sentData['max_id'] ?? $sentSince); $upd = $db->prepare('UPDATE chat_messages SET delivered = :d, seen = :s WHERE relay_id = :rid AND direction = "out"'); foreach ($receipts as $rcpt) { $upd->execute([ 'd' => (int)($rcpt['fetched'] ?? 0), 's' => (int)($rcpt['seen'] ?? 0), 'rid' => (int)$rcpt['id'], ]); $receiptUpdates += $upd->rowCount(); } // Cursor only advances when EVERY row's state is final (seen=1). // Otherwise we need to keep re-polling those rows for updates. // Cheap implementation: if every row in this batch is seen, bump // cursor to maxId; else keep polling from the lowest non-seen id. $allSeen = true; $minPending = $sentMaxId; foreach ($receipts as $rcpt) { if ((int)($rcpt['seen'] ?? 0) !== 1) { $allSeen = false; if ((int)$rcpt['id'] - 1 < $minPending) $minPending = (int)$rcpt['id'] - 1; } } if ($allSeen && $sentMaxId > $sentSince) { chatSetState('last_sent_relay_id', $sentMaxId); } else if (!$allSeen && $minPending > $sentSince) { chatSetState('last_sent_relay_id', $minPending); } } chatJson(['ok' => true, 'new' => $newCount, 'receipts' => $receiptUpdates]); } if ($route === '/api/chat/read' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); $roomUid = trim((string)($input['room_uid'] ?? '')); if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400); $db = Database::connect(); if ($roomUid !== '') { $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE room_uid = :r AND direction = "in" AND is_read = 0') ->execute(['r' => $roomUid]); // Group "seen" receipts aren't a thing in v1 — skip the relay // notification. The local is_read flag is enough for badge UI. chatJson(['ok' => true]); } $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND room_uid IS NULL AND direction = "in" AND is_read = 0') ->execute(['p' => $peer]); // Tell the relay this peer's messages have been seen by us so the // peer's next /api/chat/sent poll picks up the double-check state. $r = chatRelaySettings(); if ($r['url'] && $r['user'] && $r['key']) { $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/seen'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['peer' => $peer]), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 5, ]); curl_exec($ch); curl_close($ch); // Failures are silent — the user already saw the messages // locally, the relay state is just lagging. } chatJson(['ok' => true]); } if ($route === '/api/chat/recipients' && $method === 'GET') { $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) { chatJson(['ok' => false, 'error' => 'relay not configured']); } $ch = curl_init(rtrim($r['url'], '/') . '/api/tenants'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) chatJson(['ok' => false, 'error' => "relay returned HTTP $status"]); $data = json_decode($response, true) ?: []; chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]); } // ── Rooms CRUD (proxy to relay) ────────────────────────────── if ($route === '/api/chat/rooms' && $method === 'GET') { $db = Database::connect(); $rooms = $db->query('SELECT uid, name, owner_username, created_at FROM chat_rooms ORDER BY created_at DESC')->fetchAll(); $mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :u ORDER BY username'); foreach ($rooms as &$room) { $mstmt->execute(['u' => $room['uid']]); $room['members'] = array_column($mstmt->fetchAll(), 'username'); } chatJson(['ok' => true, 'rooms' => $rooms]); } if ($route === '/api/chat/rooms' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $name = trim((string)($input['name'] ?? '')); $members = is_array($input['members'] ?? null) ? $input['members'] : []; if ($name === '') chatJson(['error' => 'name required'], 400); if (count(array_filter($members, fn($m) => trim((string)$m) !== '')) === 0) { chatJson(['error' => 'at least one other member required'], 400); } $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400); // Client-generated UID, same convention as collab buckets. $uid = bin2hex(random_bytes(8)); $relayUrl = rtrim($r['url'], '/') . '/api/chat/rooms'; $ch = curl_init($relayUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['uid' => $uid, 'name' => $name, 'members' => $members]), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { $err = json_decode($response, true); $detail = $err['error'] ?? "relay HTTP $status"; // 404 on this path almost always means the relay is running a // pre-group-chat build. Surface that explicitly so the user // doesn't go hunting for data issues. if ($status === 404) { $detail = "relay does not know /api/chat/rooms — it's likely running old code. Restart the relay after pulling the latest commits."; } chatJson(['ok' => false, 'error' => $detail, 'url' => $relayUrl, 'relay_status' => $status]); } $data = json_decode($response, true) ?: []; // Mirror locally right away so the next conversations list shows // the room without waiting for a poll. $db = Database::connect(); $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, datetime("now"), datetime("now"))') ->execute(['u' => $uid, 'n' => $name, 'o' => $r['user']]); $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); $insMem = $db->prepare('INSERT INTO chat_room_members (room_uid, username) VALUES (:u, :un)'); foreach (($data['members'] ?? []) as $mu) { $insMem->execute(['u' => $uid, 'un' => $mu]); } chatJson(['ok' => true, 'uid' => $uid, 'members' => $data['members'] ?? []]); } if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') { $uid = $m[1]; $input = json_decode(file_get_contents('php://input'), true) ?: []; $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400); $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_POSTFIELDS => json_encode($input), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // The next poll will refresh the local room mirror with the new // name / member list. No need to replay the update here. chatJson(json_decode($response, true) ?: ['ok' => $status === 200]); } if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { $uid = $m[1]; $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400); $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status === 200) { $db = Database::connect(); $db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]); $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); } chatJson(json_decode($response, true) ?: ['ok' => $status === 200]); } // ── Backfill older messages for a thread (scrollback / cold start) ── if ($route === '/api/chat/backfill' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); $roomUid = trim((string)($input['room_uid'] ?? '')); $before = (int)($input['before'] ?? 0); $limit = max(1, min(500, (int)($input['limit'] ?? 100))); if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400); $r = chatRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400); $qs = http_build_query(array_filter([ 'room_uid' => $roomUid ?: null, 'peer' => $peer ?: null, 'before' => $before > 0 ? $before : null, 'limit' => $limit, ], fn($v) => $v !== null)); $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/backfill?' . $qs); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']], CURLOPT_TIMEOUT => 15, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { $err = json_decode($response, true); chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]); } $data = json_decode($response, true) ?: []; $messages = $data['messages'] ?? []; $db = Database::connect(); $newCount = 0; $insert = $db->prepare(' INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, room_uid) VALUES (:p, :dir, :b, :rid, :ts, 1, :ru) '); foreach ($messages as $m) { $plain = chatDecrypt($m['body'] ?? '', $r['key']); if ($plain === '') continue; $insert->execute([ 'p' => $m['sender_username'] ?? '?', 'dir' => !empty($m['mine']) ? 'out' : 'in', 'b' => $plain, 'rid' => (int)$m['id'], 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'), 'ru' => !empty($m['room_uid']) ? $m['room_uid'] : null, ]); if ($insert->rowCount() > 0) $newCount++; } chatJson(['ok' => true, 'backfilled' => $newCount]); } // ── Per-thread settings (currently just the disposable flag) ── if ($route === '/api/chat/thread-settings' && $method === 'GET') { $peer = trim((string)($_GET['peer'] ?? '')); if ($peer === '') chatJson(['error' => 'peer required'], 400); $db = Database::connect(); $stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p'); $stmt->execute(['p' => $peer]); $disposable = (int)($stmt->fetchColumn() ?: 0); chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]); } if ($route === '/api/chat/thread-settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); $disposable = !empty($input['disposable']) ? 1 : 0; if ($peer === '') chatJson(['error' => 'peer required'], 400); $db = Database::connect(); $db->prepare('INSERT OR REPLACE INTO chat_thread_settings (peer, disposable) VALUES (:p, :d)') ->execute(['p' => $peer, 'd' => $disposable]); chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]); } });