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

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