diff --git a/gitea-issue-client b/gitea-issue-client deleted file mode 160000 index 90353c9..0000000 --- a/gitea-issue-client +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 90353c93adb02b8d2e2362875ce6004fb7c3ec4e diff --git a/public/index.php b/public/index.php index 502893e..83edf2c 100644 --- a/public/index.php +++ b/public/index.php @@ -1174,6 +1174,9 @@ if (str_starts_with($route, '/api/')) { search ctrl+k +
- © hyprcrd
@@ -1463,6 +1465,9 @@ if (str_starts_with($route, '/api/')) { +
+ © hyprcrd · v +
diff --git a/public/js/hyprcrd.js b/public/js/hyprcrd.js index 58b136e..c7cab7e 100644 --- a/public/js/hyprcrd.js +++ b/public/js/hyprcrd.js @@ -117,8 +117,13 @@ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }, - // CSRF: core calls setCsrf() after auth so plugins can read it - setCsrf(token) { csrfToken = String(token || ''); }, + // CSRF: core calls setCsrf() after auth so plugins can read it. + // Emits 'csrf.ready' so plugins that need to make state-changing + // requests on init can defer until auth has happened. + setCsrf(token) { + csrfToken = String(token || ''); + if (csrfToken) hooks.emit('csrf.ready', csrfToken); + }, csrf() { return csrfToken; }, }; diff --git a/public/js/modules/caldav.js b/public/js/modules/caldav.js index 4721c7a..8239f51 100644 --- a/public/js/modules/caldav.js +++ b/public/js/modules/caldav.js @@ -175,6 +175,8 @@ $('caldav-sync-now')?.addEventListener('click', () => triggerSync()); export async function triggerSync() { const btn = $('cal-sync'); if (btn) btn.style.opacity = '0.5'; + const headerIcon = $('header-sync-icon'); + if (headerIcon) headerIcon.classList.add('card-spinner'); if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; } const res = await fetch('api/caldav/sync', { @@ -184,6 +186,7 @@ export async function triggerSync() { if (btn) btn.style.opacity = ''; // Also poll relay + // (header spinner stays on until the relay poll completes too — see end) const relayRes = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() }, }).then(r => r.ok ? r.json() : null).catch(() => null); @@ -202,6 +205,7 @@ export async function triggerSync() { } else { if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; } } + if (headerIcon) headerIcon.classList.remove('card-spinner'); } function showLog(log) { @@ -283,6 +287,7 @@ export async function loadRelaySettings() { $('cal-caldav-settings')?.addEventListener('click', openCaldavSettings); $('cal-sync')?.addEventListener('click', () => triggerSync()); +$('header-sync')?.addEventListener('click', () => triggerSync()); // Auto-sync throttle let lastAutoSync = 0; diff --git a/public/plugins/chat/backend.php b/public/plugins/chat/backend.php new file mode 100644 index 0000000..5aad496 --- /dev/null +++ b/public/plugins/chat/backend.php @@ -0,0 +1,249 @@ +exec(" + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + peer TEXT NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + body TEXT NOT NULL, + relay_id INTEGER, + sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_read INTEGER NOT NULL DEFAULT 0 + ) + "); + $db->exec("CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer, sent_at)"); + $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_relay_id ON chat_messages(relay_id) WHERE relay_id IS NOT NULL"); + // Track the largest relay msg id we've ever seen (so the next poll uses since=that) + $db->exec(" + CREATE TABLE IF NOT EXISTS chat_state ( + key TEXT PRIMARY KEY, + value TEXT + ) + "); +})(); + +function chatJson($data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} + +function chatRelaySettings(): array { + $db = Database::connect(); + return [ + 'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '', + 'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '', + 'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '', + ]; +} + +/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */ +function chatEncrypt(string $plain, string $relayKey): string { + $encKey = substr(hash('sha256', $relayKey), 0, 32); + $iv = random_bytes(16); + $cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); + return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher); +} + +function chatDecrypt(string $envelope, string $relayKey): string { + if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return ''; + $b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1)); + $raw = base64_decode($b64); + if (!$raw || strlen($raw) < 17) return ''; + $encKey = substr(hash('sha256', $relayKey), 0, 32); + $iv = substr($raw, 0, 16); + $cipher = substr($raw, 16); + $result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); + return $result !== false ? $result : ''; +} + +function chatGetState(string $key, $default = null) { + $db = Database::connect(); + $stmt = $db->prepare('SELECT value FROM chat_state WHERE key = :k'); + $stmt->execute(['k' => $key]); + $v = $stmt->fetchColumn(); + return $v === false ? $default : $v; +} + +function chatSetState(string $key, $value): void { + $db = Database::connect(); + $db->prepare('INSERT OR REPLACE INTO chat_state (key, value) VALUES (:k, :v)') + ->execute(['k' => $key, 'v' => (string)$value]); +} + +PluginManager::instance()->onApiRoute(function (string $route, string $method) { + + if ($route === '/api/chat/conversations' && $method === 'GET') { + $db = Database::connect(); + // Latest message per peer + unread count, single query + $rows = $db->query(" + SELECT + peer, + MAX(sent_at) AS last_at, + (SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_body, + (SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_direction, + SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread + FROM chat_messages c1 + GROUP BY peer + ORDER BY last_at DESC + ")->fetchAll(); + $totalUnread = array_sum(array_map(fn($r) => (int)$r['unread'], $rows)); + chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]); + } + + if ($route === '/api/chat/messages' && $method === 'GET') { + $peer = trim((string)($_GET['peer'] ?? '')); + if ($peer === '') chatJson(['error' => 'peer required'], 400); + $db = Database::connect(); + $stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500'); + $stmt->execute(['p' => $peer]); + chatJson(['messages' => $stmt->fetchAll()]); + } + + if ($route === '/api/chat/send' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $peer = trim((string)($input['peer'] ?? '')); + $body = (string)($input['body'] ?? ''); + if ($peer === '' || $body === '') chatJson(['error' => 'peer and body required'], 400); + + $r = chatRelaySettings(); + if (!$r['url'] || !$r['user'] || !$r['key']) { + chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400); + } + + $envelope = chatEncrypt($body, $r['key']); + $ch = curl_init(rtrim($r['url'], '/') . '/api/chat'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Relay-User: ' . $r['user'], + 'X-Relay-Key: ' . $r['key'], + ], + CURLOPT_TIMEOUT => 10, + ]); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { + $relayErr = ''; + $decoded = json_decode($response, true); + if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error']; + chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]); + } + + $resData = json_decode($response, true); + $relayId = isset($resData['id']) ? (int)$resData['id'] : null; + + // Store the outgoing message locally so the UI can render it immediately + $db = Database::connect(); + $db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "out", :b, NULL, datetime("now"), 1)') + ->execute(['p' => $peer, 'b' => $body]); + chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); + } + + if ($route === '/api/chat/poll' && $method === 'POST') { + $r = chatRelaySettings(); + if (!$r['url'] || !$r['user'] || !$r['key']) { + chatJson(['ok' => true, 'new' => 0, 'note' => 'relay not configured']); + } + $since = (int)chatGetState('last_relay_id', 0); + + $ch = curl_init(rtrim($r['url'], '/') . '/api/chat?since=' . $since); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'X-Relay-User: ' . $r['user'], + 'X-Relay-Key: ' . $r['key'], + ], + CURLOPT_TIMEOUT => 10, + ]); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { + $relayErr = ''; + $decoded = json_decode($response, true); + if (is_array($decoded) && !empty($decoded['error'])) $relayErr = ' — ' . $decoded['error']; + chatJson(['ok' => false, 'error' => "relay returned HTTP $status$relayErr"]); + } + + $data = json_decode($response, true) ?: []; + $messages = $data['messages'] ?? []; + $maxId = (int)($data['max_id'] ?? $since); + + $db = Database::connect(); + $newCount = 0; + $insert = $db->prepare('INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "in", :b, :rid, :ts, 0)'); + foreach ($messages as $m) { + $plain = chatDecrypt($m['body'] ?? '', $r['key']); + if ($plain === '') continue; + $insert->execute([ + 'p' => $m['sender_username'] ?? '?', + 'b' => $plain, + 'rid' => (int)$m['id'], + 'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'), + ]); + if ($insert->rowCount() > 0) $newCount++; + } + if ($maxId > $since) chatSetState('last_relay_id', $maxId); + + chatJson(['ok' => true, 'new' => $newCount]); + } + + if ($route === '/api/chat/read' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $peer = trim((string)($input['peer'] ?? '')); + if ($peer === '') chatJson(['error' => 'peer required'], 400); + $db = Database::connect(); + $db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0') + ->execute(['p' => $peer]); + chatJson(['ok' => true]); + } + + if ($route === '/api/chat/recipients' && $method === 'GET') { + $r = chatRelaySettings(); + if (!$r['url'] || !$r['user'] || !$r['key']) { + chatJson(['ok' => false, 'error' => 'relay not configured']); + } + $ch = curl_init(rtrim($r['url'], '/') . '/api/tenants'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'X-Relay-User: ' . $r['user'], + 'X-Relay-Key: ' . $r['key'], + ], + CURLOPT_TIMEOUT => 10, + ]); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($status !== 200) chatJson(['ok' => false, 'error' => "relay returned HTTP $status"]); + $data = json_decode($response, true) ?: []; + chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]); + } +}); diff --git a/public/plugins/chat/chat.css b/public/plugins/chat/chat.css new file mode 100644 index 0000000..bbe64b4 --- /dev/null +++ b/public/plugins/chat/chat.css @@ -0,0 +1,317 @@ +/* ── Chat plugin styles ── */ + +#chat-flyout { + position: fixed; + bottom: 48px; + right: 16px; + width: 340px; + height: 460px; + max-height: calc(100vh - 100px); + background: var(--t-surface); + border: 1px solid var(--t-border); + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + z-index: 100; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#chat-flyout.hidden { display: none; } + +#chat-flyout-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} + +.chat-view { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.chat-view.hidden { display: none; } + +/* ── Conversation list ── */ + +.chat-list { + flex: 1; + overflow-y: auto; +} + +.chat-list-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--t-border); + cursor: pointer; + transition: background 0.1s; +} + +.chat-list-row:hover { + background: var(--t-raised); +} + +.chat-list-row .chat-list-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--t-raised); + display: flex; + align-items: center; + justify-content: center; + color: var(--t-accent); + flex-shrink: 0; +} + +.chat-list-row .chat-list-info { + flex: 1; + min-width: 0; +} + +.chat-list-row .chat-list-peer { + font-family: var(--t-font-pixel); + font-size: 10px; + color: var(--t-bright); + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 6px; +} + +.chat-list-row .chat-list-time { + font-size: 9px; + color: var(--t-dim); + flex-shrink: 0; +} + +.chat-list-row .chat-list-preview { + font-size: 10px; + color: var(--t-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; +} + +.chat-list-row .chat-list-preview.outgoing::before { + content: "› "; + color: var(--t-dim); +} + +.chat-list-row .chat-unread-badge { + background: var(--t-accent); + color: var(--t-bg); + font-family: var(--t-font-pixel); + font-size: 9px; + padding: 1px 5px; + border-radius: 8px; + flex-shrink: 0; +} + +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; +} + +/* ── Thread view ── */ + +#chat-thread-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} + +.chat-thread-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-bubble { + max-width: 80%; + padding: 6px 10px; + font-size: 11px; + font-family: var(--t-font-mono, monospace); + word-wrap: break-word; + white-space: pre-wrap; + line-height: 1.4; + border: 1px solid var(--t-border); +} + +.chat-bubble.in { + align-self: flex-start; + background: var(--t-raised); + color: var(--t-bright); + border-top-left-radius: 0; +} + +.chat-bubble.out { + align-self: flex-end; + background: var(--t-accent); + color: var(--t-bg); + border-color: var(--t-accent); + border-top-right-radius: 0; +} + +.chat-bubble-time { + font-size: 8px; + color: var(--t-dim); + margin-top: 2px; + font-family: var(--t-font-pixel); +} + +.chat-bubble.out + .chat-bubble-time { align-self: flex-end; } +.chat-bubble.in + .chat-bubble-time { align-self: flex-start; } + +#chat-thread-form { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} + +#chat-thread-input { + flex: 1; + background: var(--t-surface); + border: 1px solid var(--t-border); + color: var(--t-bright); + padding: 6px 10px; + font-size: 11px; + font-family: var(--t-font-mono, monospace); + outline: none; +} + +#chat-thread-input:focus { + border-color: var(--t-accent); +} + +#chat-thread-send { + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; +} + +#chat-thread-send:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ── Pick recipient view ── */ + +#chat-pick-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--t-border); + background: var(--t-bg); + flex-shrink: 0; +} + +.chat-pick-list { + flex: 1; + overflow-y: auto; +} + +.chat-pick-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--t-border); + cursor: pointer; + font-family: var(--t-font-pixel); + font-size: 10px; + color: var(--t-bright); + transition: background 0.1s; +} + +.chat-pick-row:hover { + background: var(--t-raised); + color: var(--t-accent); +} + +/* ── Footer button ── */ + +#chat-footer-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 12px; + border: 1px solid var(--t-border); + color: var(--t-muted); + background: transparent; + cursor: pointer; + transition: all 0.15s; + position: relative; +} + +#chat-footer-btn:hover { + border-color: var(--t-accent); + color: var(--t-accent); +} + +#chat-footer-btn .chat-footer-label { + font-family: var(--t-font-pixel); + font-size: 10px; +} + +#chat-footer-btn.has-unread { + border-color: var(--t-accent); + color: var(--t-accent); +} + +#chat-footer-btn .chat-unread-dot { + position: absolute; + top: -3px; + right: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--t-accent); + display: none; +} + +#chat-footer-btn.has-unread .chat-unread-dot { + display: block; +} + +@keyframes chatBlink { + 0%, 100% { + border-color: var(--t-accent); + color: var(--t-accent); + box-shadow: 0 0 0 0 var(--t-accent); + } + 50% { + border-color: var(--t-accent); + color: var(--t-bright); + box-shadow: 0 0 8px 1px var(--t-accent); + } +} + +#chat-footer-btn.blinking { + animation: chatBlink 1.2s ease-in-out infinite; +} diff --git a/public/plugins/chat/chat.html b/public/plugins/chat/chat.html new file mode 100644 index 0000000..4bab0b7 --- /dev/null +++ b/public/plugins/chat/chat.html @@ -0,0 +1,44 @@ + + diff --git a/public/plugins/chat/chat.js b/public/plugins/chat/chat.js new file mode 100644 index 0000000..9598896 --- /dev/null +++ b/public/plugins/chat/chat.js @@ -0,0 +1,374 @@ +// ── Chat Plugin Frontend ── +// +// Footer button + bottom-right flyout. Polls /api/chat/poll on a loop +// (faster while open, slower while closed). Blinks the footer button +// when new incoming messages arrive while the flyout is closed. + +const hyprcrd = window.hyprcrd; +if (!hyprcrd) throw new Error('[chat plugin] window.hyprcrd missing'); + +const $ = (id) => document.getElementById(id); + +// ── State ───────────────────────────────────────────────────────── + +let conversations = []; +let currentPeer = null; +let currentMessages = []; +let pollHandle = null; +let pollIntervalMs = 20000; // closed default +let recipientCache = null; // { tenants: [...], self: '...' } +let initialPollDone = false; + +// Chat polls aggressively because the endpoint is cheap (single indexed +// SELECT with LIMIT 500). Card sync stays on its own slower interval — +// CalDAV is the expensive one. Tab-hidden pauses the timer entirely. +const POLL_OPEN_MS = 8000; +const POLL_CLOSED_MS = 20000; + +// ── Helpers ─────────────────────────────────────────────────────── + +function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function formatChatTime(iso) { + if (!iso) return ''; + const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z'); + if (isNaN(d.getTime())) return ''; + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const diffDays = (now - d) / 86400000; + if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' }); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +function isOpen() { + return !$('chat-flyout')?.classList.contains('hidden'); +} + +// ── Footer button ───────────────────────────────────────────────── + +function injectFooterButton() { + const footer = document.querySelector('footer'); + if (!footer) return; + if (document.getElementById('chat-footer-btn')) return; + + // Append next to the help button on the right side of the footer + const rightGroup = footer.querySelector('.flex.items-center.gap-2:last-of-type'); + if (!rightGroup) return; + + const btn = document.createElement('button'); + btn.id = 'chat-footer-btn'; + btn.title = 'chat'; + btn.innerHTML = ` + + chat + + `; + btn.addEventListener('click', toggleFlyout); + rightGroup.appendChild(btn); +} + +function setUnreadBadge(n) { + const btn = $('chat-footer-btn'); + if (!btn) return; + if (n > 0) { + btn.classList.add('has-unread'); + } else { + btn.classList.remove('has-unread'); + btn.classList.remove('blinking'); + } +} + +function startBlink() { + if (isOpen()) return; + $('chat-footer-btn')?.classList.add('blinking'); +} + +function stopBlink() { + $('chat-footer-btn')?.classList.remove('blinking'); +} + +// ── Flyout open / close ─────────────────────────────────────────── + +async function toggleFlyout() { + if (isOpen()) closeFlyout(); + else await openFlyout(); +} + +async function openFlyout() { + $('chat-flyout').classList.remove('hidden'); + stopBlink(); + showListView(); + pollIntervalMs = POLL_OPEN_MS; + restartPoll(); + await loadConversations(); + await pollOnce(); // immediate refresh on open + await loadConversations(); +} + +function closeFlyout() { + $('chat-flyout').classList.add('hidden'); + pollIntervalMs = POLL_CLOSED_MS; + restartPoll(); +} + +// ── Views ───────────────────────────────────────────────────────── + +function showListView() { + $('chat-list-view').classList.remove('hidden'); + $('chat-thread-view').classList.add('hidden'); + $('chat-pick-view').classList.add('hidden'); + $('chat-flyout-title').textContent = 'chat'; + currentPeer = null; +} + +function showThreadView(peer) { + $('chat-list-view').classList.add('hidden'); + $('chat-thread-view').classList.remove('hidden'); + $('chat-pick-view').classList.add('hidden'); + $('chat-flyout-title').textContent = 'chat'; + $('chat-thread-peer').textContent = peer; + currentPeer = peer; +} + +function showPickView() { + $('chat-list-view').classList.add('hidden'); + $('chat-thread-view').classList.add('hidden'); + $('chat-pick-view').classList.remove('hidden'); +} + +// ── Conversation list ───────────────────────────────────────────── + +async function loadConversations() { + const res = await fetch('api/chat/conversations').then(r => r.json()).catch(() => null); + if (!res) return; + conversations = res.conversations || []; + setUnreadBadge(res.total_unread || 0); + renderConversations(); +} + +function renderConversations() { + const list = $('chat-list'); + const empty = $('chat-empty'); + if (!list || !empty) return; + if (!conversations.length) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + list.innerHTML = conversations.map(c => { + const initial = (c.peer || '?').charAt(0).toUpperCase(); + const unreadBadge = parseInt(c.unread) > 0 + ? `${c.unread}` : ''; + const previewClass = c.last_direction === 'out' ? 'chat-list-preview outgoing' : 'chat-list-preview'; + return ` +
+
${escapeHtml(initial)}
+
+
+ ${escapeHtml(c.peer)} + ${escapeHtml(formatChatTime(c.last_at))} +
+
${escapeHtml((c.last_body || '').slice(0, 80))}
+
+ ${unreadBadge} +
+ `; + }).join(''); + + list.querySelectorAll('.chat-list-row').forEach(row => { + row.addEventListener('click', () => openConversation(row.dataset.peer)); + }); +} + +// ── Thread ──────────────────────────────────────────────────────── + +async function openConversation(peer) { + showThreadView(peer); + await loadThread(peer); + // Mark all incoming as read + await hyprcrd.api('api/chat/read', { method: 'POST', body: { peer } }).catch(() => {}); + // Refresh badges in the background + loadConversations(); + setTimeout(() => $('chat-thread-input')?.focus(), 50); +} + +async function loadThread(peer) { + const res = await fetch('api/chat/messages?peer=' + encodeURIComponent(peer)) + .then(r => r.json()).catch(() => null); + if (!res) return; + currentMessages = res.messages || []; + renderThread(); +} + +function renderThread() { + const wrap = $('chat-thread-messages'); + if (!wrap) return; + wrap.innerHTML = currentMessages.map(m => { + const cls = m.direction === 'out' ? 'out' : 'in'; + return ` +
${escapeHtml(m.body)}
+
${escapeHtml(formatChatTime(m.sent_at))}
+ `; + }).join(''); + // Scroll to bottom + wrap.scrollTop = wrap.scrollHeight; +} + +async function sendMessage(e) { + e?.preventDefault(); + const input = $('chat-thread-input'); + const body = input.value.trim(); + if (!body || !currentPeer) return; + const sendBtn = $('chat-thread-send'); + sendBtn.disabled = true; + input.disabled = true; + + try { + const res = await hyprcrd.api('api/chat/send', { + method: 'POST', + body: { peer: currentPeer, body }, + }); + if (!res.ok) { + alert('Send failed: ' + (res.error || 'unknown')); + return; + } + input.value = ''; + await loadThread(currentPeer); + await loadConversations(); + } catch (err) { + alert('Send error: ' + err.message); + } finally { + sendBtn.disabled = false; + input.disabled = false; + input.focus(); + } +} + +// ── Pick recipient ──────────────────────────────────────────────── + +async function openPickView() { + showPickView(); + const list = $('chat-pick-list'); + list.innerHTML = '
loading...
'; + + if (!recipientCache) { + const res = await fetch('api/chat/recipients').then(r => r.json()).catch(() => null); + if (!res?.ok) { + list.innerHTML = `
${escapeHtml(res?.error || 'failed to load recipients')}
`; + return; + } + recipientCache = res; + } + + const tenants = recipientCache.tenants || []; + const self = recipientCache.self; + const others = tenants.filter(t => t.username !== self); + if (!others.length) { + list.innerHTML = '
no other users on the relay
'; + return; + } + list.innerHTML = others.map(t => ` +
+ + ${escapeHtml(t.username)} +
+ `).join(''); + list.querySelectorAll('.chat-pick-row').forEach(row => { + row.addEventListener('click', () => openConversation(row.dataset.peer)); + }); +} + +// ── Polling ─────────────────────────────────────────────────────── + +async function pollOnce() { + const icon = $('chat-refresh-icon'); + if (icon && isOpen()) icon.classList.add('card-spinner'); + try { + const res = await hyprcrd.api('api/chat/poll', { method: 'POST' }).catch(() => null); + if (!res?.ok) return; + const newCount = res.new || 0; + if (newCount > 0) { + await loadConversations(); + // If we're inside a thread for any peer, refresh + auto-read it + if (isOpen() && currentPeer) { + await loadThread(currentPeer); + hyprcrd.api('api/chat/read', { method: 'POST', body: { peer: currentPeer } }) + .then(() => loadConversations()).catch(() => {}); + } + // Blink if the flyout is closed + if (!isOpen()) startBlink(); + } else if (initialPollDone === false) { + // First poll: refresh badges in case we have unread from a previous session + await loadConversations(); + } + initialPollDone = true; + } finally { + if (icon) icon.classList.remove('card-spinner'); + } +} + +function restartPoll() { + if (pollHandle) clearInterval(pollHandle); + pollHandle = setInterval(pollOnce, pollIntervalMs); +} + +// ── Init ────────────────────────────────────────────────────────── + +function init() { + if (!$('chat-flyout')) return; + + injectFooterButton(); + + $('chat-close-btn').addEventListener('click', closeFlyout); + $('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations)); + $('chat-new-btn').addEventListener('click', openPickView); + $('chat-empty-start')?.addEventListener('click', openPickView); + $('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); }); + $('chat-pick-back').addEventListener('click', showListView); + $('chat-thread-form').addEventListener('submit', sendMessage); + + // Esc closes flyout when focus is inside it + $('chat-flyout').addEventListener('keydown', e => { + if (e.key === 'Escape') { e.stopPropagation(); closeFlyout(); } + }); + + // Defer the first POST (poll) until auth has set csrf. GETs are fine immediately. + loadConversations().catch(() => {}); + const startPolling = () => { + pollOnce().catch(() => {}); + restartPoll(); + }; + if (hyprcrd.util.csrf?.()) { + startPolling(); + } else { + hyprcrd.hooks.on('csrf.ready', startPolling); + } + + // Pause polling when the page is hidden + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } + } else { + pollOnce().catch(() => {}); + restartPoll(); + } + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +hyprcrd.registerPlugin?.({ + name: 'chat', + version: '1.0.0', + description: 'Encrypted chat between relay tenants', +}); diff --git a/public/plugins/chat/plugin.json b/public/plugins/chat/plugin.json new file mode 100644 index 0000000..fd344f0 --- /dev/null +++ b/public/plugins/chat/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "chat", + "version": "1.0.0", + "description": "Encrypted chat between relay tenants. Footer flyout that blinks on new messages.", + "frontend": { + "js": ["chat.js"], + "css": ["chat.css"], + "html": ["chat.html"] + }, + "backend": "backend.php" +} diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index 753f8ad..1e0db8c 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -164,6 +164,23 @@ $db->exec('CREATE TABLE IF NOT EXISTS attachments ( FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE )'); +// Chat: separate from `messages` so chat traffic doesnt land in card inboxes. +// Same decrypt-reencrypt hop as messages: arrives encrypted with senders +// secret, stored at-rest under the inter-tenant key, returned re-encrypted +// with the recipients secret. +$db->exec('CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_id INTEGER NOT NULL, + sender_id INTEGER, + sender_username TEXT, + body TEXT NOT NULL, + fetched INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)'); + $db->exec('CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, tenant_id INTEGER NOT NULL, @@ -181,6 +198,8 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits ( // Cleanup: 24h ephemeral storage, expired sessions, old rate limits $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"); $db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')"); +// Chat: 7 days, longer than card sync but still ephemeral +$db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')"); $db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')"); $db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')"); diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php new file mode 100644 index 0000000..ba742d6 --- /dev/null +++ b/relay/inc/routes/chat.php @@ -0,0 +1,82 @@ + N +// +// Same decrypt-reencrypt hop as messages.php: arrives encrypted with the +// senders client secret, stored at-rest under the inter-tenant key, returned +// re-encrypted with the recipients client secret. Relay sees plaintext only +// during the routing hop. + +if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } + +// $clientTenant is set by messages.php earlier in the chain via authClient(). +// We re-resolve here to keep this file self-contained. +$clientTenant = $clientTenant ?? authClient($db); + +if ($route === '/api/chat' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $encryptedBody = (string)($input['body'] ?? ''); + $recipientName = trim((string)($input['recipient'] ?? '')); + if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400); + if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); + + $recipient = getTenantByUsername($db, $recipientName); + if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); + + 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); + + $plain = clientDecrypt($encryptedBody, $senderSecret); + if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400); + $stored = clientEncrypt($plain, $interKey); + + $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)') + ->execute([ + 'r' => $recipient['id'], + 's' => $clientTenant['id'], + 'sn' => $clientTenant['username'], + 'b' => $stored, + ]); + jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); +} + +if ($route === '/api/chat' && $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 = (int)($_GET['since'] ?? 0); + $stmt = $db->prepare('SELECT id, sender_username, body, created_at FROM chat_messages WHERE recipient_id = :r AND id > :s ORDER BY id ASC LIMIT 500'); + $stmt->execute(['r' => $clientTenant['id'], 's' => $since]); + $rows = $stmt->fetchAll(); + + $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 = []; + $maxId = $since; + foreach ($rows as $row) { + $plain = clientDecrypt($row['body'] ?? '', $interKey); + $row['body'] = clientEncrypt($plain, $recipientSecret); + $out[] = $row; + if ((int)$row['id'] > $maxId) $maxId = (int)$row['id']; + } + // Mark fetched so cleanup can prune them sooner if desired. We still + // keep them around for the 7-day window so a re-poll with since=0 + // backfills history if a client wipes its local cache. + if ($out) { + $db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0') + ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); + } + jsonResponse(['messages' => $out, 'max_id' => $maxId]); +} diff --git a/relay/index.php b/relay/index.php index ac4ecda..98a45df 100644 --- a/relay/index.php +++ b/relay/index.php @@ -61,6 +61,7 @@ if (str_starts_with($route, '/api/')) { require __DIR__ . '/inc/routes/account.php'; require __DIR__ . '/inc/routes/admin.php'; require __DIR__ . '/inc/routes/messages.php'; + require __DIR__ . '/inc/routes/chat.php'; jsonResponse(['error' => 'not found'], 404); }