N) // GET /api/chat/sent?since=N — pull state updates (delivered/seen) for // *my* sent messages with id > N. The // sender uses this to learn read receipts // without needing a backchannel. // POST /api/chat/seen {peer} — mark every message FROM peer TO me as // seen. The sender's next /api/chat/sent // poll picks the new state up. // // Same decrypt-reencrypt hop as messages.php: arrives encrypted with the // senders client secret, stored at-rest under the inter-tenant key, returned // re-encrypted with the recipients client secret. Relay sees plaintext only // during the routing hop. if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } // $clientTenant is set by messages.php earlier in the chain via authClient(). // We re-resolve here to keep this file self-contained. $clientTenant = $clientTenant ?? authClient($db); 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'] ?? '')); 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 (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400); } $senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500); $plain = clientDecrypt($encryptedBody, $senderSecret); 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, ]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/chat' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); } $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]); $rows = $stmt->fetchAll(); $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); $out = []; $maxId = $since; foreach ($rows as $row) { $plain = clientDecrypt($row['body'] ?? '', $interKey); $row['body'] = clientEncrypt($plain, $recipientSecret); $out[] = $row; 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. if ($out) { $db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0') ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); } jsonResponse(['messages' => $out, 'max_id' => $maxId]); } // ── 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 // plaintext locally; the relay only echoes the state flags so the sender // can render check / double-check on the right messages. if ($route === '/api/chat/sent' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $since = (int)($_GET['since'] ?? 0); $stmt = $db->prepare(' SELECT id, recipient_id, fetched, seen, created_at FROM chat_messages WHERE sender_id = :s AND id > :since ORDER BY id ASC LIMIT 1000 '); $stmt->execute(['s' => $clientTenant['id'], 'since' => $since]); $rows = $stmt->fetchAll(); $maxId = $since; foreach ($rows as $r) { if ((int)$r['id'] > $maxId) $maxId = (int)$r['id']; } jsonResponse(['receipts' => $rows, 'max_id' => $maxId]); } // ── POST /api/chat/seen ──────────────────────────────────────── // Body: { peer: "" }. Marks every message that the // peer sent to the caller as seen=1. Idempotent — safe to call on // every thread open. if ($route === '/api/chat/seen' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); if ($peer === '') jsonResponse(['error' => 'peer required'], 400); $db->prepare(' UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_username = :p AND seen = 0 ')->execute(['r' => $clientTenant['id'], 'p' => $peer]); jsonResponse(['ok' => true]); }