create on relay + mirror local * PATCH /api/collab-buckets/{uid} {name?, color?, members?} -> push to relay * DELETE /api/collab-buckets/{uid} owner: delete; member: leave * POST /api/collab-buckets/poll full re-sync from relay * POST /api/collab-buckets/mark-seen {uid} -> reset dot/pulse state * GET /api/collab-buckets/relay-tenants proxy to relay /api/tenants for the member-picker * * Phase 1+2: bucket metadata + membership only. Card sync lands in Phase 3. */ (function () { $db = Database::connect(); $db->exec(" CREATE TABLE IF NOT EXISTS collab_buckets ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE NOT NULL, name TEXT NOT NULL, color TEXT NOT NULL DEFAULT '#38bdf8', owner_username TEXT NOT NULL, members_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at TEXT ) "); $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_buckets_uid ON collab_buckets(uid)"); // ── Phase 3 schema: card sync tracking ─────────────────────── // Per-card state: which relay uid and bucket uid the card maps to, plus // the last time we pushed it and the last remote updated_at we applied. // `last_push_at` is compared against `cards.updated_at` to find dirty // cards that need a push. On apply, `last_push_at` is bumped to the // remote's updated_at so we don't re-push what we just received. $db->exec(" CREATE TABLE IF NOT EXISTS collab_card_state ( card_id INTEGER PRIMARY KEY, relay_uid TEXT NOT NULL, bucket_uid TEXT NOT NULL, last_push_at TEXT, last_remote_at TEXT, last_seen_at TEXT, FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE ) "); $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)"); // Per-card unread tracking (added 2026-04-14). Existing deployments need // the column added explicitly. $ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name'); if (!in_array('last_seen_at', $ccsCols)) { $db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT"); // Backfill: everything existing gets seen=now so we don't flood the // user with a board full of red dots after they deploy this. $db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')"); } // Tombstone outbox: populated by a BEFORE DELETE trigger on the cards // table. The poll's push phase drains it and sends each entry as a // DELETE to the relay. // // The trigger fires unconditionally on any collab-card delete, including // deletions caused by *applying a remote tombstone*. That would loop the // tombstone back to the relay pointlessly. To break the loop without // needing a temp-table suppress flag (SQLite forbids triggers on // permanent tables from referencing `temp.*` objects), the apply phase // snapshots MAX(id) on `collab_tombstones` before it starts and deletes // every row with id > that marker after it finishes — i.e. everything // that the apply phase itself caused. $db->exec(" CREATE TABLE IF NOT EXISTS collab_tombstones ( id INTEGER PRIMARY KEY AUTOINCREMENT, relay_uid TEXT NOT NULL, bucket_uid TEXT NOT NULL, queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) "); // Phase 5: create-outbox for buckets. When the user creates (or // upgrades) a collab bucket but the relay is unreachable, the local // row is created anyway and an entry is queued here. The poll drains // it on every tick by retrying the upgrade. $db->exec(" CREATE TABLE IF NOT EXISTS collab_bucket_outbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, bucket_id INTEGER NOT NULL, members_json TEXT NOT NULL DEFAULT '[]', queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) "); $db->exec(" CREATE TRIGGER IF NOT EXISTS collab_card_delete_tombstone BEFORE DELETE ON cards WHEN OLD.bucket_id IS NOT NULL AND OLD.relay_uid IS NOT NULL BEGIN INSERT INTO collab_tombstones (relay_uid, bucket_uid) SELECT OLD.relay_uid, b.collab_uid FROM buckets b WHERE b.id = OLD.bucket_id AND b.collab_uid IS NOT NULL; END "); })(); /** Encrypt a JSON payload with the local relay_key using the shared * dual-format envelope (GCM-V2 writes, V1 read-only fallback lives in * hyprcrdRelayDecrypt). */ function collabEncryptPayload(array $fields): ?string { $key = collabRelaySettings()['key'] ?? ''; if ($key === '') return null; $plain = json_encode($fields); if ($plain === false) return null; return hyprcrdRelayEncrypt($plain, $key); } /** Decrypt the inverse. Returns an associative array, or null on failure. */ function collabDecryptPayload(string $envelope): ?array { if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) return null; $key = collabRelaySettings()['key'] ?? ''; if ($key === '') return null; $plain = hyprcrdRelayDecrypt($envelope, $key); if ($plain === '') return null; $data = json_decode($plain, true); return is_array($data) ? $data : null; } function collabJson($data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; } function collabRelaySettings(): 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() ?: '', ]; } function collabRelayCall(string $path, string $method = 'GET', $body = null): array { $r = collabRelaySettings(); if (!$r['url'] || !$r['user'] || !$r['key']) { return ['ok' => false, 'error' => 'relay not configured (set URL, username, and secret in settings)']; } $url = rtrim($r['url'], '/') . $path; $ch = curl_init($url); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key'], ], CURLOPT_TIMEOUT => 10, ]; if ($body !== null) $opts[CURLOPT_POSTFIELDS] = json_encode($body); curl_setopt_array($ch, $opts); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { $decoded = json_decode($response ?: '', true); return [ 'ok' => false, 'status' => $status, 'error' => is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : "HTTP $status", 'url' => $url, ]; } $decoded = json_decode($response ?: '', true); return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'invalid relay response']; } /** Serialize a local bucket row for the frontend. */ function collabShapeLocalBucket(array $r): array { $members = json_decode($r['members_json'] ?? '[]', true) ?: []; return [ 'uid' => $r['uid'], 'name' => $r['name'], 'color' => $r['color'], 'owner_username' => $r['owner_username'], 'members' => $members, 'created_at' => $r['created_at'], 'updated_at' => $r['updated_at'], 'last_seen_at' => $r['last_seen_at'], // A bucket has unread state if it was never opened OR if remote // updated it after the last time we opened it. 'has_unread' => $r['last_seen_at'] === null || $r['updated_at'] > $r['last_seen_at'], ]; } /** Upsert the corresponding row in the main `buckets` table so the collab * bucket shows up in the standard bucket bar. Returns the main bucket id. */ function collabUpsertMainBucket(string $uid, string $name, string $color): int { $db = Database::connect(); $stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u'); $stmt->execute(['u' => $uid]); $existing = $stmt->fetchColumn(); if ($existing) { $db->prepare('UPDATE buckets SET name = :n, color = :c WHERE id = :id') ->execute(['n' => $name, 'c' => $color, 'id' => $existing]); return (int)$existing; } // Put new collab buckets at the end of the bar $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn(); $db->prepare('INSERT INTO buckets (name, color, collab_uid, position) VALUES (:n, :c, :u, :p)') ->execute(['n' => $name, 'c' => $color, 'u' => $uid, 'p' => $maxPos + 1]); return (int)$db->lastInsertId(); } /** Delete the main `buckets` row for a collab bucket (if any) and unlink its * cards. Uses the same ON DELETE SET NULL semantics as the regular delete path. */ function collabDeleteMainBucket(string $uid): void { $db = Database::connect(); $stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u'); $stmt->execute(['u' => $uid]); $bid = $stmt->fetchColumn(); if (!$bid) return; $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $bid]); $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $bid]); } /** * Shared upgrade path: given a local buckets row, generate a fresh uid, * push the create to the relay, then (on success) rewrite the local * collab_uid and install the plugin mirror row. Used by both the * "upgrade existing bucket" route AND the new outbox drain that retries * failed creates. * * Returns an array with: * ok=true + uid on success * ok=false + error on relay failure — the caller decides * whether to queue a retry in the outbox * ok=true + already=true if the bucket already has a collab_uid */ function collabAttemptUpgrade(int $bucketId, array $members): array { $db = Database::connect(); $stmt = $db->prepare('SELECT id, name, color, collab_uid FROM buckets WHERE id = :id'); $stmt->execute(['id' => $bucketId]); $bucket = $stmt->fetch(); if (!$bucket) return ['ok' => false, 'error' => 'bucket not found']; if (!empty($bucket['collab_uid'])) { return ['ok' => true, 'already' => true, 'uid' => $bucket['collab_uid']]; } $uid = bin2hex(random_bytes(8)); $res = collabRelayCall('/api/collab-buckets', 'POST', [ 'uid' => $uid, 'name' => $bucket['name'], 'color' => $bucket['color'], 'members' => $members, ]); if (!($res['ok'] ?? false)) { return ['ok' => false, 'error' => $res['error'] ?? 'relay create failed']; } $db->prepare('UPDATE buckets SET collab_uid = :u WHERE id = :id') ->execute(['u' => $uid, 'id' => $bucketId]); $now = date('Y-m-d H:i:s'); $self = collabRelaySettings()['user']; $resolvedMembers = $res['members'] ?? $members; $db->prepare(' INSERT INTO collab_buckets (uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at) VALUES (:uid, :n, :c, :ou, :m, :ca, :ua, :ls) ON CONFLICT(uid) DO UPDATE SET name = excluded.name, color = excluded.color, owner_username = excluded.owner_username, members_json = excluded.members_json, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at ')->execute([ 'uid' => $uid, 'n' => $bucket['name'], 'c' => $bucket['color'], 'ou' => $self, 'm' => json_encode($resolvedMembers), 'ca' => $now, 'ua' => $now, 'ls' => $now, ]); return ['ok' => true, 'uid' => $uid]; } /** Find or create a local-only "Conflicts" bucket where LWW losers get * stashed. Never gets a collab_uid so it doesn't sync anywhere. */ function collabFindOrCreateConflictsBucket(): int { $db = Database::connect(); $stmt = $db->prepare("SELECT id FROM buckets WHERE name = 'Conflicts' AND collab_uid IS NULL"); $stmt->execute(); $id = $stmt->fetchColumn(); if ($id) return (int)$id; $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn(); $db->prepare("INSERT INTO buckets (name, color, position) VALUES ('Conflicts', '#fb7185', :p)") ->execute(['p' => $maxPos + 1]); return (int)$db->lastInsertId(); } /** Duplicate a card into the Conflicts bucket with a title marker, so the * local user can see what they lost before the remote overwrite applied. * Called from the apply phase when LWW is about to clobber unsynced local * edits (local.updated_at > last_push_at && remote.updated_at > local). */ function collabStashConflict(PDO $db, array $local, string $remoteAuthor): void { $bid = collabFindOrCreateConflictsBucket(); $origTitle = (string)($local['title'] ?? ''); $newTitle = '(conflict' . ($remoteAuthor !== '' ? ' from ' . $remoteAuthor : '') . ') ' . $origTitle; $db->prepare(" INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, created_at, updated_at) VALUES (:t, :b, :c, :tg, :p, :d, :bid, datetime('now'), datetime('now')) ")->execute([ 't' => substr($newTitle, 0, 400), 'b' => $local['body'] ?? '', 'c' => $local['color'] ?? '#fb7185', 'tg' => $local['tags'] ?? '', 'p' => (int)($local['position'] ?? 0), 'd' => $local['due_date'] ?? null, 'bid' => $bid, ]); } /** Build the card payload we send to the relay. Fields that don't matter * cross-user (parent_id for groups, caldav_*, the autoinc id) are omitted. */ function collabBuildCardPayload(array $card): array { return [ 'title' => $card['title'] ?? '', 'body' => $card['body'] ?? '', 'color' => $card['color'] ?? '#38bdf8', 'tags' => $card['tags'] ?? '', 'position' => (int)($card['position'] ?? 0), 'due_date' => $card['due_date'] ?? null, 'updated_at' => $card['updated_at'] ?? null, ]; } /** * Phase 3 sync: push local changes + pull remote deltas for all collab * buckets. Returns counters for the response body. * * Order matters: * 1. Drain tombstones — pushes delete-your-card notifications before we * pull, otherwise we might immediately re-apply a remote version of * the just-deleted card. * 2. Push dirty cards — `cards.updated_at > collab_card_state.last_push_at` * means the user edited since we last pushed. Encrypt, POST, stamp * last_push_at so the next poll skips. * 3. Pull deltas — since the highest `last_remote_at` we have, apply LWW * to local rows. The suppress temp-table flag keeps the BEFORE DELETE * trigger from re-queueing remotely-triggered deletes. */ function collabSyncCards(PDO $db): array { $relay = collabRelaySettings(); $stats = ['pushed' => 0, 'pulled' => 0, 'deleted_local' => 0, 'tombstones_sent' => 0, 'conflicts' => 0, 'moved' => 0, 'blocked' => 0, 'blocked_by' => []]; if (!$relay['url'] || !$relay['user'] || !$relay['key']) return $stats; // ── 1. Drain tombstones ──────────────────────────────── $tombRows = $db->query('SELECT id, relay_uid, bucket_uid FROM collab_tombstones ORDER BY id ASC')->fetchAll(); foreach ($tombRows as $t) { $res = collabRelayCall( '/api/collab-cards/' . urlencode($t['bucket_uid']) . '/' . urlencode($t['relay_uid']), 'DELETE' ); if (($res['ok'] ?? false)) { $db->prepare('DELETE FROM collab_tombstones WHERE id = :id')->execute(['id' => $t['id']]); $stats['tombstones_sent']++; } else if (($res['status'] ?? 0) === 423) { // Lock holder is editing — don't delete under their feet. // Tombstone stays queued; next poll retries after release. $stats['blocked']++; $by = $res['locked_by'] ?? ''; if ($by !== '' && !in_array($by, $stats['blocked_by'], true)) $stats['blocked_by'][] = $by; } // If a tombstone push fails for any other reason it stays queued // and gets retried next poll. } // ── 1b. Detect moved cards ───────────────────────────── // A card previously tracked in collab_card_state under bucket_uid X can // now be in (a) no bucket, (b) a local bucket without collab_uid, or // (c) a different collab bucket. In all three cases we need to push a // tombstone to the original bucket so other members stop seeing the // stale copy. If the card is still a collab card in a new bucket, the // normal push phase below picks it up and pushes under the new uid. $movedRows = $db->query(" SELECT cs.card_id, cs.relay_uid, cs.bucket_uid AS old_bucket_uid, c.id IS NULL AS card_missing, b.collab_uid AS new_bucket_uid FROM collab_card_state cs LEFT JOIN cards c ON c.id = cs.card_id LEFT JOIN buckets b ON b.id = c.bucket_id WHERE c.id IS NULL OR b.collab_uid IS NULL OR b.collab_uid != cs.bucket_uid ")->fetchAll(); foreach ($movedRows as $mv) { // Tombstone on the OLD bucket — members of that bucket should stop // seeing this card regardless of where it went. $res = collabRelayCall( '/api/collab-cards/' . urlencode($mv['old_bucket_uid']) . '/' . urlencode($mv['relay_uid']), 'DELETE' ); if (!($res['ok'] ?? false)) continue; // retry next poll $stats['moved']++; if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) { // Card is gone or moved out of collab entirely — forget about it. // Critical: also clear the card's relay_uid so the incoming // tombstone (echoed back from the relay on next pull) doesn't // find this card and delete it locally too. $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id') ->execute(['id' => $mv['card_id']]); if (!$mv['card_missing']) { $db->prepare('UPDATE cards SET relay_uid = NULL WHERE id = :id') ->execute(['id' => $mv['card_id']]); } } else { // Card moved to a different collab bucket. Rewrite state so the // push phase below picks it up under the new bucket_uid and also // resets last_push_at so the push actually fires. $db->prepare(" UPDATE collab_card_state SET bucket_uid = :b, last_push_at = NULL WHERE card_id = :id ")->execute(['b' => $mv['new_bucket_uid'], 'id' => $mv['card_id']]); } } // ── 2. Push dirty cards ──────────────────────────────── // Find every card in a collab bucket whose updated_at is newer than the // last time we pushed it (or was never pushed). $dirty = $db->query(" SELECT c.id, c.title, c.body, c.color, c.tags, c.position, c.due_date, c.updated_at, c.relay_uid, b.collab_uid AS bucket_uid, cs.last_push_at FROM cards c INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL LEFT JOIN collab_card_state cs ON cs.card_id = c.id WHERE (cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at) AND c.updated_at < datetime('now', '-2 seconds') ")->fetchAll(); $assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id'); $stateUpsert = $db->prepare(" INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at) VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2) ON CONFLICT(card_id) DO UPDATE SET relay_uid = excluded.relay_uid, bucket_uid = excluded.bucket_uid, last_push_at = excluded.last_push_at, last_seen_at = excluded.last_seen_at "); foreach ($dirty as $card) { // Ensure the card has a relay_uid (cross-tenant identity) $relayUid = $card['relay_uid']; if (!$relayUid) { $relayUid = bin2hex(random_bytes(8)); $assignUid->execute(['u' => $relayUid, 'id' => $card['id']]); } $payload = collabEncryptPayload(collabBuildCardPayload($card)); if ($payload === null) continue; // no relay_key or openssl failure // Attachments FIRST, card second. If a peer polls between the two // HTTP calls, they'd see the new card body with an `uploads/xyz.png` // reference but the attachment wouldn't be on the relay yet — their // fetch returns empty and the image renders broken permanently // (the pull short-circuits on the card's next poll because // updated_at doesn't change). Pushing attachments first guarantees // the relay's state is consistent: if the card is visible, its // images are fetchable. $body = $card['body'] ?? ''; if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) { foreach (array_unique($fm[1]) as $fn) { $localPath = UPLOAD_DIR . '/' . $fn; if (!is_file($localPath)) continue; $bytes = file_get_contents($localPath); if ($bytes === false) continue; $encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]); if ($encoded === null) continue; collabRelayCall('/api/collab-attachments', 'POST', [ 'bucket_uid' => $card['bucket_uid'], 'filename' => $fn, 'data' => $encoded, ]); } } $res = collabRelayCall('/api/collab-cards', 'POST', [ 'bucket_uid' => $card['bucket_uid'], 'card_uid' => $relayUid, 'payload' => $payload, ]); if (!($res['ok'] ?? false)) { // 423 = relay refused because someone else holds an exclusive // lock. Don't bump last_push_at — keep the card dirty so we // retry on the next poll. Surface the holder name so the // frontend can toast. if (($res['status'] ?? 0) === 423) { $stats['blocked']++; $by = $res['locked_by'] ?? ''; if ($by !== '' && !in_array($by, $stats['blocked_by'], true)) $stats['blocked_by'][] = $by; } continue; } $stateUpsert->execute([ 'cid' => $card['id'], 'cid2' => $card['id'], 'ru' => $relayUid, 'bu' => $card['bucket_uid'], 'push' => $res['updated_at'] ?? $card['updated_at'], 'push2'=> $res['updated_at'] ?? $card['updated_at'], ]); $stats['pushed']++; } // ── 3. Pull deltas ───────────────────────────────────── $since = $db->query("SELECT value FROM settings WHERE key = 'collab_cards_last_remote_at'")->fetchColumn(); if (!$since) $since = ''; $qs = $since !== '' ? ('?since=' . urlencode($since)) : ''; $res = collabRelayCall('/api/collab-cards' . $qs); if (!($res['ok'] ?? false)) return $stats; $incoming = $res['cards'] ?? []; $maxTs = $res['max_updated_at'] ?? $since; if (!$incoming) { if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs); return $stats; } // The delete trigger fires unconditionally on collab-card deletes, // including the ones the apply phase itself causes. To avoid looping // the delete back to the relay we snapshot the highest tombstone id // before apply and purge everything > that marker afterwards. $tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn(); // Buckets that received changes from a foreign author. Used at the end // of the apply phase to bump collab_buckets.updated_at, which the // frontend's has_unread calculation reads — that's how a different // bucket-bar tab gets the unread dot + 3-pulse when a peer pushes a // card while the user is looking at something else. $selfUser = collabRelaySettings()['user']; $unreadBuckets = []; try { foreach ($incoming as $c) { // Resolve local bucket id $bid = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u'); $bid->execute(['u' => $c['bucket_uid']]); $localBucketId = $bid->fetchColumn(); if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen $foreignAuthor = ($c['author_username'] ?? '') !== $selfUser; // Find local card by relay_uid — grab all fields so we can // stash a conflict copy if needed before overwriting $find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u'); $find->execute(['u' => $c['card_uid']]); $local = $find->fetch(); if (!empty($c['deleted'])) { if ($local) { $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]); $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]); $stats['deleted_local']++; if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; } continue; } // Decrypt payload $fields = collabDecryptPayload($c['payload'] ?? ''); if ($fields === null) continue; // Fetch referenced attachments BEFORE applying the body so that // the moment state.cards reflects the new body (via _reloadAll), // every `uploads/xxx.png` reference is already on disk. Without // this, a user who opens the card preview between apply and // fetch would get a broken-image render — and the browser's // 404 response can stick around in the page's image cache even // after the file finally lands. $pulledBody = $fields['body'] ?? ''; if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) { $uploadDir = UPLOAD_DIR; if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true); foreach (array_unique($fm[1]) as $fn) { $dest = $uploadDir . '/' . $fn; if (file_exists($dest)) continue; $attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn)); if (!($attRes['ok'] ?? false)) continue; foreach (($attRes['attachments'] ?? []) as $a) { if (($a['filename'] ?? '') !== $fn) continue; $decoded = collabDecryptPayload($a['data'] ?? ''); if ($decoded && isset($decoded['raw'])) { $bytes = base64_decode($decoded['raw']); if ($bytes !== false) file_put_contents($dest, $bytes); } } } } if ($local) { // LWW: only accept remote if it's strictly newer than local if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue; // Conflict detection: if the local card has local edits that // were never pushed (local.updated_at > last_push_at), we're // about to clobber them. Stash a copy into the local-only // Conflicts bucket before overwriting. $cs = $db->prepare('SELECT last_push_at FROM collab_card_state WHERE card_id = :id'); $cs->execute(['id' => $local['id']]); $lastPushAt = $cs->fetchColumn(); if ($lastPushAt === false) $lastPushAt = null; $hasUnpushed = $lastPushAt === null ? false // first apply of a remote card — no "local edits" yet : ($local['updated_at'] ?? '') > $lastPushAt; if ($hasUnpushed) { collabStashConflict($db, $local, (string)($c['author_username'] ?? '')); $stats['conflicts']++; } $db->prepare(" UPDATE cards SET title = :t, body = :b, color = :c, tags = :tg, position = :p, due_date = :d, bucket_id = :bid, updated_at = :ua WHERE id = :id ")->execute([ 't' => $fields['title'] ?? '', 'b' => $fields['body'] ?? '', 'c' => $fields['color'] ?? '#38bdf8', 'tg' => $fields['tags'] ?? '', 'p' => (int)($fields['position'] ?? 0), 'd' => $fields['due_date'] ?? null, 'bid' => $localBucketId, 'ua' => $c['updated_at'], 'id' => $local['id'], ]); $localId = (int)$local['id']; } else { // New card $db->prepare(" INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, relay_uid, created_at, updated_at) VALUES (:t, :b, :c, :tg, :p, :d, :bid, :u, :ca, :ua) ")->execute([ 't' => $fields['title'] ?? '', 'b' => $fields['body'] ?? '', 'c' => $fields['color'] ?? '#38bdf8', 'tg' => $fields['tags'] ?? '', 'p' => (int)($fields['position'] ?? 0), 'd' => $fields['due_date'] ?? null, 'bid' => $localBucketId, 'u' => $c['card_uid'], 'ca' => $c['updated_at'], 'ua' => $c['updated_at'], ]); $localId = (int)$db->lastInsertId(); } // Record the remote state so we don't push it back on the next // poll. last_seen_at semantics: self-authored updates (our own // echo) mark as seen; foreign-authored updates leave last_seen_at // untouched so the red dot fires on the board. $seenAt = $foreignAuthor ? null : $c['updated_at']; $db->prepare(" INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at) VALUES (:cid, :ru, :bu, :p, :r, :s) ON CONFLICT(card_id) DO UPDATE SET relay_uid = excluded.relay_uid, bucket_uid = excluded.bucket_uid, last_push_at = excluded.last_push_at, last_remote_at = excluded.last_remote_at, last_seen_at = CASE WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at ELSE collab_card_state.last_seen_at END ")->execute([ 'cid' => $localId, 'ru' => $c['card_uid'], 'bu' => $c['bucket_uid'], 'p' => $c['updated_at'], 'r' => $c['updated_at'], 's' => $seenAt, ]); $stats['pulled']++; if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true; // Notify the card's author that we synced it (best-effort sync // event → toast on the author's side). Only fires for cards // authored by someone else. if ($foreignAuthor && !empty($c['author_username'])) { $cardTitle = trim((string)($fields['title'] ?? '')) ?: '(untitled)'; $evRes = collabRelayCall('/api/sync-events', 'POST', [ 'recipient' => $c['author_username'], 'body' => $selfUser . ' synced: ' . $cardTitle, ]); @error_log('[hyprcrd] sync-event POST → ' . $c['author_username'] . ' ok=' . ($evRes['ok'] ?? 'n/a') . ' status=' . ($evRes['status'] ?? '-') . ' err=' . substr((string)($evRes['error'] ?? ''), 0, 120)); } } } finally { // Bump `collab_buckets.updated_at` for every bucket that received // changes from another user. The frontend's has_unread is // `updated_at > last_seen_at`, so this is what triggers the unread // dot + 3-pulse on the bucket-bar tab when the user is looking at // something else. Skipped for the user's own pushes since the // relay echoes those back too. if ($unreadBuckets) { $bumpStmt = $db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u"); foreach (array_keys($unreadBuckets) as $u) { $bumpStmt->execute(['u' => $u]); } } // Purge any tombstones the apply phase itself created. Tombstones // queued before apply (by actual user deletes) survive and get // drained on the next poll iteration. $db->prepare('DELETE FROM collab_tombstones WHERE id > :m')->execute(['m' => $tombstoneMark]); } if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs); // Self-heal phase: walk every card in a collab bucket and try to fetch // any `uploads/xxx.png` reference whose file isn't on disk. Covers the // historical race where a card body arrived before its attachment, as // well as any one-off fetch failure (network blip, timeout) that left // a card with a broken image reference. Normal-case cost is trivial: // one SQL, one `file_exists()` per reference, no HTTP when everything // is already in place. $missing = $db->query(" SELECT c.id, c.body, b.collab_uid AS bucket_uid FROM cards c INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL WHERE c.body LIKE '%uploads/%' ")->fetchAll(); $uploadDir = UPLOAD_DIR; if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true); foreach ($missing as $mc) { if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue; foreach (array_unique($fm[1]) as $fn) { $dest = $uploadDir . '/' . $fn; if (file_exists($dest)) continue; $attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['bucket_uid']) . '&filename=' . urlencode($fn)); if (!($attRes['ok'] ?? false)) continue; foreach (($attRes['attachments'] ?? []) as $a) { if (($a['filename'] ?? '') !== $fn) continue; $decoded = collabDecryptPayload($a['data'] ?? ''); if ($decoded && isset($decoded['raw'])) { $bytes = base64_decode($decoded['raw']); if ($bytes !== false) file_put_contents($dest, $bytes); } } } } return $stats; } /** Tiny key/value on the core `settings` table. */ function collabSaveSetting(string $key, string $value): void { Database::connect() ->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)") ->execute(['k' => $key, 'v' => $value]); } PluginManager::instance()->onApiRoute(function (string $route, string $method) { // ── List local mirror ────────────────────────────────────── if ($route === '/api/collab-buckets' && $method === 'GET') { $db = Database::connect(); $rows = $db->query(' SELECT uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at FROM collab_buckets ORDER BY updated_at DESC ')->fetchAll(); $out = array_map('collabShapeLocalBucket', $rows); collabJson(['ok' => true, 'buckets' => $out]); } // ── Create on relay, mirror local ────────────────────────── // Resilient path: always create the local bucket first so the user // can keep working even when the relay is down. Then attempt to // upgrade it via the shared helper. On relay failure we queue the // upgrade in collab_bucket_outbox and the next poll retries. if ($route === '/api/collab-buckets' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $name = trim((string)($input['name'] ?? '')); $color = (string)($input['color'] ?? '#38bdf8'); $members = is_array($input['members'] ?? null) ? $input['members'] : []; if ($name === '') collabJson(['ok' => false, 'error' => 'name required'], 400); $db = Database::connect(); $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn(); $db->prepare('INSERT INTO buckets (name, color, position) VALUES (:n, :c, :p)') ->execute(['n' => $name, 'c' => $color, 'p' => $maxPos + 1]); $bucketId = (int)$db->lastInsertId(); $up = collabAttemptUpgrade($bucketId, $members); if ($up['ok'] ?? false) { collabJson(['ok' => true, 'uid' => $up['uid'], 'bucket_id' => $bucketId]); } // Relay unreachable: queue the upgrade for retry. The bucket // already exists locally so the user sees it immediately. $db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)') ->execute(['b' => $bucketId, 'm' => json_encode($members)]); collabJson([ 'ok' => true, 'queued' => true, 'bucket_id' => $bucketId, 'error' => $up['error'] ?? 'relay unreachable — will retry on next poll', ]); } // ── Update on relay (owner-only) ─────────────────────────── if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') { $uid = $m[1]; $input = json_decode(file_get_contents('php://input'), true) ?: []; $res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'PATCH', $input); if (!($res['ok'] ?? false)) { collabJson(['ok' => false, 'error' => $res['error'] ?? 'patch failed'], 500); } // Don't mirror the patch locally — the next poll will pick up the new // state from the relay (keeps the relay as source of truth). collabJson(['ok' => true]); } // ── Delete / leave ───────────────────────────────────────── if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { $uid = $m[1]; $res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'DELETE'); if (!($res['ok'] ?? false)) { collabJson(['ok' => false, 'error' => $res['error'] ?? 'delete failed'], 500); } Database::connect()->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]); collabDeleteMainBucket($uid); collabJson(['ok' => true]); } // ── Poll: full resync ────────────────────────────────────── if ($route === '/api/collab-buckets/poll' && $method === 'POST') { $db = Database::connect(); // Phase 5: drain the create/upgrade outbox first. Each queued row // is a "tried to make this local bucket collab but the relay was // down" intent. Retry the upgrade helper; on success drop the // outbox row; on continued failure leave it queued for next poll. $outbox = $db->query('SELECT id, bucket_id, members_json FROM collab_bucket_outbox ORDER BY id ASC')->fetchAll(); $outboxProcessed = 0; foreach ($outbox as $o) { $members = json_decode($o['members_json'] ?? '[]', true) ?: []; $up = collabAttemptUpgrade((int)$o['bucket_id'], $members); if ($up['ok'] ?? false) { $db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]); $outboxProcessed++; } elseif (strpos((string)($up['error'] ?? ''), 'bucket not found') !== false) { // Bucket was deleted locally before the upgrade could fly; // drop the stale outbox entry instead of retrying forever. $db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]); } // All other failures: leave queued, retry next poll. } $res = collabRelayCall('/api/collab-buckets'); if (!($res['ok'] ?? false)) { collabJson(['ok' => false, 'error' => $res['error'] ?? 'poll failed']); } $remote = $res['buckets'] ?? []; $remoteUids = array_column($remote, 'uid'); // Any local bucket that is no longer on the relay for us = we were // removed (or the bucket was deleted). Drop both the plugin mirror // and the corresponding row in the main buckets table. $staleUids = []; if ($remoteUids) { $ph = implode(',', array_fill(0, count($remoteUids), '?')); $sel = $db->prepare("SELECT uid FROM collab_buckets WHERE uid NOT IN ($ph)"); $sel->execute($remoteUids); $staleUids = array_column($sel->fetchAll(), 'uid'); if ($staleUids) { $stmt = $db->prepare("DELETE FROM collab_buckets WHERE uid NOT IN ($ph)"); $stmt->execute($remoteUids); } } else { $staleUids = array_column($db->query('SELECT uid FROM collab_buckets')->fetchAll(), 'uid'); $db->exec('DELETE FROM collab_buckets'); } foreach ($staleUids as $uid) { collabDeleteMainBucket($uid); } // Upsert each remote bucket. Preserve local last_seen_at so the dot // state survives across polls. $changed = 0; $upsert = $db->prepare(" INSERT INTO collab_buckets (uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at) VALUES (:uid, :n, :c, :ou, :m, :ca, :ua, COALESCE((SELECT last_seen_at FROM collab_buckets WHERE uid = :uid2), NULL)) ON CONFLICT(uid) DO UPDATE SET name = excluded.name, color = excluded.color, owner_username = excluded.owner_username, members_json = excluded.members_json, created_at = excluded.created_at, updated_at = excluded.updated_at "); $check = $db->prepare('SELECT updated_at FROM collab_buckets WHERE uid = :u'); foreach ($remote as $b) { // Always mirror into main buckets table (idempotent upsert) collabUpsertMainBucket($b['uid'], $b['name'], $b['color']); $check->execute(['u' => $b['uid']]); $existing = $check->fetchColumn(); if ($existing === ($b['updated_at'] ?? '')) continue; // unchanged, skip plugin-table write $upsert->execute([ 'uid' => $b['uid'], 'n' => $b['name'], 'c' => $b['color'], 'ou' => $b['owner_username'], 'm' => json_encode($b['members'] ?? []), 'ca' => $b['created_at'], 'ua' => $b['updated_at'], 'uid2' => $b['uid'], ]); $changed++; } // ── Phase 3: card sync ───────────────────────────────── $cardStats = collabSyncCards($db); // If anything changed locally we hint to the frontend to reload $cardsChanged = ( $cardStats['pushed'] + $cardStats['pulled'] + $cardStats['deleted_local'] + $cardStats['tombstones_sent'] + $cardStats['conflicts'] + $cardStats['moved'] ) > 0; // ── Phase 4: fold the current lock list into the poll response // so the frontend can decorate cards without a second round-trip. $locks = []; $lockRes = collabRelayCall('/api/collab-locks'); if (($lockRes['ok'] ?? false) && is_array($lockRes['locks'] ?? null)) { $locks = $lockRes['locks']; } // Any pending outbox entries still waiting for a reachable relay $outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn(); // Per-card unread set: cards whose local updated_at is newer than // their last_seen_at (or never seen). Frontend uses this to draw // the little red dot on the board card. $unreadCards = array_map('intval', $db->query(" SELECT cs.card_id FROM collab_card_state cs JOIN cards c ON c.id = cs.card_id WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at ")->fetchAll(PDO::FETCH_COLUMN)); collabJson([ 'ok' => true, 'self' => collabRelaySettings()['user'], 'changed' => $changed, 'total' => count($remote), 'cards_changed' => $cardsChanged, 'card_stats' => $cardStats, 'locks' => $locks, 'outbox_processed' => $outboxProcessed, 'outbox_pending' => $outboxPending, 'unread_cards' => $unreadCards, ]); } // ── Mark a bucket as seen (clears dot + stops pulse next render) ── if ($route === '/api/collab-buckets/mark-seen' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $uid = trim((string)($input['uid'] ?? '')); if ($uid === '') collabJson(['ok' => false, 'error' => 'uid required'], 400); Database::connect() ->prepare("UPDATE collab_buckets SET last_seen_at = datetime('now') WHERE uid = :u") ->execute(['u' => $uid]); collabJson(['ok' => true]); } // ── Mark a single collab card as seen (clears its red dot) ──── if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $cardId = (int)($input['card_id'] ?? 0); if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400); Database::connect() ->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c") ->execute(['c' => $cardId]); collabJson(['ok' => true]); } // ── Proxy: relay tenants (for the create-bucket member picker) ── // The relay's /api/tenants returns {tenants, self} without an `ok` field, // so we wrap it here to match the shape the frontend expects. if ($route === '/api/collab-buckets/relay-tenants' && $method === 'GET') { $res = collabRelayCall('/api/tenants'); if (isset($res['ok']) && $res['ok'] === false) { collabJson($res); } collabJson([ 'ok' => true, 'tenants' => $res['tenants'] ?? [], 'self' => $res['self'] ?? collabRelaySettings()['user'], ]); } // ── Phase 5: upgrade an existing local bucket to collab ─── // Keeps the same local buckets.id (so cards stay attached), writes a // new collab_uid onto the row, and creates the mirror on the relay. // On the very next poll the push phase sees every card in that bucket // as dirty (no collab_card_state rows) and uploads them. If the relay // is unreachable, the upgrade is queued in the outbox and retried on // the next poll. if ($route === '/api/collab-buckets/upgrade' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $bucketId = (int)($input['bucket_id'] ?? 0); $members = is_array($input['members'] ?? null) ? $input['members'] : []; if ($bucketId <= 0) collabJson(['ok' => false, 'error' => 'bucket_id required'], 400); $db = Database::connect(); $up = collabAttemptUpgrade($bucketId, $members); if ($up['ok'] ?? false) { if (!empty($up['already'])) collabJson(['ok' => false, 'error' => 'bucket is already collaborative'], 400); collabJson(['ok' => true, 'uid' => $up['uid']]); } // Relay unreachable — queue retry $db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)') ->execute(['b' => $bucketId, 'm' => json_encode($members)]); collabJson([ 'ok' => true, 'queued' => true, 'error' => $up['error'] ?? 'relay unreachable — will retry on next poll', ]); } // ── Phase 4: edit-lock passthrough ───────────────────────── if ($route === '/api/collab-locks' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $res = collabRelayCall('/api/collab-locks', 'POST', $input); // 409 from the relay = another tenant already holds this lock. // Surface the status so the frontend can take the editor over. $code = ($res['ok'] ?? false) ? 200 : (int)($res['status'] ?? 500); if ($code === 0) $code = 500; collabJson($res, $code); } if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { $res = collabRelayCall('/api/collab-locks/' . urlencode($m[1]) . '/' . urlencode($m[2]), 'DELETE'); collabJson($res); } });