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:
thandClaude Opus 4.6 committed 2026-04-14 18:22:02 +02:00
1 parent 002562b1d3
commit ca47052768
11 files changed
+254 -64

No files matched your search

+63
View File
@@ -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]);
}