405 lines
20 KiB
PHP
405 lines
20 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') {
|
|
// 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.
|
|
});
|