exec(' CREATE TABLE IF NOT EXISTS support_tickets ( relay_id INTEGER PRIMARY KEY, card_id INTEGER, issue_id TEXT NOT NULL, email TEXT NOT NULL, subject TEXT NOT NULL DEFAULT "", status TEXT NOT NULL DEFAULT "open", message_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_reply_at TEXT, polled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) '); $db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_card ON support_tickets(card_id)'); $db->exec(' CREATE TABLE IF NOT EXISTS support_replies ( relay_id INTEGER PRIMARY KEY, ticket_id INTEGER NOT NULL, direction TEXT NOT NULL, body TEXT NOT NULL, sent_at TEXT, created_at TEXT NOT NULL, FOREIGN KEY (ticket_id) REFERENCES support_tickets(relay_id) ON DELETE CASCADE ) '); $db->exec('CREATE INDEX IF NOT EXISTS idx_support_replies_ticket ON support_replies(ticket_id, created_at)'); })(); // ── Encryption (API key at rest) ────────────────────────────────── function supportEncKey(): string { $db = Database::connect(); $vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); if ($vk) return substr(hash('sha256', $vk), 0, 32); $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32); } function supportEncrypt(string $plain): string { $iv = random_bytes(16); $cipher = openssl_encrypt($plain, 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, $iv); return base64_encode($iv . $cipher); } function supportDecrypt(string $encoded): string { $raw = base64_decode($encoded); if (!$raw || strlen($raw) < 17) return ''; return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: ''; } function supportSetting(string $key, ?string $default = null): ?string { $db = Database::connect(); $stmt = $db->prepare('SELECT value FROM settings WHERE key = :k'); $stmt->execute(['k' => 'support_' . $key]); $v = $stmt->fetchColumn(); return $v === false ? $default : $v; } function supportSetSetting(string $key, ?string $value): void { $db = Database::connect(); if ($value === null) { $db->prepare('DELETE FROM settings WHERE key = :k')->execute(['k' => 'support_' . $key]); } else { $db->prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)') ->execute(['k' => 'support_' . $key, 'v' => $value]); } } function supportRelayUrl(): string { return rtrim((string)supportSetting('relay_url', ''), '/'); } function supportRelayKey(): string { $enc = supportSetting('relay_key_enc', ''); return $enc ? supportDecrypt($enc) : ''; } function supportJson($data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; } /** cURL helper for the support-relay. Short timeouts — this runs inline * from user-triggered flows. Responses are returned as the decoded body * plus the HTTP status so callers can surface errors. */ function supportCurl(string $path, string $method = 'GET', ?array $body = null): array { $url = supportRelayUrl() . $path; $key = supportRelayKey(); if ($url === '' || $key === '') return ['status' => 0, 'error' => 'support relay not configured', 'body' => null]; $ch = curl_init($url); $headers = ['X-Support-Key: ' . $key, 'Accept: application/json']; $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 10, CURLOPT_CUSTOMREQUEST => $method, ]; if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($body); $opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; } curl_setopt_array($ch, $opts); if (function_exists('hyprcrdApplySafeCurlOpts')) hyprcrdApplySafeCurlOpts($ch); $resp = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($resp === false) return ['status' => 0, 'error' => $err ?: 'request failed', 'body' => null]; $decoded = json_decode($resp, true); return ['status' => $status, 'body' => $decoded, 'raw' => $resp, 'error' => null]; } /** Apply a relay-side ticket (+ replies) into the local cache, creating * or updating the linked hyprcrd card. */ function supportApplyTicket(array $t): void { $db = Database::connect(); $relayId = (int)$t['id']; $issueId = (string)($t['issue_id'] ?? ''); $email = strtolower((string)($t['email'] ?? '')); $subject = (string)($t['subject'] ?? ''); $status = (string)($t['status'] ?? 'open'); $body = (string)($t['body'] ?? ''); // Look up existing mapping $ex = $db->prepare('SELECT card_id FROM support_tickets WHERE relay_id = :r'); $ex->execute(['r' => $relayId]); $cardId = (int)($ex->fetchColumn() ?: 0); // Compose the card body from the ticket + reply log (relay is // authoritative on replies too). $replies = $t['replies'] ?? []; $rendered = "**From:** " . $email . "\n" . "**Issue:** " . $issueId . "\n" . "**Status:** " . $status . "\n\n---\n\n" . $body; foreach ($replies as $r) { if (($r['direction'] ?? '') === 'in' && ($r['body'] ?? '') === $body) continue; // skip initial dupe $label = ($r['direction'] ?? '') === 'out' ? 'Reply sent' : 'User reply'; $when = (string)($r['created_at'] ?? ''); $rendered .= "\n\n---\n\n**{$label} — {$when}:**\n\n" . ($r['body'] ?? ''); } $title = $subject !== '' ? $subject : '(no subject)'; $tagSet = 'support,' . $status; $buckets = ['inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0)]; $bucketId = $buckets['inbox'] ?: null; if ($cardId) { $db->prepare('UPDATE cards SET title = :t, body = :b, tags = :tg, updated_at = datetime("now") WHERE id = :id') ->execute(['t' => $title, 'b' => $rendered, 'tg' => $tagSet, 'id' => $cardId]); } else { $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)') ->execute(['t' => $title, 'b' => $rendered, 'c' => '#38bdf8', 'bi' => $bucketId, 'tg' => $tagSet]); $cardId = (int)$db->lastInsertId(); } $db->prepare(' INSERT INTO support_tickets (relay_id, card_id, issue_id, email, subject, status, message_id, created_at, updated_at, last_reply_at, polled_at) VALUES (:r, :c, :ii, :e, :s, :st, :mid, :ca, :ua, :lr, datetime("now")) ON CONFLICT(relay_id) DO UPDATE SET card_id = excluded.card_id, issue_id = excluded.issue_id, email = excluded.email, subject = excluded.subject, status = excluded.status, message_id = excluded.message_id, updated_at = excluded.updated_at, last_reply_at = excluded.last_reply_at, polled_at = datetime("now") ')->execute([ 'r' => $relayId, 'c' => $cardId, 'ii' => $issueId, 'e' => $email, 's' => $subject, 'st' => $status, 'mid' => $t['message_id'] ?? null, 'ca' => (string)($t['created_at'] ?? date('Y-m-d H:i:s')), 'ua' => (string)($t['updated_at'] ?? date('Y-m-d H:i:s')), 'lr' => $t['last_reply_at'] ?? null, ]); // Replies: straight upsert by relay reply id. Lets the local card // body stay in sync no matter which side added the reply. $db->prepare('DELETE FROM support_replies WHERE ticket_id = :t')->execute(['t' => $relayId]); $ins = $db->prepare('INSERT INTO support_replies (relay_id, ticket_id, direction, body, sent_at, created_at) VALUES (:r, :t, :d, :b, :sa, :ca)'); foreach ($replies as $r) { $ins->execute([ 'r' => (int)($r['id'] ?? 0) ?: null, 't' => $relayId, 'd' => (string)($r['direction'] ?? 'in'), 'b' => (string)($r['body'] ?? ''), 'sa' => $r['sent_at'] ?? null, 'ca' => (string)($r['created_at'] ?? date('Y-m-d H:i:s')), ]); } } // ── Routes ──────────────────────────────────────────────────────── PluginManager::instance()->onApiRoute(function (string $route, string $method) { $db = Database::connect(); if ($route === '/api/support/tickets' && $method === 'GET') { $status = trim((string)($_GET['status'] ?? '')); $sql = 'SELECT t.*, c.title AS card_title FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id'; $params = []; if ($status !== '') { $sql .= ' WHERE t.status = :s'; $params['s'] = $status; } $sql .= ' ORDER BY COALESCE(t.last_reply_at, t.updated_at) DESC LIMIT 500'; $stmt = $db->prepare($sql); $stmt->execute($params); supportJson(['tickets' => $stmt->fetchAll()]); } if (preg_match('#^/api/support/tickets/(\d+)$#', $route, $m) && $method === 'GET') { $relayId = (int)$m[1]; $stmt = $db->prepare('SELECT t.*, c.title AS card_title, c.body AS card_body FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id WHERE t.relay_id = :r'); $stmt->execute(['r' => $relayId]); $t = $stmt->fetch(); if (!$t) supportJson(['error' => 'not found'], 404); $reps = $db->prepare('SELECT direction, body, sent_at, created_at FROM support_replies WHERE ticket_id = :t ORDER BY created_at ASC'); $reps->execute(['t' => $relayId]); $t['replies'] = $reps->fetchAll(); supportJson(['ticket' => $t]); } if (preg_match('#^/api/support/tickets/(\d+)/reply$#', $route, $m) && $method === 'POST') { $relayId = (int)$m[1]; $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); if ($body === '') supportJson(['error' => 'body required'], 400); $res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/reply', 'POST', ['body' => $body]); if ($res['status'] !== 200) { supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error') . ' (HTTP ' . $res['status'] . ')'], 502); } // Re-fetch the updated ticket so the local cache immediately reflects // the new reply + answered status. $fresh = supportCurl('/api/hyprcrd/tickets?since=', 'GET'); if ($fresh['status'] === 200 && is_array($fresh['body']['tickets'] ?? null)) { foreach ($fresh['body']['tickets'] as $t) { if ((int)$t['id'] === $relayId) { supportApplyTicket($t); break; } } } supportJson(['ok' => true]); } if (preg_match('#^/api/support/tickets/(\d+)/status$#', $route, $m) && $method === 'PATCH') { $relayId = (int)$m[1]; $input = json_decode(file_get_contents('php://input'), true) ?: []; $status = trim((string)($input['status'] ?? '')); if (!in_array($status, ['open', 'answered', 'closed'], true)) supportJson(['error' => 'invalid status'], 400); $res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/status', 'PATCH', ['status' => $status]); if ($res['status'] !== 200) supportJson(['error' => 'relay error ' . $res['status']], 502); $db->prepare('UPDATE support_tickets SET status = :s WHERE relay_id = :r')->execute(['s' => $status, 'r' => $relayId]); // Sync the card's support tag set $db->prepare(" UPDATE cards SET tags = ( SELECT 'support,' || :s FROM support_tickets WHERE card_id = cards.id ) WHERE id = (SELECT card_id FROM support_tickets WHERE relay_id = :r) ")->execute(['s' => $status, 'r' => $relayId]); supportJson(['ok' => true]); } if ($route === '/api/support/poll' && $method === 'POST') { // Mirrors /api/relay/poll: an unconfigured support relay is a normal // resting state, not an error. Returning 502 here floods the network // tab on every background poll for users who don't use the plugin. if (supportRelayUrl() === '' || supportRelayKey() === '') { supportJson(['ok' => true, 'pulled' => 0]); } $last = supportSetting('last_polled_at', '') ?: ''; $qs = $last !== '' ? '?since=' . urlencode($last) : ''; $res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET'); if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error'), 'status' => $res['status']], 502); $tickets = $res['body']['tickets'] ?? []; $maxUpd = $last; foreach ($tickets as $t) { supportApplyTicket($t); if (!empty($t['updated_at']) && $t['updated_at'] > $maxUpd) $maxUpd = $t['updated_at']; } if ($maxUpd !== $last) supportSetSetting('last_polled_at', $maxUpd); supportJson(['ok' => true, 'pulled' => count($tickets)]); } // KB search (local cards in the configured KB bucket) if ($route === '/api/support/kb' && $method === 'GET') { $q = trim((string)($_GET['q'] ?? '')); $bucket = (int)(supportSetting('bucket_kb', '0') ?: 0); if (!$bucket) supportJson(['fragments' => []]); $sql = 'SELECT id, title, body, tags, updated_at FROM cards WHERE bucket_id = :b'; $params = ['b' => $bucket]; if ($q !== '') { $sql .= ' AND (title LIKE :q OR body LIKE :q2 OR tags LIKE :q3)'; $like = '%' . $q . '%'; $params['q'] = $like; $params['q2'] = $like; $params['q3'] = $like; } $sql .= ' ORDER BY updated_at DESC LIMIT 50'; $stmt = $db->prepare($sql); $stmt->execute($params); supportJson(['fragments' => $stmt->fetchAll()]); } if ($route === '/api/support/kb' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $title = trim((string)($input['title'] ?? '')); $body = trim((string)($input['body'] ?? '')); $tags = trim((string)($input['tags'] ?? 'faq')); if ($body === '') supportJson(['error' => 'body required'], 400); if ($title === '') $title = mb_substr(str_replace("\n", ' ', $body), 0, 50); $bucket = (int)(supportSetting('bucket_kb', '0') ?: 0); if (!$bucket) supportJson(['error' => 'KB bucket not configured'], 400); $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)') ->execute(['t' => $title, 'b' => $body, 'c' => '#4ade80', 'bi' => $bucket, 'tg' => $tags]); supportJson(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/support/settings' && $method === 'GET') { supportJson([ 'relay_url' => supportRelayUrl(), 'has_key' => supportRelayKey() !== '', 'bucket_inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0), 'bucket_kb' => (int)(supportSetting('bucket_kb', '0') ?: 0), 'last_polled_at' => supportSetting('last_polled_at', ''), ]); } if ($route === '/api/support/settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; if (array_key_exists('relay_url', $input)) supportSetSetting('relay_url', rtrim((string)$input['relay_url'], '/')); if (array_key_exists('relay_key', $input)) { $k = (string)$input['relay_key']; if ($k === '') supportSetSetting('relay_key_enc', null); else supportSetSetting('relay_key_enc', supportEncrypt($k)); } if (array_key_exists('bucket_inbox', $input)) supportSetSetting('bucket_inbox', (int)$input['bucket_inbox'] ?: null); if (array_key_exists('bucket_kb', $input)) supportSetSetting('bucket_kb', (int)$input['bucket_kb'] ?: null); supportJson(['ok' => true]); } if ($route === '/api/support/settings/test' && $method === 'POST') { $res = supportCurl('/api/hyprcrd/ping', 'GET'); if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'ping failed'), 'status' => $res['status']], 502); supportJson(['ok' => true, 'brand' => $res['body']['brand'] ?? '']); } // Proxy to the relay's editable-settings endpoint. The plugin UI // uses this to let the admin tweak the mail footer / public URL / // brand post-setup without touching the relay's data directly. if ($route === '/api/support/relay-settings' && $method === 'GET') { $res = supportCurl('/api/hyprcrd/settings', 'GET'); if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay unreachable'), 'status' => $res['status']], 502); supportJson($res['body']); } if ($route === '/api/support/relay-settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?: []; $res = supportCurl('/api/hyprcrd/settings', 'POST', $input); if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay update failed'), 'status' => $res['status']], 502); supportJson(['ok' => true]); } }); // Hook into the central sync.tick so support polls happen alongside the // CalDAV + relay-mail cycle. Runs server-side triggered from the client // — no separate cron needed. If the relay isn't configured the call // silently no-ops. PluginManager::instance()->onApiRoute(function (string $route, string $method) { // Tie the poll to the main relay poll: when hyprcrd fires /api/relay/poll // the client also fires /api/support/poll. No server-side hook runs // here, we just expose the endpoint. See support.js. });