Files
hyprcrd/public/plugins/support/backend.php
T
thandClaude Opus 4.7 7bce6a3a36 v2.0.12 — support channel: relay + hyprcrd plugin
Central support desk for the Oumta project. Tickets arrive through a
self-hostable support-relay (public form, SMTP out), hyprcrd pulls
them in as cards, replies go back out through the relay, and KB
fragments are plain hyprcrd cards so the board + search + tags
machinery handles them for free.

support-relay/ (standalone PHP/SQLite service)
- Public form + submit endpoint with IP rate-limit, honeypot, and an
  email-matched reply path so users can follow up by typing their
  OUMTA-n issue id — mismatched email falls back to a fresh ticket
  so a hostile submitter can't hijack someone else's thread.
- One-shot setup wizard configures branding, issue-id prefix, public
  URL, mail footer template, SMTP creds (stored AES-256-GCM at rest),
  and generates the X-Support-Key API token shown exactly once.
- Hyprcrd-facing API: GET /api/hyprcrd/tickets?since=<iso>, POST
  …/reply (sends via SMTP, flips status to answered), PATCH
  …/status, DELETE, GET /ping, plus GET/POST /api/hyprcrd/settings
  exposing brand/public_url/mail_footer for post-setup edits.
- Minimal dependency-free SMTP client covering STARTTLS/TLS/none,
  AUTH LOGIN, RFC 2047 subject encoding, dot-stuffing. Extended to
  emit multipart/alternative (plain+HTML) + multipart/related so
  outbound mails carry the branded logo via Content-ID embedding;
  plain-text fallback stays intact for older clients.
- Mail footer template supports {brand}, {issue_id}, {public_url}
  placeholders. Lines with an empty placeholder are dropped, so the
  "Reply via …" line vanishes cleanly when no public URL is set.
- Favicon on all public pages. Logo (SVG by default, PNG preferred
  when both exist) picked up from logo2026col.* at send time.
- Docker: Dockerfile + docker-compose.yml for a stand-alone test
  container, plus a support-relay service in the main compose
  (profile "support") so hyprcrd can reach it at http://support-relay
  without host-networking shenanigans.

public/plugins/support (hyprcrd client)
- Full-screen Support overlay: ticket list (status-filterable),
  active ticket thread, reply composer with KB-fragment search +
  click-to-insert, "Save as fragment" button, "Close ticket" shortcut.
- KB fragments are regular cards in a configurable bucket. The
  composer just adds a fast search-and-insert path on top.
- Sync-tick hook auto-polls the relay alongside the existing CalDAV
  + relay-mail cycle.
- Settings tab in the main overlay: relay URL, API key, inbox / KB
  bucket pickers, connection test, and a "Relay config" subsection
  that round-trips to /api/hyprcrd/settings so the mail footer and
  branding can be edited without touching the relay's DB.
- Local cache tables support_tickets + support_replies mirror the
  relay's authoritative data keyed by relay_id.

Tooling
- dev.sh now opts into both relay + support profiles.
- dev-down.sh wipes support-relay volumes and bind-mount data for a
  clean re-setup. Hyprcrd + relay data is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:41:19 +02:00

399 lines
19 KiB
PHP

<?php
/**
* Support Plugin: backend (hyprcrd side).
*
* Talks to a standalone support-relay via HTTP. Tickets live on the
* relay; this plugin keeps a local cache + links each ticket to a
* hyprcrd card so the user can answer from the card detail view.
* KB fragments stay purely in hyprcrd (cards in a configured bucket).
*
* Routes (auth-protected, registered via the plugin dispatch):
* GET /api/support/tickets list from local cache
* GET /api/support/tickets/{id} single ticket w/ replies
* POST /api/support/tickets/{id}/reply forward reply to the relay
* PATCH /api/support/tickets/{id}/status forward status change
* POST /api/support/poll pull tickets from the relay
* GET /api/support/kb?q=... search KB fragments (local cards)
* POST /api/support/kb save a new KB fragment
* GET /api/support/settings read plugin config (no secret)
* POST /api/support/settings write plugin config
* POST /api/support/settings/test ping the configured relay
*/
// ── Schema ────────────────────────────────────────────────────────
(function () {
$db = Database::connect();
// Local cache of tickets that live on the relay. We map relay_id
// (authoritative) to hyprcrd card_id so the card detail view can
// surface the ticket + its reply history.
$db->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') {
$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.
});