pull deltas since a timestamp // DELETE /api/collab-cards/{bucket}/{card} tombstone a card // // Encryption hop (same pattern as chat.php): // POST: arrives encrypted with the sender's client secret → relay // decrypts with that secret (via masterDecrypt of their // secret_encrypted) → re-encrypts under the inter-tenant key → // stored at rest. // GET: for each row visible to the caller, decrypt from inter-tenant // key → re-encrypt with the recipient's client secret → return. // // Membership check: every operation requires the caller to be a current // member of the bucket_uid referenced. Drops silently when the caller // leaves or is kicked. if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } $clientTenant = $clientTenant ?? authClient($db); /** Is $tenantId a member of $bucketUid right now? */ function collabCardsIsMember(PDO $db, string $bucketUid, int $tenantId): bool { $stmt = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t'); $stmt->execute(['b' => $bucketUid, 't' => $tenantId]); return (bool)$stmt->fetchColumn(); } /** If a foreign tenant currently holds a non-expired lock on this card, * return the lock row; otherwise null. Used to gate writes / deletes. */ function collabCardsForeignLock(PDO $db, string $bucketUid, string $cardUid, int $tenantId): ?array { $stmt = $db->prepare(" SELECT holder_id, holder_username, expires_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND holder_id != :t AND expires_at > datetime('now') "); $stmt->execute(['b' => $bucketUid, 'c' => $cardUid, 't' => $tenantId]); $row = $stmt->fetch(); return $row ?: null; } // ── POST: upsert a card ────────────────────────────────────────── if ($route === '/api/collab-cards' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (isRateLimited($db, 'collab-cards-post', 240)) jsonResponse(['error' => 'rate limited'], 429); $input = json_decode(file_get_contents('php://input'), true) ?: []; $bucketUid = trim((string)($input['bucket_uid'] ?? '')); $cardUid = trim((string)($input['card_uid'] ?? '')); $encEnvelope = (string)($input['payload'] ?? ''); if ($bucketUid === '' || $cardUid === '' || $encEnvelope === '') { jsonResponse(['error' => 'bucket_uid, card_uid, payload required'], 400); } if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) { jsonResponse(['error' => 'not a member of this bucket'], 403); } // Exclusive lock enforcement: if another tenant holds the lock, // refuse the write. Plugin retries on the next poll until the lock // expires or is released. The local edit is preserved either way. $foreign = collabCardsForeignLock($db, $bucketUid, $cardUid, (int)$clientTenant['id']); if ($foreign) { jsonResponse([ 'error' => 'card is being edited by ' . $foreign['holder_username'], 'locked_by' => $foreign['holder_username'], ], 423); } 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($encEnvelope, $senderSecret); if ($plain === '') jsonResponse(['error' => 'payload decrypt failed (sender secret mismatch?)'], 400); $stored = clientEncrypt($plain, $interKey); // LWW: upsert with server-generated updated_at so clients all agree on // the authoritative order (client clocks drift, server clock is the tie- // breaker). Last writer wins. $db->prepare(' INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted) VALUES (:b, :c, :a, :p, datetime(\'now\'), 0) ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET author_username = excluded.author_username, payload = excluded.payload, updated_at = excluded.updated_at, deleted = 0 ')->execute([ 'b' => $bucketUid, 'c' => $cardUid, 'a' => $clientTenant['username'], 'p' => $stored, ]); // Return the row's updated_at so the client can track what it pushed $row = $db->prepare('SELECT updated_at FROM collab_cards WHERE bucket_uid = :b AND card_uid = :c'); $row->execute(['b' => $bucketUid, 'c' => $cardUid]); jsonResponse(['ok' => true, 'updated_at' => $row->fetchColumn()]); } // ── GET: pull deltas since a given timestamp ───────────────────── // ── Session-auth summary for the relay UI ───────────────────── // Lets the relay's web UI surface collab bucket activity to the // logged-in tenant without exposing the per-client encryption dance: // decrypts payloads with the inter-tenant key (we're authorizing via // the PHP session, so there's no per-request client secret). Returns // just title + small body preview + author + bucket so the UI can list // recent activity like an inbox. if ($route === '/api/collab-inbox' && $method === 'GET' && $sessionTenant) { $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $stmt = $db->prepare(' SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted, b.name AS bucket_name, b.color AS bucket_color FROM collab_cards c INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid LEFT JOIN collab_buckets b ON b.uid = c.bucket_uid WHERE m.tenant_id = :tid AND c.deleted = 0 ORDER BY c.updated_at DESC LIMIT 200 '); $stmt->execute(['tid' => $sessionTenant['id']]); $out = []; foreach ($stmt->fetchAll() as $row) { $plain = clientDecrypt($row['payload'] ?? '', $interKey); $fields = $plain ? (json_decode($plain, true) ?: []) : []; $body = (string)($fields['body'] ?? ''); $out[] = [ 'bucket_uid' => $row['bucket_uid'], 'bucket_name' => $row['bucket_name'], 'bucket_color' => $row['bucket_color'] ?? '#38bdf8', 'card_uid' => $row['card_uid'], 'author_username' => $row['author_username'], 'title' => (string)($fields['title'] ?? ''), 'preview' => mb_substr(trim(strip_tags($body)), 0, 140), 'updated_at' => $row['updated_at'], 'mine' => $row['author_username'] === $sessionTenant['username'], ]; } jsonResponse(['ok' => true, 'items' => $out]); } if ($route === '/api/collab-cards' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (isRateLimited($db, 'collab-cards-get', 120)) jsonResponse(['error' => 'rate limited'], 429); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); } $since = trim((string)($_GET['since'] ?? '')); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); // Join to membership so only buckets the caller is in right now are returned $sql = ' SELECT c.bucket_uid, c.card_uid, c.author_username, c.payload, c.updated_at, c.deleted FROM collab_cards c INNER JOIN collab_bucket_members m ON m.bucket_uid = c.bucket_uid WHERE m.tenant_id = :tid '; $params = ['tid' => $clientTenant['id']]; if ($since !== '') { $sql .= ' AND c.updated_at > :since'; $params['since'] = $since; } $sql .= ' ORDER BY c.updated_at ASC LIMIT 1000'; $stmt = $db->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll(); $out = []; $maxTs = $since; foreach ($rows as $row) { // Tombstones don't need payload — they just signal "delete locally" if ((int)$row['deleted'] === 1) { $out[] = [ 'bucket_uid' => $row['bucket_uid'], 'card_uid' => $row['card_uid'], 'author_username' => $row['author_username'], 'updated_at' => $row['updated_at'], 'deleted' => true, ]; } else { $plain = clientDecrypt($row['payload'] ?? '', $interKey); $reEncrypted = clientEncrypt($plain, $recipientSecret); $out[] = [ 'bucket_uid' => $row['bucket_uid'], 'card_uid' => $row['card_uid'], 'author_username' => $row['author_username'], 'payload' => $reEncrypted, 'updated_at' => $row['updated_at'], 'deleted' => false, ]; } if ($row['updated_at'] > $maxTs) $maxTs = $row['updated_at']; } jsonResponse(['ok' => true, 'cards' => $out, 'max_updated_at' => $maxTs]); } // ── DELETE: tombstone a card ───────────────────────────────────── if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $bucketUid = $m[1]; $cardUid = $m[2]; if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) { jsonResponse(['error' => 'not a member of this bucket'], 403); } // Same lock enforcement on delete: if another user has the editor // open on this card, deleting it under their feet would be rude. $foreign = collabCardsForeignLock($db, $bucketUid, $cardUid, (int)$clientTenant['id']); if ($foreign) { jsonResponse([ 'error' => 'card is being edited by ' . $foreign['holder_username'], 'locked_by' => $foreign['holder_username'], ], 423); } // Mark as tombstone rather than actually deleting. Late pollers see it, // bootstrap.php cleanup prunes after 7 days. $db->prepare(' INSERT INTO collab_cards (bucket_uid, card_uid, author_username, payload, updated_at, deleted) VALUES (:b, :c, :a, \'\', datetime(\'now\'), 1) ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET author_username = excluded.author_username, payload = \'\', updated_at = datetime(\'now\'), deleted = 1 ')->execute([ 'b' => $bucketUid, 'c' => $cardUid, 'a' => $clientTenant['username'], ]); jsonResponse(['ok' => true]); } // ── Collab attachments (images/PDFs/audio referenced by collab cards) ─ // Attachment filenames always follow the hyprcrd upload pattern // (bin2hex(random_bytes(16)).). Enforce server-side so malicious // clients can't poison the DB with traversal-shaped names that later // surface in UIs or get forwarded to filesystem APIs. function collabAttachmentFilenameValid(string $fn): bool { return (bool)preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $fn); } if ($route === '/api/collab-attachments' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $bucketUid = (string)($input['bucket_uid'] ?? ''); $filename = (string)($input['filename'] ?? ''); $data = (string)($input['data'] ?? ''); if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400); if (!collabAttachmentFilenameValid($filename)) jsonResponse(['error' => 'invalid filename'], 400); if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) { jsonResponse(['error' => 'not a member of this bucket'], 403); } // Decrypt-reencrypt hop: data arrives encrypted with sender's secret, // stored at rest under the inter-tenant key. if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'sender has no client secret'], 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'], 500); $plain = clientDecrypt($data, $senderSecret); if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400); $stored = clientEncrypt($plain, $interKey); $db->prepare('INSERT INTO collab_attachments (bucket_uid, filename, data) VALUES (:b, :f, :d) ON CONFLICT(bucket_uid, filename) DO UPDATE SET data = excluded.data') ->execute(['b' => $bucketUid, 'f' => $filename, 'd' => $stored]); jsonResponse(['ok' => true]); } if ($route === '/api/collab-attachments' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $bucketUid = trim($_GET['bucket_uid'] ?? ''); $filename = trim($_GET['filename'] ?? ''); if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400); if ($filename !== '' && !collabAttachmentFilenameValid($filename)) jsonResponse(['error' => 'invalid filename'], 400); if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) { jsonResponse(['error' => 'not a member of this bucket'], 403); } if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'no client secret set'], 400); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); if ($filename) { $stmt = $db->prepare('SELECT filename, data FROM collab_attachments WHERE bucket_uid = :b AND filename = :f'); $stmt->execute(['b' => $bucketUid, 'f' => $filename]); } else { // List all filenames for this bucket (no data — client picks what to fetch) $stmt = $db->prepare('SELECT filename FROM collab_attachments WHERE bucket_uid = :b ORDER BY created_at DESC'); $stmt->execute(['b' => $bucketUid]); jsonResponse(['ok' => true, 'files' => array_column($stmt->fetchAll(), 'filename')]); } $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $plain = clientDecrypt($row['data'] ?? '', $interKey); $out[] = ['filename' => $row['filename'], 'data' => clientEncrypt($plain, $recipientSecret)]; } jsonResponse(['ok' => true, 'attachments' => $out]); }