collab attachments, multi-day events, calendar improvements
- Collab buckets sync attachments alongside cards (images/PDFs/audio). New /api/collab-attachments endpoint on relay, encrypted at rest, bucket-scoped for members. - CalDAV multi-day events via **Bis:** body field. Calendar renders event on every day in range with day counter (1/6, 2/6, ...). - Calendar preserves month across close/reopen (only jumps to today on first open). - Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] — dimmed brackets, fixed width so nothing wiggles. - Editor: scratchpad uses custom confirm, autobackup-draft dialog replaced with custom styled dialog. - Scanlines theme feature removed (performance issue). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
002562b1d3
commit
ca47052768
11 files changed
+254
-64
No files matched your search
@@ -232,6 +232,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
|
||||
|
||||
// Collab attachments: binary assets referenced by collab cards (images,
|
||||
// PDFs, audio). Stored encrypted under the inter-tenant key. Scoped to
|
||||
// a bucket — all members of the bucket can fetch. Filename is the
|
||||
// hex-hash name that appears in card bodies as uploads/<name>.
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS collab_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bucket_uid TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (bucket_uid, filename)
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_att_bucket ON collab_attachments(bucket_uid, created_at)');
|
||||
|
||||
// Phase 4: advisory edit locks on collab cards. A client that opens the
|
||||
// editor for a collab card heartbeats into this table every 30s with a
|
||||
// 60s TTL. Other clients poll the list and show a lock indicator on the
|
||||
|
||||
@@ -199,3 +199,66 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route
|
||||
]);
|
||||
jsonResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
|
||||
|
||||
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 (!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 (!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]);
|
||||
}
|
||||
Reference in new issue
Block a user