Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
221 lines
11 KiB
PHP
221 lines
11 KiB
PHP
<?php
|
|
// Message + attachment routes:
|
|
// POST /api/messages (UI session: server encrypts)
|
|
// POST /api/messages (client API: decrypt-reencrypt hop)
|
|
// GET /api/messages (client API: re-encrypt for recipient)
|
|
// DELETE /api/messages/{id} (own only)
|
|
// POST /api/attachments (client API)
|
|
// GET /api/attachments (client API)
|
|
|
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
// $sessionTenant comes from routes/public.php earlier in the chain
|
|
|
|
// ── UI compose path: server encrypts plaintext with inter-tenant key ──
|
|
|
|
if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty($_SERVER['HTTP_X_RELAY_KEY'])) {
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$title = (string)($input['title'] ?? '');
|
|
$body = (string)($input['body'] ?? '');
|
|
$recipientName = trim((string)($input['recipient'] ?? $sessionTenant['username']));
|
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
|
|
|
$recipient = getTenantByUsername($db, $recipientName);
|
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
|
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
|
$storedTitle = clientEncrypt($title, $interKey);
|
|
$storedBody = clientEncrypt($body, $interKey);
|
|
|
|
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
|
|
->execute([
|
|
'r' => $recipient['id'],
|
|
's' => $sessionTenant['id'],
|
|
'sn' => $sessionTenant['username'],
|
|
't' => $storedTitle,
|
|
'b' => $storedBody,
|
|
'c' => $input['color'] ?? '#38bdf8',
|
|
'tg' => $input['tags'] ?? '',
|
|
'uid' => $input['relay_uid'] ?? null,
|
|
]);
|
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
|
}
|
|
|
|
// ── UI inbox view: session reads decrypted messages ──
|
|
//
|
|
// The relay already sees plaintext during the routing hop (decrypt with
|
|
// sender secret → re-encrypt with inter-tenant key for at-rest storage),
|
|
// so reading them back to the session user is no extra leakage. Lets the
|
|
// user check their inbox from the relay web UI on a phone without
|
|
// needing the full hyprcrd client. Does NOT mark messages as fetched —
|
|
// the canonical receiver is still the hyprcrd client.
|
|
|
|
if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
|
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
|
$stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200');
|
|
$stmt->execute(['r' => $sessionTenant['id']]);
|
|
$rows = $stmt->fetchAll();
|
|
$out = [];
|
|
foreach ($rows as $row) {
|
|
$out[] = [
|
|
'id' => (int)$row['id'],
|
|
'sender' => $row['sender_username'] ?? '?',
|
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
|
'created_at' => $row['created_at'],
|
|
'fetched' => (int)$row['fetched'],
|
|
];
|
|
}
|
|
jsonResponse(['messages' => $out]);
|
|
}
|
|
|
|
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
|
|
|
$clientTenant = authClient($db);
|
|
|
|
if ($route === '/api/messages' && $method === 'POST') {
|
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$encryptedTitle = (string)($input['title'] ?? '');
|
|
$encryptedBody = (string)($input['body'] ?? '');
|
|
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
|
|
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
|
|
|
$recipient = getTenantByUsername($db, $recipientName);
|
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
|
|
// Decrypt with sender's secret
|
|
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);
|
|
}
|
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
|
$plainTitle = clientDecrypt($encryptedTitle, $senderSecret);
|
|
$plainBody = clientDecrypt($encryptedBody, $senderSecret);
|
|
|
|
// Re-encrypt with the inter-tenant (super-shared) key for at-rest storage
|
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500);
|
|
$storedTitle = clientEncrypt($plainTitle, $interKey);
|
|
$storedBody = clientEncrypt($plainBody, $interKey);
|
|
|
|
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
|
|
->execute([
|
|
'r' => $recipient['id'],
|
|
's' => $clientTenant['id'],
|
|
'sn' => $clientTenant['username'],
|
|
't' => $storedTitle,
|
|
'b' => $storedBody,
|
|
'c' => $input['color'] ?? '#38bdf8',
|
|
'tg' => $input['tags'] ?? '',
|
|
'uid' => $input['relay_uid'] ?? null,
|
|
]);
|
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
|
}
|
|
|
|
if ($route === '/api/messages' && $method === 'GET') {
|
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
if (empty($clientTenant['secret_encrypted'])) {
|
|
jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400);
|
|
}
|
|
|
|
$since = $_GET['since'] ?? null;
|
|
if ($since) {
|
|
$stmt = $db->prepare("SELECT * FROM messages WHERE recipient_id = :r AND created_at > :s ORDER BY created_at ASC");
|
|
$stmt->execute(['r' => $clientTenant['id'], 's' => $since]);
|
|
} else {
|
|
$stmt = $db->prepare("SELECT * FROM messages WHERE recipient_id = :r ORDER BY created_at ASC");
|
|
$stmt->execute(['r' => $clientTenant['id']]);
|
|
}
|
|
$msgs = $stmt->fetchAll();
|
|
|
|
// Decrypt with inter-tenant key, re-encrypt with recipient's secret
|
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
|
|
|
$out = [];
|
|
foreach ($msgs as $msg) {
|
|
$plainTitle = clientDecrypt($msg['title'] ?? '', $interKey);
|
|
$plainBody = clientDecrypt($msg['body'] ?? '', $interKey);
|
|
$msg['title'] = clientEncrypt($plainTitle, $recipientSecret);
|
|
$msg['body'] = clientEncrypt($plainBody, $recipientSecret);
|
|
$out[] = $msg;
|
|
}
|
|
$db->prepare("UPDATE messages SET fetched = 1 WHERE recipient_id = :r AND fetched = 0")
|
|
->execute(['r' => $clientTenant['id']]);
|
|
jsonResponse($out);
|
|
}
|
|
|
|
if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
|
$tenant = $clientTenant ?: $sessionTenant;
|
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$id = (int)$m[1];
|
|
$db->prepare('DELETE FROM messages WHERE id = :id AND recipient_id = :r')
|
|
->execute(['id' => $id, 'r' => $tenant['id']]);
|
|
jsonResponse(['ok' => true]);
|
|
}
|
|
|
|
if ($route === '/api/attachments' && $method === 'POST') {
|
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
|
$relayUid = (string)($input['relay_uid'] ?? '');
|
|
$filename = (string)($input['filename'] ?? '');
|
|
$data = (string)($input['data'] ?? '');
|
|
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
|
|
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400);
|
|
|
|
$recipient = getTenantByUsername($db, $recipientName);
|
|
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
|
|
// Decrypt-reencrypt hop, same shape as messages: arrives encrypted with
|
|
// sender's secret, gets stored at-rest under the inter-tenant key.
|
|
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);
|
|
}
|
|
$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 — admin must complete setup'], 500);
|
|
|
|
$plainBytes = clientDecrypt($data, $senderSecret);
|
|
if ($plainBytes === '') jsonResponse(['error' => 'attachment decrypt failed (sender secret mismatch?)'], 400);
|
|
$storedData = clientEncrypt($plainBytes, $interKey);
|
|
|
|
$db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn')
|
|
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $filename]);
|
|
$db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)')
|
|
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $filename, 'data' => $storedData]);
|
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
|
}
|
|
|
|
if ($route === '/api/attachments' && $method === 'GET') {
|
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
|
if (empty($clientTenant['secret_encrypted'])) {
|
|
jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400);
|
|
}
|
|
$relayUid = $_GET['relay_uid'] ?? '';
|
|
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
|
|
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
|
|
|
$stmt = $db->prepare('SELECT id, relay_uid, filename, data, created_at FROM attachments WHERE recipient_id = :r AND relay_uid = :uid');
|
|
$stmt->execute(['r' => $clientTenant['id'], 'uid' => $relayUid]);
|
|
$rows = $stmt->fetchAll();
|
|
|
|
$out = [];
|
|
foreach ($rows as $row) {
|
|
$plainBytes = clientDecrypt($row['data'] ?? '', $interKey);
|
|
$row['data'] = clientEncrypt($plainBytes, $recipientSecret);
|
|
$out[] = $row;
|
|
}
|
|
jsonResponse($out);
|
|
}
|