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(); } // ── POST: acquire/refresh ──────────────────────────────────────── // Maximum time a single holder can keep refreshing the same lock. Without a // cap a malicious member could heartbeat forever, blocking every other // member's writes. 10 minutes is long enough for any real edit session and // short enough to time-box grief. When the cap is hit the lock is force- // released and the client must re-acquire — giving everyone else a window. const COLLAB_LOCK_MAX_HOLD_SECONDS = 600; if ($route === '/api/collab-locks' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (isRateLimited($db, 'collab-locks-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'] ?? '')); if ($bucketUid === '' || $cardUid === '') jsonResponse(['error' => 'bucket_uid and card_uid required'], 400); 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]); // Also drop locks that have been refreshed past the max hold window — // cap enforcement for grief prevention. $db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND acquired_at <= datetime('now', '-" . COLLAB_LOCK_MAX_HOLD_SECONDS . " seconds')") ->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, acquired_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) 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, 'holder_id' => (int)$clientTenant['id'], 'expires_at' => (string)$db->query("SELECT datetime('now', '+60 seconds')")->fetchColumn(), ]); } // ── DELETE: release own lock ───────────────────────────────────── if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $bucketUid = $m[1]; $cardUid = $m[2]; $db->prepare('DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND holder_id = :h') ->execute(['b' => $bucketUid, 'c' => $cardUid, 'h' => $clientTenant['id']]); jsonResponse(['ok' => true]); } // ── GET: list locks visible to caller ──────────────────────────── if ($route === '/api/collab-locks' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (isRateLimited($db, 'collab-locks-get', 120)) jsonResponse(['error' => 'rate limited'], 429); // Only return locks in buckets the caller is currently a member of, // and only non-expired rows (bootstrap cleanup + defensive filter). $stmt = $db->prepare(" SELECT l.bucket_uid, l.card_uid, l.holder_id, l.holder_username, l.acquired_at, l.expires_at FROM collab_card_locks l INNER JOIN collab_bucket_members m ON m.bucket_uid = l.bucket_uid WHERE m.tenant_id = :tid AND l.expires_at > datetime('now') ORDER BY l.acquired_at DESC "); $stmt->execute(['tid' => $clientTenant['id']]); jsonResponse(['ok' => true, 'locks' => $stmt->fetchAll()]); }