From f6cf7b4358e0541602965e9d7e9e3c558024fcdc Mon Sep 17 00:00:00 2001 From: th Date: Sat, 11 Apr 2026 19:36:57 +0200 Subject: [PATCH] collab-buckets: exclusive locks, server-enforced, with editor takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous lock implementation was advisory only — the relay's lock table PK was (bucket_uid, card_uid, holder_id), so multiple users could "hold" the same card simultaneously and the plugin only used the locks to draw a small badge. Concurrent writers raced through LWW, and an unsaved editor on User A could be silently overwritten when User B saved (the symptom: cards appearing to "disappear" from User A's view once their version was clobbered by an LWW pull). Now an exclusive, server-enforced lock: * relay schema: collab_card_locks PK is (bucket_uid, card_uid). One lock per card. Migration drops the old 3-column-PK table on the next bootstrap (locks are 60s ephemeral, no data to preserve). * /api/collab-locks POST: returns 409 + holder info when another tenant already holds it. Same holder refreshes the TTL. * /api/collab-cards POST/DELETE: refuse with 423 + locked_by when a foreign tenant holds the lock. The plugin's local edit is preserved — the push just stays queued and retries on the next poll until the lock expires or is released. Tombstones are gated the same way so a card can't be deleted under the holder's editor. * Plugin push phase: tracks blocked count + holder names, surfaces them in the poll response so the frontend can toast the user. Frontend lock-on-first-keystroke flow: * The lock is no longer acquired on editor-open — opening for read doesn't lock anything. On the FIRST input event in #inp-title or #editor-body the plugin tries to acquire. On success, heartbeat every 30 s; on 409, immediately enter editor takeover. * Takeover mode: title + body inputs disabled, all three save buttons get a capture-phase click blocker (stopImmediatePropagation to defeat the editor's own listener), a window-level capture-phase keydown blocker swallows Ctrl/Cmd+S (app.js binds save directly to the shortcut, bypassing the buttons), and a banner inside the modal explains the situation. The user can still copy their unsaved text out before closing. * If a foreign lock arrives via the regular poll while the editor is already open on that card, the same takeover engages mid-session. * Opening the editor on a card whose lock is *already* known to be foreign starts in takeover mode without arming the input listeners at all. * Self-detection: poll response now carries `self` (the relay username), cached as window._collabSelf, so we don't take ourselves over and the board decoration ignores our own lock. Visual: * Board cards held by a foreign lock now get a frosted overlay + centered lock icon + holder name (replaces the small corner badge). * Editor takeover banner uses the danger accent. Save buttons get a .collab-save-blocked dim style. * New `.collab-lock-toast` variant of the existing conflict toast, used for both "your push is queued because X is editing" and "this card just got locked by X" notifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- public/plugins/collab-buckets/backend.php | 31 ++- .../plugins/collab-buckets/collab-buckets.css | 91 ++++++- .../plugins/collab-buckets/collab-buckets.js | 255 +++++++++++++++--- relay/inc/bootstrap.php | 14 +- relay/inc/routes/collab-cards.php | 34 +++ relay/inc/routes/collab-locks.php | 30 ++- 6 files changed, 407 insertions(+), 48 deletions(-) diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php index b7160d4..b3467b6 100644 --- a/public/plugins/collab-buckets/backend.php +++ b/public/plugins/collab-buckets/backend.php @@ -358,7 +358,7 @@ function collabBuildCardPayload(array $card): array { */ function collabSyncCards(PDO $db): array { $relay = collabRelaySettings(); - $stats = ['pushed' => 0, 'pulled' => 0, 'deleted_local' => 0, 'tombstones_sent' => 0, 'conflicts' => 0, 'moved' => 0]; + $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 ──────────────────────────────── @@ -371,8 +371,15 @@ function collabSyncCards(PDO $db): array { 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 it stays queued and gets retried next poll. + // If a tombstone push fails for any other reason it stays queued + // and gets retried next poll. } // ── 1b. Detect moved cards ───────────────────────────── @@ -457,7 +464,18 @@ function collabSyncCards(PDO $db): array { 'card_uid' => $relayUid, 'payload' => $payload, ]); - if (!($res['ok'] ?? false)) continue; // retry next poll + 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'], @@ -798,6 +816,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { collabJson([ 'ok' => true, + 'self' => collabRelaySettings()['user'], 'changed' => $changed, 'total' => count($remote), 'cards_changed' => $cardsChanged, @@ -867,7 +886,11 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { if ($route === '/api/collab-locks' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $res = collabRelayCall('/api/collab-locks', 'POST', $input); - collabJson($res); + // 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'); diff --git a/public/plugins/collab-buckets/collab-buckets.css b/public/plugins/collab-buckets/collab-buckets.css index 19d897e..a4c8908 100644 --- a/public/plugins/collab-buckets/collab-buckets.css +++ b/public/plugins/collab-buckets/collab-buckets.css @@ -235,27 +235,84 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing { font-style: italic; } -/* ── Phase 4: edit-lock indicator on board cards ── */ +/* ── Phase 4: exclusive edit-lock indicator on board cards ── + Locked cards get a frosted overlay + centered lock icon. The card + content underneath stays visible enough to read but is clearly + "off-limits" — the cue is intentionally heavier than the old badge + so the user can't miss it. */ .board-card.collab-locked { - box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 55%, transparent); + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 70%, transparent); +} +.board-card.collab-locked > *:not(.collab-lock-badge) { + filter: blur(2px); + opacity: 0.55; + transition: filter 0.15s, opacity 0.15s; } .collab-lock-badge { position: absolute; - top: 6px; - right: 6px; - width: 20px; - height: 20px; + top: 0; + left: 0; + right: 0; + bottom: 0; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 4px; color: var(--t-accent2); - background: color-mix(in srgb, var(--t-surface) 92%, transparent); - border: 1px solid color-mix(in srgb, var(--t-accent2) 60%, var(--t-border)); - border-radius: 50%; + background: color-mix(in srgb, var(--t-surface) 60%, transparent); + backdrop-filter: blur(1px); + -webkit-backdrop-filter: blur(1px); z-index: 6; pointer-events: none; - font-size: 11px; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 9px; + text-align: center; + padding: 4px 8px; } +.collab-lock-badge i { + font-size: 22px; +} +.collab-lock-badge span { + color: var(--t-text); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Editor takeover (foreign lock arrived while editing) ── */ +.collab-readonly-modal #inp-title, +.collab-readonly-modal #editor-body { + opacity: 0.7; + cursor: not-allowed; +} +.collab-readonly-modal #editor-body { + background: color-mix(in srgb, var(--t-danger) 6%, transparent) !important; +} +.collab-save-blocked { + opacity: 0.4 !important; + cursor: not-allowed !important; + pointer-events: auto; +} +.collab-takeover-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: color-mix(in srgb, var(--t-danger) 12%, transparent); + border-bottom: 1px solid var(--t-danger); + color: var(--t-bright); + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 10px; + line-height: 1.5; +} +.collab-takeover-banner i { + color: var(--t-danger); + font-size: 16px; + flex-shrink: 0; +} +.collab-takeover-banner b { color: var(--t-danger); } /* ── Conflict toast (LWW losers stashed into the Conflicts bucket) ── */ .collab-conflict-toast { @@ -302,3 +359,17 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing { align-items: center; } .collab-conflict-close:hover { color: var(--t-danger); } + +/* Lock toast: same shape as the conflict toast but accented with the + collab/lock color. Reused for both the editor-takeover notification + and the "your push was queued because someone else is editing" toast. */ +.collab-conflict-toast.collab-lock-toast { + border-color: var(--t-accent2); +} +.collab-conflict-toast.collab-lock-toast i.ph-lock-simple-fill { + color: var(--t-accent2); + flex-shrink: 0; +} +.collab-conflict-toast.collab-lock-toast b { + color: var(--t-accent2); +} diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js index b8ec215..685c57a 100644 --- a/public/plugins/collab-buckets/collab-buckets.js +++ b/public/plugins/collab-buckets/collab-buckets.js @@ -27,11 +27,12 @@ let collabByUid = new Map(); let pollHandle = null; const POLL_INTERVAL_MS = 30000; -// Phase 4: edit locks +// Phase 4: edit locks (now exclusive — server-enforced) // card_uid -> { bucket_uid, holder_username, expires_at } let collabLocks = new Map(); -// Active heartbeat for the currently open editor (if editing a collab card) -let activeLock = null; // { bucket_uid, card_uid, timer } +// Tracks the editor session: { bucket_uid, card_uid, cardId, hbTimer, +// inputCleanup, acquired, takeover } +let editorSession = null; const LOCK_HEARTBEAT_MS = 30000; // ── Helpers ─────────────────────────────────────────────────────── @@ -61,6 +62,7 @@ async function pollOnce() { try { const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' }); if (!res?.ok) return; + if (res.self) window._collabSelf = res.self; // Reload if: // * a bucket changed (metadata / members) // * the bucket set shrunk or grew @@ -90,6 +92,25 @@ async function pollOnce() { collabLocks = next; window._collabLocks = collabLocks; decorateBoardLocks(); + // If the editor is currently open on a card that just got + // locked by someone else, take over immediately. Covers the + // race where two users started typing nearly simultaneously + // and the relay arbitration only resolves on the next poll. + if (editorSession && !editorSession.takeover) { + const lock = collabLocks.get(editorSession.card_uid); + const self = window._collabSelf || null; + if (lock && lock.holder_username && lock.holder_username !== self) { + enterEditorTakeover(lock.holder_username); + } + } + } + // The push phase couldn't deliver because someone else holds an + // exclusive lock on a dirty card. Surface so the user knows their + // edit isn't lost — it's just queued. + const blocked = res.card_stats?.blocked || 0; + if (blocked > 0) { + const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : []; + showBlockedToast(by); } } catch { /* silent */ } } @@ -597,10 +618,13 @@ function decorateBoardLocks() { if (!badge) { badge = document.createElement('div'); badge.className = 'collab-lock-badge'; - badge.innerHTML = ''; el.appendChild(badge); } - badge.title = `Being edited by ${lock.holder_username}`; + badge.innerHTML = ` + + ${escapeHtml(lock.holder_username)} + `; + badge.title = `Locked by ${lock.holder_username}`; } else if (badge) { badge.remove(); } @@ -630,13 +654,22 @@ function collabIdentityOf(card) { return { bucket_uid: bucket.collab_uid, card_uid: card.relay_uid }; } -async function acquireLock(bucket_uid, card_uid) { +/** Acquire (or refresh) the exclusive lock. Returns: + * { ok: true } on success + * { ok: false, locked_by: '…' } when another tenant already holds it (409) + * { ok: false } on transport / unknown error */ +async function tryAcquireLock(bucket_uid, card_uid) { try { - await hyprcrd.api('api/collab-locks', { + const res = await hyprcrd.api('api/collab-locks', { method: 'POST', body: { bucket_uid, card_uid }, }); - } catch { /* silent */ } + if (res?.ok) return { ok: true }; + if (res?.locked_by) return { ok: false, locked_by: res.locked_by }; + return { ok: false }; + } catch { + return { ok: false }; + } } async function releaseLock(bucket_uid, card_uid) { @@ -648,41 +681,203 @@ async function releaseLock(bucket_uid, card_uid) { } catch { /* silent */ } } -function startHeartbeat(bucket_uid, card_uid) { - stopHeartbeat(); - activeLock = { bucket_uid, card_uid, timer: null }; - acquireLock(bucket_uid, card_uid); - activeLock.timer = setInterval(() => acquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS); +/** First-keystroke lock: sets up one-shot input listeners on the editor + * inputs. The first input event tries to acquire the exclusive lock; on + * success we start the heartbeat, on failure we slap the editor into + * takeover mode. */ +function armFirstKeystrokeLock() { + if (!editorSession) return; + const inputs = [document.getElementById('inp-title'), document.getElementById('editor-body')].filter(Boolean); + let fired = false; + const onInput = async () => { + if (fired) return; + fired = true; + // Drop the listeners — heartbeat or takeover takes over from here + for (const el of inputs) el.removeEventListener('input', onInput); + if (!editorSession) return; + const { bucket_uid, card_uid } = editorSession; + const res = await tryAcquireLock(bucket_uid, card_uid); + if (!editorSession) return; // editor closed during the await + if (res.ok) { + editorSession.acquired = true; + editorSession.hbTimer = setInterval(() => tryAcquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS); + } else { + enterEditorTakeover(res.locked_by || 'someone'); + } + }; + for (const el of inputs) el.addEventListener('input', onInput); + editorSession.inputCleanup = () => { + for (const el of inputs) el.removeEventListener('input', onInput); + }; } -function stopHeartbeat() { - if (!activeLock) return; - const { bucket_uid, card_uid, timer } = activeLock; - if (timer) clearInterval(timer); - releaseLock(bucket_uid, card_uid); - activeLock = null; +/** Switch the editor to read-only takeover mode. The user can still see / + * copy their text but the inputs are disabled and save is blocked. */ +function enterEditorTakeover(holder) { + if (!editorSession || editorSession.takeover) return; + editorSession.takeover = true; + const modal = document.getElementById('modal'); + const title = document.getElementById('inp-title'); + const body = document.getElementById('editor-body'); + if (title) title.disabled = true; + if (body) body.disabled = true; + modal?.classList.add('collab-readonly-modal'); + + // Block save buttons by capturing clicks before the editor handler. + // stopImmediatePropagation also blocks any sibling listeners registered + // on the same element (the editor's own click handler) and skips both + // target + bubble phases for ancestors. + const saveIds = ['modal-save', 'modal-save-view', 'modal-save-close']; + const blockers = saveIds.map(id => { + const btn = document.getElementById(id); + if (!btn) return null; + const handler = (e) => { + e.stopImmediatePropagation(); + e.preventDefault(); + }; + btn.addEventListener('click', handler, true); // capture phase + btn.setAttribute('disabled', 'disabled'); + btn.classList.add('collab-save-blocked'); + return { btn, handler }; + }).filter(Boolean); + editorSession.saveBlockers = blockers; + + // Also intercept Ctrl/Cmd+S — app.js binds this directly to saveCard/ + // saveOnly without going through the buttons we just disabled. + const keyBlocker = (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + e.stopImmediatePropagation(); + } + }; + window.addEventListener('keydown', keyBlocker, true); + editorSession.keyBlocker = keyBlocker; + + // Banner inside the modal explaining the situation + let banner = document.getElementById('collab-takeover-banner'); + if (!banner) { + banner = document.createElement('div'); + banner.id = 'collab-takeover-banner'; + banner.className = 'collab-takeover-banner'; + const inner = document.querySelector('#modal .modal-inner') || document.querySelector('#modal > div') || modal; + inner?.prepend(banner); + } + banner.innerHTML = ` + + Locked by ${escapeHtml(holder)}. This card was locked by another user while you were typing. Copy your text out — saving is disabled. + `; + + // Also show a one-off toast so the user can't miss it + showLockToast(holder); +} + +function exitEditorTakeover() { + const modal = document.getElementById('modal'); + const title = document.getElementById('inp-title'); + const body = document.getElementById('editor-body'); + if (title) title.disabled = false; + if (body) body.disabled = false; + modal?.classList.remove('collab-readonly-modal'); + document.getElementById('collab-takeover-banner')?.remove(); + if (editorSession?.saveBlockers) { + for (const { btn, handler } of editorSession.saveBlockers) { + btn.removeEventListener('click', handler, true); + btn.removeAttribute('disabled'); + btn.classList.remove('collab-save-blocked'); + } + editorSession.saveBlockers = null; + } + if (editorSession?.keyBlocker) { + window.removeEventListener('keydown', editorSession.keyBlocker, true); + editorSession.keyBlocker = null; + } +} + +function showLockToast(holder) { + const existing = document.getElementById('collab-lock-toast'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.id = 'collab-lock-toast'; + toast.className = 'collab-conflict-toast collab-lock-toast'; + toast.innerHTML = ` + + Locked by ${escapeHtml(holder)}. Copy your text — saving is blocked. + + `; + toast.querySelector('.collab-conflict-close').addEventListener('click', () => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 400); + }); + document.body.appendChild(toast); + setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 8000); +} + +function showBlockedToast(by) { + const existing = document.getElementById('collab-blocked-toast'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.id = 'collab-blocked-toast'; + toast.className = 'collab-conflict-toast collab-lock-toast'; + const who = by.length ? by.join(', ') : 'another user'; + toast.innerHTML = ` + + Your edit is queued — ${escapeHtml(who)} is currently editing. It'll sync when they're done. + + `; + toast.querySelector('.collab-conflict-close').addEventListener('click', () => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 400); + }); + document.body.appendChild(toast); + setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000); +} + +/** Tear down everything we own for the active editor session. Called both + * on editor-close and on plugin reload. */ +function endEditorSession() { + if (!editorSession) return; + const { hbTimer, inputCleanup, acquired, bucket_uid, card_uid } = editorSession; + if (hbTimer) clearInterval(hbTimer); + if (typeof inputCleanup === 'function') inputCleanup(); + if (acquired) releaseLock(bucket_uid, card_uid); + exitEditorTakeover(); + editorSession = null; } window.addEventListener('hyprcrd:editor-open', (e) => { + endEditorSession(); const card = e.detail?.card; const id = collabIdentityOf(card); - if (id) startHeartbeat(id.bucket_uid, id.card_uid); + if (!id) return; + editorSession = { + bucket_uid: id.bucket_uid, + card_uid: id.card_uid, + cardId: card?.id, + hbTimer: null, + inputCleanup: null, + acquired: false, + takeover: false, + saveBlockers: null, + }; + // If a foreign lock is already known from the last poll, take over + // immediately — no point letting the user type into a doomed editor. + const existing = collabLocks.get(id.card_uid); + const self = window._collabSelf || null; + if (existing && existing.holder_username && existing.holder_username !== self) { + enterEditorTakeover(existing.holder_username); + return; + } + armFirstKeystrokeLock(); }); window.addEventListener('hyprcrd:editor-close', () => { - stopHeartbeat(); + endEditorSession(); }); // Graceful release on tab close window.addEventListener('beforeunload', () => { - if (activeLock) { - // Best-effort sync release; browsers may block regular fetch, so - // use sendBeacon when available. + if (editorSession?.acquired) { try { - if (navigator.sendBeacon) { - const url = 'api/collab-locks/' + encodeURIComponent(activeLock.bucket_uid) + '/' + encodeURIComponent(activeLock.card_uid); - // sendBeacon only supports POST; use fetch keepalive as the - // DELETE fallback. Both are best-effort at unload time. - fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {}); - } + const url = 'api/collab-locks/' + encodeURIComponent(editorSession.bucket_uid) + '/' + encodeURIComponent(editorSession.card_uid); + fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {}); } catch { /* ignore */ } } }); diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index 576a89d..f29ffe4 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -227,6 +227,18 @@ $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_ // 60s TTL. Other clients poll the list and show a lock indicator on the // card. The lock is purely advisory — force-editing is still allowed and // LWW remains the authoritative conflict resolver. +// Exclusive lock: PRIMARY KEY is (bucket_uid, card_uid). The first writer +// to acquire holds it; everyone else gets a 409 Locked. The previous schema +// had holder_id in the PK, which let multiple users "lock" the same card +// simultaneously — locks were advisory only, and concurrent writers could +// silently overwrite each other through LWW. +// +// Migration: if the existing table still has the 3-column PK, drop it. +// Locks are ephemeral (60s TTL) so dropping discards nothing important. +$lockTableInfo = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='collab_card_locks'")->fetchColumn(); +if ($lockTableInfo && stripos($lockTableInfo, 'PRIMARY KEY (bucket_uid, card_uid, holder_id)') !== false) { + $db->exec('DROP TABLE collab_card_locks'); +} $db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks ( bucket_uid TEXT NOT NULL, card_uid TEXT NOT NULL, @@ -234,7 +246,7 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks ( holder_username TEXT NOT NULL, acquired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, - PRIMARY KEY (bucket_uid, card_uid, holder_id), + PRIMARY KEY (bucket_uid, card_uid), FOREIGN KEY (holder_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)'); diff --git a/relay/inc/routes/collab-cards.php b/relay/inc/routes/collab-cards.php index 334306a..08f3aa4 100644 --- a/relay/inc/routes/collab-cards.php +++ b/relay/inc/routes/collab-cards.php @@ -27,6 +27,21 @@ function collabCardsIsMember(PDO $db, string $bucketUid, int $tenantId): bool { 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') { @@ -42,6 +57,16 @@ if ($route === '/api/collab-cards' && $method === 'POST') { 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); } @@ -148,6 +173,15 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route 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(' diff --git a/relay/inc/routes/collab-locks.php b/relay/inc/routes/collab-locks.php index 2e2396c..953e8c6 100644 --- a/relay/inc/routes/collab-locks.php +++ b/relay/inc/routes/collab-locks.php @@ -30,18 +30,42 @@ if ($route === '/api/collab-locks' && $method === 'POST') { if (!collabLocksIsMember($db, $bucketUid, (int)$clientTenant['id'])) { jsonResponse(['error' => 'not a member of this bucket'], 403); } + // Drop expired before checking — bootstrap cleanup runs every request + // but this protects against a stale lock blocking acquisition in the + // narrow window between cleanup and this query. + $db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND expires_at <= datetime('now')") + ->execute(['b' => $bucketUid, 'c' => $cardUid]); + // Existing live lock? If a different tenant holds it, refuse. + $cur = $db->prepare('SELECT holder_id, holder_username, expires_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c'); + $cur->execute(['b' => $bucketUid, 'c' => $cardUid]); + $existing = $cur->fetch(); + if ($existing && (int)$existing['holder_id'] !== (int)$clientTenant['id']) { + jsonResponse([ + 'error' => 'card is being edited by ' . $existing['holder_username'], + 'locked_by' => $existing['holder_username'], + 'locked_by_id' => (int)$existing['holder_id'], + 'expires_at' => $existing['expires_at'], + ], 409); + } + // Acquire / refresh — same holder bumps the expiry $db->prepare(" INSERT INTO collab_card_locks (bucket_uid, card_uid, holder_id, holder_username, acquired_at, expires_at) VALUES (:b, :c, :hid, :hu, datetime('now'), datetime('now', '+60 seconds')) - ON CONFLICT(bucket_uid, card_uid, holder_id) DO UPDATE SET - expires_at = datetime('now', '+60 seconds') + ON CONFLICT(bucket_uid, card_uid) DO UPDATE SET + holder_id = excluded.holder_id, + holder_username = excluded.holder_username, + expires_at = excluded.expires_at ")->execute([ 'b' => $bucketUid, 'c' => $cardUid, 'hid' => $clientTenant['id'], 'hu' => $clientTenant['username'], ]); - jsonResponse(['ok' => true, 'expires_at' => (string)$db->query("SELECT datetime('now', '+60 seconds')")->fetchColumn()]); + jsonResponse([ + 'ok' => true, + 'holder_id' => (int)$clientTenant['id'], + 'expires_at' => (string)$db->query("SELECT datetime('now', '+60 seconds')")->fetchColumn(), + ]); } // ── DELETE: release own lock ─────────────────────────────────────