diff --git a/config.php b/config.php index 36dd2ef..3ff8a15 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ /dev/null || true +fi + +# Stop main dev stack across all profiles. `down` without -v preserves +# the named volumes (hyprcrd + relay data). +docker compose --profile relay --profile support down --remove-orphans + +# Wipe support-relay storage — both the compose-managed named volume +# and the stand-alone bind-mount location. Match volumes by suffix so +# whatever compose-project-name was in effect (hyprcrd / something else) +# we find it. +for vol in $(docker volume ls -q | grep -E '(^|_)support-relay-data$' || true); do + docker volume rm -f "$vol" >/dev/null + echo " wiped volume: $vol" +done +if [ -d support-relay/data ]; then + rm -f support-relay/data/*.db support-relay/data/*.db-* support-relay/data/master.key 2>/dev/null || true + echo " wiped bind-mount: support-relay/data/" +fi + +echo "" +echo " all containers stopped." +echo " support-relay data gone — next boot runs the setup wizard again" +echo " and mints a fresh API key." +echo " hyprcrd + relay data preserved." diff --git a/dev.sh b/dev.sh index 5c06de1..789b904 100755 --- a/dev.sh +++ b/dev.sh @@ -1,17 +1,21 @@ #!/bin/bash -# Start hyprcrd + relay for local development. The relay service is -# behind a compose profile since it's opt-in — this script explicitly -# opts in so you get both containers. +# Start hyprcrd + relay + support-relay for local development. Both +# auxiliary services sit behind compose profiles — this script opts +# into all of them so a dev can exercise the full sync + support flows +# without juggling profile flags. cd "$(dirname "$0")" -docker compose --profile relay up -d --build +#docker compose --profile relay --profile support up -d --build +docker compose --profile support up -d --build echo "" -echo " hyprcrd: http://localhost:8088" -echo " relay: http://localhost:8081" +echo " hyprcrd: http://localhost:8088" +#echo " relay: http://localhost:8081" +echo " support-relay: http://localhost:8090" echo "" -echo " relay from hyprcrd container: http://relay:80" +#echo " relay from hyprcrd container: http://relay:80" +echo " support-relay from hyprcrd container: http://support-relay:80" echo "" -echo " (for app-only, drop the relay profile: docker compose up -d)" +echo " (for app-only, drop the profiles: docker compose up -d)" echo "" diff --git a/docker-compose.yml b/docker-compose.yml index 769a813..f3bc170 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,5 +25,19 @@ services: - ./relay/ui:/var/www/html/ui - relay-data:/var/www/html/data + # Support-relay also opt-in via its own profile. Shares the compose + # network with the app so hyprcrd's support plugin can reach it at + # `http://support-relay:80` (container-to-container, no host port + # needed). Port 8090 is still published so you can open the public + # form in a browser. + support-relay: + build: ./support-relay + profiles: ["support"] + ports: + - "8090:80" + volumes: + - support-relay-data:/var/www/html/data + volumes: relay-data: + support-relay-data: diff --git a/electron/package.json b/electron/package.json index 7764831..103e7ab 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "hyprcrd", - "version": "2.0.10", + "version": "2.0.12", "description": "hyprcrd — self-hosted card manager", "main": "main.js", "scripts": { diff --git a/public/plugins/support/backend.php b/public/plugins/support/backend.php new file mode 100644 index 0000000..ecc74d5 --- /dev/null +++ b/public/plugins/support/backend.php @@ -0,0 +1,398 @@ +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. +}); diff --git a/public/plugins/support/plugin.json b/public/plugins/support/plugin.json new file mode 100644 index 0000000..91def21 --- /dev/null +++ b/public/plugins/support/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "support", + "version": "1.0.0", + "description": "Central support channel: public form for incoming tickets (OUMTA-nnn issue IDs, email-matched replies), plugin inbox with a reply composer, KB-fragment library. Outbound replies via configured SMTP.", + "frontend": { + "js": ["support.js"], + "css": ["support.css"], + "html": ["support.html"] + }, + "backend": "backend.php" +} diff --git a/public/plugins/support/support.css b/public/plugins/support/support.css new file mode 100644 index 0000000..ef93a4b --- /dev/null +++ b/public/plugins/support/support.css @@ -0,0 +1,265 @@ +/* Support overlay. Full-screen like chat / email / canvas. Dark chrome, + * two-column layout on desktop, stacked on narrow. Inbox left, ticket + * detail + reply composer + KB picker on the right. */ + +.support-overlay { + position: fixed; + inset: 0; + z-index: 90; + background: var(--t-bg); + display: none; + flex-direction: column; +} +.support-overlay.open { display: flex; } + +.support-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 10px 16px; + border-bottom: 1px solid var(--t-border); + background: var(--t-surface); +} +.support-header-left, .support-header-right { display: flex; align-items: center; gap: 10px; } +.support-header i { font-size: 16px; color: var(--t-accent); } + +.support-btn { + background: transparent; + border: 1px solid var(--t-border); + color: var(--t-muted); + cursor: pointer; + border-radius: 3px; + padding: 6px 10px; + font-size: 12px; + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + transition: all 0.12s; +} +.support-btn:hover { border-color: var(--t-accent); color: var(--t-accent); } +.support-btn-primary { + background: var(--t-accent); + color: var(--t-bg); + border-color: var(--t-accent); +} +.support-btn-primary:hover { filter: brightness(1.08); color: var(--t-bg); } +.support-btn-danger:hover { border-color: var(--t-danger); color: var(--t-danger); } +.support-btn-ghost { + width: 30px; height: 30px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + font-size: 14px; +} + +.support-filter { display: flex; gap: 4px; } +.support-pill { + background: transparent; + border: 1px solid var(--t-border); + color: var(--t-muted); + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 10px; + cursor: pointer; + border-radius: 3px; + transition: all 0.12s; +} +.support-pill:hover { color: var(--t-bright); } +.support-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); } + +.support-body { + flex: 1; + display: grid; + grid-template-columns: 320px 1fr; + min-height: 0; +} +@media (max-width: 800px) { + .support-body { grid-template-columns: 1fr; } + .support-list { max-height: 40vh; } + .support-list.hidden-mobile { display: none; } +} + +.support-list { + border-right: 1px solid var(--t-border); + overflow-y: auto; + background: var(--t-bg); +} +.support-ticket { + padding: 12px 14px; + border-bottom: 1px solid var(--t-border); + cursor: pointer; + transition: background 0.1s; + display: flex; + flex-direction: column; + gap: 4px; +} +.support-ticket:hover { background: var(--t-surface); } +.support-ticket.active { background: color-mix(in srgb, var(--t-accent) 12%, transparent); border-left: 3px solid var(--t-accent); padding-left: 11px; } +.support-ticket-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.support-ticket-issue { + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 9px; + color: var(--t-accent); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.support-ticket-status { + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 8px; + padding: 1px 5px; + border-radius: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.support-ticket-status[data-status="open"] { background: rgba(251, 146, 60, 0.2); color: #fb923c; } +.support-ticket-status[data-status="answered"] { background: rgba(74, 222, 128, 0.15); color: #4ade80; } +.support-ticket-status[data-status="closed"] { background: rgba(100, 116, 139, 0.2); color: var(--t-dim); } +.support-ticket-title { color: var(--t-bright); font-size: 13px; line-height: 1.3; } +.support-ticket-meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 8px; } + +.support-detail { + overflow-y: auto; + padding: 20px 24px 40px; + display: flex; + flex-direction: column; + gap: 18px; +} +.support-empty, .support-empty-hero { + color: var(--t-dim); + text-align: center; + padding: 40px 20px; + font-size: 13px; +} +.support-empty-hero i { font-size: 48px; display: block; margin: 0 auto 12px; color: var(--t-muted); } + +.support-ticket-header h2 { font-family: var(--t-font-pixel); color: var(--t-bright); font-size: 14px; margin: 0 0 4px; } +.support-ticket-header .meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; } +.support-ticket-header .email { color: var(--t-text); } + +.support-thread { display: flex; flex-direction: column; gap: 10px; } +.support-msg { + background: var(--t-surface); + border: 1px solid var(--t-border); + border-radius: 4px; + padding: 12px 14px; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.5; +} +.support-msg[data-dir="out"] { + border-left: 3px solid var(--t-accent); + padding-left: 11px; +} +.support-msg[data-dir="in"] { + border-left: 3px solid #fb923c; + padding-left: 11px; +} +.support-msg-label { + font-family: var(--t-font-pixel); + font-size: 9px; + color: var(--t-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + display: flex; + gap: 8px; +} +.support-msg-label .tag { color: var(--t-accent); } +.support-msg-label[data-dir="in"] .tag { color: #fb923c; } + +/* Composer */ +.support-composer { + background: var(--t-surface); + border: 1px solid var(--t-border); + border-radius: 4px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.support-composer-head { + display: flex; + gap: 8px; + align-items: center; +} +.support-composer-head .font-pixel { font-family: var(--t-font-pixel); font-size: 10px; color: var(--t-accent); text-transform: uppercase; letter-spacing: 0.5px; } +.support-kb-search { + flex: 1; + background: var(--t-bg); + border: 1px solid var(--t-border); + color: var(--t-bright); + padding: 6px 10px; + border-radius: 3px; + font-size: 12px; + font-family: var(--t-font-content, monospace); + outline: none; +} +.support-kb-search:focus { border-color: var(--t-accent); } + +.support-kb-results { + display: none; + max-height: 200px; + overflow-y: auto; + background: var(--t-bg); + border: 1px solid var(--t-border); + border-radius: 3px; +} +.support-kb-results.open { display: block; } +.support-kb-result { + padding: 8px 12px; + border-bottom: 1px solid var(--t-border); + cursor: pointer; + transition: background 0.1s; +} +.support-kb-result:hover { background: var(--t-surface); } +.support-kb-result:last-child { border-bottom: none; } +.support-kb-result-title { color: var(--t-bright); font-size: 12px; font-weight: 600; } +.support-kb-result-preview { color: var(--t-dim); font-size: 11px; margin-top: 2px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + +.support-composer textarea { + background: var(--t-bg); + border: 1px solid var(--t-border); + color: var(--t-bright); + padding: 10px 12px; + border-radius: 3px; + font-family: var(--t-font-content, ui-monospace, monospace); + font-size: 13px; + min-height: 140px; + resize: vertical; + outline: none; +} +.support-composer textarea:focus { border-color: var(--t-accent); } + +.support-composer-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; +} +.support-composer-actions .spacer { flex: 1; } +.support-composer-status { font-size: 11px; color: var(--t-dim); align-self: center; margin-right: auto; } +.support-composer-status.err { color: var(--t-danger); } +.support-composer-status.ok { color: #4ade80; } + +/* Settings tab (injected into the main settings overlay) */ +.support-settings-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } +.support-settings-field label { + font-family: var(--t-font-pixel, 'Silkscreen', cursive); + font-size: 9px; + color: var(--t-dim); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.support-settings-field input, .support-settings-field select { + background: var(--t-bg); + border: 1px solid var(--t-border); + color: var(--t-bright); + padding: 8px 10px; + border-radius: 3px; + font-family: var(--t-font-content, monospace); + font-size: 12px; + outline: none; +} +.support-settings-field input:focus { border-color: var(--t-accent); } +.support-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; } +.support-settings-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; } diff --git a/public/plugins/support/support.html b/public/plugins/support/support.html new file mode 100644 index 0000000..4d54a0c --- /dev/null +++ b/public/plugins/support/support.html @@ -0,0 +1,35 @@ + +
+
+
+ + Support inbox + +
+
+
+ + + + +
+ +
+
+ +
+ + + + +
+
+ +

Pick a ticket on the left.

+
+
+
+
diff --git a/public/plugins/support/support.js b/public/plugins/support/support.js new file mode 100644 index 0000000..8ec337d --- /dev/null +++ b/public/plugins/support/support.js @@ -0,0 +1,498 @@ +// ── Support plugin ── +// +// Talks to a self-hosted support-relay (see support-relay/ in the repo). +// Renders a full-screen overlay with the ticket inbox, the active +// ticket's thread, a reply composer with KB-fragment search + insert, +// and a "save as fragment" button. KB fragments are plain hyprcrd cards +// in the configured KB bucket, searched via the plugin backend. + +const h = window.hyprcrd; +const $ = id => document.getElementById(id); + +// ── State ─────────────────────────────────────────────────────── + +const state = { + tickets: [], + filter: '', // '', 'open', 'answered', 'closed' + activeId: null, // relay_id of the selected ticket + kbResults: [], + composerDirty: false, +}; + +// ── Overlay lifecycle ─────────────────────────────────────────── + +const overlay = document.getElementById('support-overlay'); +function isOpen() { return overlay?.classList.contains('open'); } +function show() { + if (!overlay) return; + overlay.classList.add('open'); + refreshTickets(); + // Hopefully poll right away so the user sees fresh data + poll(); +} +function hide() { + overlay?.classList.remove('open'); + state.composerDirty = false; +} +window._supportToggle = () => (isOpen() ? hide() : show()); + +$('support-close')?.addEventListener('click', hide); +$('support-poll')?.addEventListener('click', () => poll({ manual: true })); + +// Close with Escape (respects any open child dialogs) +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && isOpen()) { + e.preventDefault(); + hide(); + } +}); + +// Filter pills +overlay?.querySelectorAll('.support-pill').forEach(btn => { + btn.addEventListener('click', () => { + overlay.querySelectorAll('.support-pill').forEach(b => b.classList.toggle('active', b === btn)); + state.filter = btn.dataset.filter || ''; + renderList(); + }); +}); + +// ── API wrappers ──────────────────────────────────────────────── + +async function api(path, opts = {}) { + const res = await fetch(path, { + method: opts.method || 'GET', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': h?.util?.csrf?.() || '' }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + const data = await res.json().catch(() => ({ error: 'bad response' })); + if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status)); + return data; +} + +async function poll({ manual = false } = {}) { + try { + const res = await api('api/support/poll', { method: 'POST' }); + if (manual) toast(`Pulled ${res.pulled || 0} update(s)`); + await refreshTickets(); + if (state.activeId) await refreshActive(); + } catch (e) { + if (manual) toast('Poll failed: ' + e.message, 'err'); + } +} + +async function refreshTickets() { + try { + const res = await api('api/support/tickets'); + state.tickets = res.tickets || []; + renderList(); + } catch (e) { + $('support-list').innerHTML = `
Couldn't load: ${escapeHtml(e.message)}
`; + } +} + +async function refreshActive() { + if (!state.activeId) return; + try { + const res = await api('api/support/tickets/' + encodeURIComponent(state.activeId)); + renderDetail(res.ticket); + } catch (e) { + $('support-detail').innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +// ── Rendering ─────────────────────────────────────────────────── + +function renderList() { + const host = $('support-list'); + if (!host) return; + const shown = state.filter ? state.tickets.filter(t => t.status === state.filter) : state.tickets; + if (!shown.length) { + host.innerHTML = `
No tickets${state.filter ? ' with status ' + state.filter : ''}.
`; + return; + } + host.innerHTML = shown.map(t => ` +
+
+ ${escapeHtml(t.issue_id)} + ${escapeHtml(t.status)} +
+
${escapeHtml(t.subject || '(no subject)')}
+
+ ${escapeHtml(t.email)} + ${escapeHtml(formatRel(t.last_reply_at || t.updated_at))} +
+
+ `).join(''); + host.querySelectorAll('.support-ticket').forEach(el => { + el.addEventListener('click', () => openTicket(parseInt(el.dataset.id))); + }); +} + +function renderDetail(t) { + const host = $('support-detail'); + if (!host) return; + if (!t) { + host.innerHTML = `

Pick a ticket on the left.

`; + return; + } + const replies = (t.replies || []).map(r => ` +
+
+ ${r.direction === 'out' ? 'sent' : 'received'} + ${escapeHtml(r.created_at)} +
${escapeHtml(r.body)} +
+ `).join(''); + host.innerHTML = ` +
+

${escapeHtml(t.subject || '(no subject)')}

+
+ ${escapeHtml(t.issue_id)} + ${escapeHtml(t.status)} + + opened ${escapeHtml(formatRel(t.created_at))} +
+
+
+ ${replies || '
No messages yet.
'} +
+
+
+ Reply + + +
+
+ +
+ + + + + +
+
+ `; + bindComposer(t); +} + +function bindComposer(t) { + const kbInput = $('support-kb-input'); + const kbResults = $('support-kb-results'); + const kbToggle = $('support-kb-toggle'); + const body = $('support-reply-body'); + const sendBtn = $('support-send-btn'); + const saveFrag = $('support-save-fragment'); + const closeBtn = $('support-status-closed'); + const statusEl = $('support-composer-status'); + + // KB search: debounced + let kbTimer = null; + async function runKb(q) { + try { + const res = await api('api/support/kb?q=' + encodeURIComponent(q || '')); + state.kbResults = res.fragments || []; + kbResults.classList.toggle('open', !!state.kbResults.length); + kbResults.innerHTML = state.kbResults.map((f, i) => ` +
+
${escapeHtml(f.title)}
+
${escapeHtml(stripMd(f.body).slice(0, 140))}
+
+ `).join('') || `
No fragments yet.
`; + kbResults.querySelectorAll('.support-kb-result').forEach(el => { + el.addEventListener('click', () => { + const frag = state.kbResults[parseInt(el.dataset.idx)]; + insertAtCursor(body, frag.body); + kbResults.classList.remove('open'); + kbInput.value = ''; + }); + }); + } catch (e) { + kbResults.innerHTML = `
${escapeHtml(e.message)}
`; + kbResults.classList.add('open'); + } + } + kbInput.addEventListener('input', () => { + clearTimeout(kbTimer); + kbTimer = setTimeout(() => runKb(kbInput.value.trim()), 120); + }); + kbInput.addEventListener('focus', () => runKb(kbInput.value.trim())); + kbToggle.addEventListener('click', () => { + if (kbResults.classList.contains('open')) kbResults.classList.remove('open'); + else runKb(kbInput.value.trim()); + }); + // Close KB results when clicking outside + document.addEventListener('click', e => { + if (!overlay.contains(e.target)) return; + if (kbResults.contains(e.target) || e.target === kbInput || e.target === kbToggle || kbToggle.contains(e.target)) return; + kbResults.classList.remove('open'); + }); + + sendBtn.addEventListener('click', async () => { + const text = body.value.trim(); + if (!text) { setStatus('Nothing to send.', 'err'); return; } + sendBtn.disabled = true; setStatus('Sending…'); + try { + await api('api/support/tickets/' + t.relay_id + '/reply', { method: 'POST', body: { body: text } }); + body.value = ''; + setStatus('Sent.', 'ok'); + await refreshTickets(); + await refreshActive(); + } catch (e) { + setStatus('Failed: ' + e.message, 'err'); + } finally { + sendBtn.disabled = false; + } + }); + + saveFrag.addEventListener('click', async () => { + const text = body.value.trim(); + if (!text) { setStatus('Type something first.', 'err'); return; } + const title = prompt('Fragment title?', text.split('\n')[0].slice(0, 60)); + if (title === null) return; + try { + await api('api/support/kb', { method: 'POST', body: { title: title || '', body: text } }); + setStatus('Saved to fragment library.', 'ok'); + // Refresh KB list if open + if (kbResults.classList.contains('open')) runKb(kbInput.value.trim()); + } catch (e) { + setStatus('Save failed: ' + e.message, 'err'); + } + }); + + closeBtn.addEventListener('click', async () => { + try { + await api('api/support/tickets/' + t.relay_id + '/status', { method: 'PATCH', body: { status: 'closed' } }); + await refreshTickets(); + await refreshActive(); + } catch (e) { + setStatus('Close failed: ' + e.message, 'err'); + } + }); + + function setStatus(msg, kind = '') { + statusEl.textContent = msg; + statusEl.className = 'support-composer-status' + (kind ? ' ' + kind : ''); + if (kind === 'ok') setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'support-composer-status'; }, 3500); + } +} + +async function openTicket(id) { + state.activeId = id; + renderList(); + await refreshActive(); +} + +// ── Helpers ───────────────────────────────────────────────────── + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +/** Strip Markdown markers for preview purposes (headings, emphasis). */ +function stripMd(s) { + return String(s || '') + .replace(/^#+\s+/gm, '') + .replace(/[*_`]/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Human-friendly timestamp: "just now", "14m ago", "2h ago", "Tue 14:02". */ +function formatRel(iso) { + if (!iso) return ''; + const d = new Date(iso.replace(' ', 'T') + (iso.includes('T') || iso.endsWith('Z') ? '' : 'Z')); + if (isNaN(d.getTime())) return iso; + const now = new Date(); + const diff = (now - d) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + if (diff < 86400 * 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString(); +} + +function insertAtCursor(textarea, text) { + const start = textarea.selectionStart, end = textarea.selectionEnd; + const before = textarea.value.slice(0, start); + const after = textarea.value.slice(end); + const prefix = (before && !before.endsWith('\n')) ? '\n' : ''; + textarea.value = before + prefix + text + after; + const caret = (before + prefix + text).length; + textarea.focus(); + textarea.setSelectionRange(caret, caret); +} + +function toast(msg, kind = 'info') { + // Piggy-back on the simple notification system from relay sync + const el = document.createElement('div'); + el.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;font-family:var(--t-font-pixel);font-size:10px;color:' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 350); }, 3500); +} + +// ── Header button ─────────────────────────────────────────────── + +function injectHeaderButton() { + const tasksBtn = document.getElementById('tasks-btn'); + if (!tasksBtn || document.getElementById('support-btn')) return; + const btn = document.createElement('button'); + btn.id = 'support-btn'; + btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; + btn.innerHTML = 'Support'; + btn.addEventListener('click', () => window._supportToggle?.()); + tasksBtn.parentNode.insertBefore(btn, tasksBtn); +} +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', injectHeaderButton); +} else { + injectHeaderButton(); +} + +// ── Settings tab in the main settings overlay ─────────────────── + +function renderSettingsPanel(panelEl) { + panelEl.innerHTML = ` +

+ Connect hyprcrd to your support relay. The relay hosts the public form and + sends outgoing replies via SMTP; hyprcrd mirrors tickets and lets you reply + from any card. +

+
+ + +
+
+ + +
Leave empty to keep the currently stored key.
+
+
+ + +
Where newly-pulled ticket cards will land.
+
+
+ + +
Cards in this bucket become reusable reply fragments.
+
+
+ + + +
+ +

Relay config

+

+ Editable post-setup values on the connected relay. Mail footer supports + {brand}, {issue_id}, {public_url} + placeholders — lines containing an empty placeholder are dropped. +

+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+ `; + + // Populate bucket selects from state.buckets + const opts = (window.state?.buckets || []).map(b => ``).join(''); + panelEl.querySelector('#support-set-inbox').innerHTML += opts; + panelEl.querySelector('#support-set-kb').innerHTML += opts; + + api('api/support/settings').then(cur => { + panelEl.querySelector('#support-set-url').value = cur.relay_url || ''; + panelEl.querySelector('#support-set-key').placeholder = cur.has_key ? '•••••••• (saved — leave empty to keep)' : 'paste the key shown by the relay setup'; + if (cur.bucket_inbox) panelEl.querySelector('#support-set-inbox').value = cur.bucket_inbox; + if (cur.bucket_kb) panelEl.querySelector('#support-set-kb').value = cur.bucket_kb; + }).catch(() => {}); + + const statusEl = panelEl.querySelector('#support-set-status'); + function setStatus(msg, kind = '') { + statusEl.textContent = msg; + statusEl.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)'; + } + + panelEl.querySelector('#support-set-save').addEventListener('click', async () => { + const payload = { + relay_url: panelEl.querySelector('#support-set-url').value.trim(), + bucket_inbox: parseInt(panelEl.querySelector('#support-set-inbox').value) || 0, + bucket_kb: parseInt(panelEl.querySelector('#support-set-kb').value) || 0, + }; + const key = panelEl.querySelector('#support-set-key').value; + if (key) payload.relay_key = key; + try { + await api('api/support/settings', { method: 'POST', body: payload }); + setStatus('Saved.', 'ok'); + } catch (e) { setStatus(e.message, 'err'); } + }); + + panelEl.querySelector('#support-set-test').addEventListener('click', async () => { + setStatus('Testing…'); + try { + const r = await api('api/support/settings/test', { method: 'POST' }); + setStatus('OK — relay says "' + (r.brand || 'support') + '"', 'ok'); + } catch (e) { setStatus(e.message, 'err'); } + }); + + // Relay config (brand / public url / footer). Loaded after a tick so + // the local-settings render happens first — keeps this lazy, and + // silently hides if the relay isn't reachable. + const rsStatus = panelEl.querySelector('#support-rs-status'); + function setRsStatus(msg, kind = '') { + rsStatus.textContent = msg; + rsStatus.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)'; + } + (async () => { + try { + const rs = await api('api/support/relay-settings'); + panelEl.querySelector('#support-rs-brand').value = rs.brand || ''; + panelEl.querySelector('#support-rs-public').value = rs.public_url || ''; + panelEl.querySelector('#support-rs-footer').value = rs.mail_footer || ''; + const def = panelEl.querySelector('#support-rs-footer-default'); + if (def && rs.default_footer) def.textContent = 'Default if left empty: ' + rs.default_footer.replace(/\n/g, ' ⏎ '); + } catch (e) { + setRsStatus('(relay unreachable — configure above first)', 'err'); + } + })(); + panelEl.querySelector('#support-rs-save').addEventListener('click', async () => { + setRsStatus('Saving…'); + try { + await api('api/support/relay-settings', { method: 'POST', body: { + brand: panelEl.querySelector('#support-rs-brand').value.trim(), + public_url: panelEl.querySelector('#support-rs-public').value.trim(), + mail_footer: panelEl.querySelector('#support-rs-footer').value, + }}); + setRsStatus('Saved.', 'ok'); + } catch (e) { + setRsStatus(e.message, 'err'); + } + }); +} + +h?.settings?.registerTab?.({ + id: 'support', + label: 'support', + icon: 'ph-lifebuoy', + onShow: renderSettingsPanel, +}); + +// Hook the sync tick so each user-triggered sync also pulls support. +h?.hooks?.on?.('sync.tick', () => { poll().catch(() => {}); }); + +h?.registerPlugin?.({ name: 'support', version: '1.0.0' }); diff --git a/support-relay/.dockerignore b/support-relay/.dockerignore new file mode 100644 index 0000000..bf30153 --- /dev/null +++ b/support-relay/.dockerignore @@ -0,0 +1,6 @@ +data/*.db +data/*.db-* +data/master.key +.git +.gitignore +docker-compose.yml diff --git a/support-relay/.htaccess b/support-relay/.htaccess new file mode 100644 index 0000000..2cbd172 --- /dev/null +++ b/support-relay/.htaccess @@ -0,0 +1,12 @@ +RewriteEngine On + +# Keep the DB + master key out of the web path (defence-in-depth; the +# Apache doc-root should already be the project root, not /data). +RewriteRule ^data(/|$) - [F,L] + +# Everything else funnels through index.php so route dispatch is +# centralised. Actual files (ui/style.css, etc.) continue to be +# served directly. +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [L] diff --git a/support-relay/Dockerfile b/support-relay/Dockerfile new file mode 100644 index 0000000..e2e5937 --- /dev/null +++ b/support-relay/Dockerfile @@ -0,0 +1,25 @@ +FROM php:8.3-apache + +# Apache rewrite so .htaccess routing works +RUN a2enmod rewrite + +# SQLite + PDO +RUN apt-get update && apt-get install -y libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* \ + && docker-php-ext-install pdo_sqlite + +# Let per-directory .htaccess override (needed for our RewriteRule) +RUN sed -ri 's!AllowOverride None!AllowOverride All!g' /etc/apache2/apache2.conf + +# App files. data/ is also mounted as a volume in docker-compose so the +# DB + master key persist across rebuilds — but we COPY it too so a +# bare `docker run` still boots. +COPY . /var/www/html/ + +# Entrypoint makes sure data/ is writable by www-data inside the +# container (mounts from the host can otherwise land as root:root). +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 80 +CMD ["docker-entrypoint.sh"] diff --git a/support-relay/README.md b/support-relay/README.md new file mode 100644 index 0000000..96fa8d3 --- /dev/null +++ b/support-relay/README.md @@ -0,0 +1,197 @@ +# Support-Relay — Setup + +Nimmt anonyme Support-Anfragen per Web-Formular entgegen, legt sie in +einer lokalen SQLite-DB ab, versendet Bestätigungs- und Antwort-Mails +per SMTP. Hyprcrd zieht sich die Tickets und antwortet durch diesen +Relay hindurch. **Single-user** — ein Admin, ein API-Key. + +## Lokal testen mit Docker (einfachster Weg) + +```sh +cd support-relay +docker compose up -d --build +``` + +Danach `http://localhost:8090/` im Browser öffnen → Setup-Wizard. Kein +Host-Setup nötig. Die DB und der master-key landen im lokalen +`./data/`-Ordner und überleben `docker compose down`. + +Zum Rücksetzen (neuer API-Key, leere DB): + +```sh +rm -f data/support.db data/support.db-* data/master.key +docker compose restart +``` + +Stoppen: `docker compose down`. + +--- + +## Was du für Production brauchst + +- Eine Subdomain, z.B. `support.oumta.de` +- PHP ≥ 8.1 mit `pdo_sqlite` und `openssl` +- Apache mit `mod_rewrite` (oder nginx mit äquivalentem Rewrite) +- Einen SMTP-Zugang (Mailserver, Mailtrap, Office365, was auch immer) + +--- + +## 1. Upload + +Den kompletten Ordner `support-relay/` als Document-Root der Subdomain +hochladen. Die Struktur sollte am Host so aussehen: + +``` +support.oumta.de/ +├── index.php +├── .htaccess +├── inc/ +├── ui/ +└── data/ ← muss vom Webserver schreibbar sein! +``` + +**`data/` schreibbar machen** (SSH): + +```sh +chmod 755 data +``` + +Der Webserver legt dort `support.db` und `master.key` an. `master.key` +kriegt automatisch `chmod 600`. + +--- + +## 2. Erster Aufruf → Setup-Wizard + +`https://support.oumta.de/` im Browser öffnen. Wenn `data/` schreibbar +ist, erscheint automatisch der Setup-Wizard. Ausfüllen: + +| Feld | Was eintragen | +|---|---| +| **Display name** | `Oumta Support` (wird Absendername, Subject-Präfix, Footer) | +| **Issue-prefix** | `OUMTA` (Tickets heißen dann `OUMTA-1`, `OUMTA-2`, …) | +| **Host** | z.B. `smtp.office365.com` / `smtp.mailtrap.io` / eigener Mailserver | +| **Port** | `587` (STARTTLS — Standard) | +| **Encryption** | `STARTTLS (587)` lassen, außer dein Server will was anderes | +| **Username** | SMTP-User | +| **Password** | SMTP-Passwort (wird verschlüsselt abgelegt) | +| **From address** | z.B. `support@oumta.de` | +| **From name** | `Oumta Support` | + +**Speichern** drücken. Die nächste Seite zeigt einen langen **API-Key**. +Kopieren und sicher aufheben — wird **exakt einmal** angezeigt. + +Nach dem Setup ist diese Seite nicht mehr erreichbar. Stattdessen zeigt +`/` jetzt das öffentliche Support-Formular. + +--- + +## 3. Hyprcrd verbinden + +In hyprcrd: + +1. Zwei Buckets anlegen (falls noch nicht vorhanden): + - **Support Inbox** — hier landen eingehende Tickets als Karten + - **Support KB** — hier liegen deine wiederverwendbaren Antwort-Bausteine +2. Settings (Zahnrad) öffnen → Tab **support** +3. Eintragen: + - **Relay URL**: `https://support.oumta.de` + - **API key**: der gerade kopierte Key + - **Inbox bucket**: `Support Inbox` + - **KB bucket**: `Support KB` +4. **Save** → **Test connection** → muss grün zurückkommen + (`OK — relay says "Oumta Support"`) + +--- + +## 4. Smoke-Test + +**a) Ticket einreichen** + +In einem Inkognito-Tab `https://support.oumta.de/` öffnen, eine +Testanfrage absenden. Erwartung: + +- Grüner Banner: `Received. Your issue id is OUMTA-1.` +- Bestätigungsmail im Posteingang + +**b) Ticket in hyprcrd** + +Oben rechts den **Support**-Button im Header klicken → Overlay auf. +Auf den Reload-Knopf (↻) klicken, dann erscheint das Ticket in der +Liste. Im "Support Inbox"-Bucket liegt eine Karte mit derselben Info. + +**c) Antworten** + +Ticket anklicken → ins Textfeld tippen → **Send reply**. Erwartung: + +- Status wechselt auf `answered` +- Mail landet im Posteingang des Absenders + +**d) Fragment speichern** + +Nach dem Tippen **Save as fragment** → Titel vergeben → die Antwort +taucht im KB-Bucket als Karte auf und wird ab sofort im Suchfeld +über dem Composer gefunden. + +--- + +## 5. Normaler Betrieb + +- **Neue Tickets sehen**: Support-Overlay in hyprcrd. Pollt automatisch + bei jedem Sync-Tick; manueller Pull per ↻-Knopf. +- **Antworten**: aus dem Composer heraus, SMTP läuft über den Relay. +- **Status setzen**: `open` → `answered` (automatisch bei Reply) → + `closed` (manuell per Button). +- **User antwortet auf eine Mail**: er muss seine **Issue-ID** + (`OUMTA-n`) erneut ins Formular eintragen. Dann wird die Nachricht + an das bestehende Ticket gehängt (nur wenn die Email matcht, sonst + wird daraus ein neues Ticket). + +--- + +## Troubleshooting + +**Setup-Wizard kommt nicht** +`data/` ist nicht schreibbar. → `chmod 755 data/` (oder 775) und neu +laden. + +**"Test connection" schlägt fehl** +Relay-URL falsch (Trailing-Slash ist OK, aber HTTPS muss stimmen) oder +API-Key nicht exakt übernommen. + +**Confirmation-Mail kommt nicht** +SMTP-Creds falsch oder Port blockiert. Direkt vom Host testen: + +```sh +php -r ' + require "inc/bootstrap.php"; require "inc/SmtpClient.php"; + $c = supportRelaySmtpConfig(Database::connect(), $MASTER_KEY); + $r = (new SmtpClient($c))->send("du@example.com", $c["from_name"], $c["from"], + "ping", ["Subject" => "SMTP ping"]); + print_r($r); +' +``` + +Der `log`-Array zeigt die SMTP-Konversation. + +**Alles kaputt, von vorn anfangen** +`data/support.db` und `data/master.key` löschen. Nächster Aufruf +startet wieder im Setup-Wizard — der API-Key ändert sich dann, +musst du in hyprcrd neu eintragen. + +--- + +## Dateien + +| Pfad | Zweck | +|---|---| +| `index.php` | Entry-Point + Routing | +| `inc/bootstrap.php` | DB-Schema, Settings, Helpers | +| `inc/SmtpClient.php` | Minimaler SMTP-Client | +| `inc/routes/*.php` | Public / Setup / Hyprcrd-API | +| `ui/*.php` | Form, Setup-Wizard, Status-Seite | +| `ui/style.css` | Styling | +| `data/support.db` | SQLite DB (tickets, replies, settings) | +| `data/master.key` | Verschlüsselt SMTP-Passwort (chmod 600) | + +Gute Nacht 🌙 diff --git a/support-relay/data/.gitkeep b/support-relay/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/support-relay/data/.htaccess b/support-relay/data/.htaccess new file mode 100644 index 0000000..adf32a3 --- /dev/null +++ b/support-relay/data/.htaccess @@ -0,0 +1,2 @@ +# Block all direct access to the data directory (SQLite, master key). +Require all denied diff --git a/support-relay/docker-compose.yml b/support-relay/docker-compose.yml new file mode 100644 index 0000000..e0448b4 --- /dev/null +++ b/support-relay/docker-compose.yml @@ -0,0 +1,17 @@ +# Local-dev runner for the support-relay. One command: +# docker compose up -d --build +# then open http://localhost:8090/ to land on the setup wizard. +# +# `./data` is bind-mounted so the SQLite DB and master.key survive +# `docker compose down` + container rebuilds. To start fresh: delete +# everything in ./data/ (keep the folder itself). + +services: + support-relay: + build: . + container_name: support-relay + ports: + - "8090:80" + volumes: + - ./data:/var/www/html/data + restart: unless-stopped diff --git a/support-relay/docker-entrypoint.sh b/support-relay/docker-entrypoint.sh new file mode 100755 index 0000000..bfebb54 --- /dev/null +++ b/support-relay/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Make sure the bind-mounted data dir is writable by the Apache user +# inside the container. Bind mounts from the host land as root:root by +# default which would prevent the relay from creating support.db + +# master.key on first boot. +mkdir -p /var/www/html/data +chown -R www-data:www-data /var/www/html/data +chmod 755 /var/www/html/data + +exec apache2-foreground diff --git a/support-relay/favicon.svg b/support-relay/favicon.svg new file mode 100644 index 0000000..634ea71 --- /dev/null +++ b/support-relay/favicon.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/support-relay/inc/SmtpClient.php b/support-relay/inc/SmtpClient.php new file mode 100644 index 0000000..ea594e0 --- /dev/null +++ b/support-relay/inc/SmtpClient.php @@ -0,0 +1,237 @@ +config = $config; } + + /** Connect, authenticate, send a single mail. Returns + * ['ok' => bool, 'error' => string, 'message_id' => string, 'log' => string[]]. + * + * $options: + * - html: optional HTML body. When set, the message goes + * out as multipart/alternative with plain + html. + * - inlineImages: array of ['cid' => 'logo', 'path' => '/abs/path', + * 'mime' => 'image/svg+xml']. Wraps the alternative + * in multipart/related so + * resolves in the rendered HTML. */ + public function send(string $to, string $fromName, string $fromAddr, string $body, array $extraHeaders = [], array $options = []): array { + try { + $this->connect(); + $this->greet(); + $this->authenticate(); + $messageId = $this->transmit($to, $fromName, $fromAddr, $body, $extraHeaders, $options); + $this->quit(); + return ['ok' => true, 'error' => '', 'message_id' => $messageId, 'log' => $this->log]; + } catch (\Throwable $e) { + $this->closeSocket(); + return ['ok' => false, 'error' => $e->getMessage(), 'message_id' => '', 'log' => $this->log]; + } + } + + private function connect(): void { + $host = $this->config['host']; + $port = (int)($this->config['port'] ?? 587); + $enc = $this->config['encryption'] ?? 'starttls'; + // Direct TLS ("smtps", usually port 465) opens on a TLS socket + // straight away; STARTTLS opens plaintext then upgrades after EHLO. + $scheme = ($enc === 'tls') ? 'tls://' : ''; + $errno = 0; $errstr = ''; + $this->sock = @stream_socket_client($scheme . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT); + if (!$this->sock) throw new \RuntimeException("connect failed: $errstr ($errno)"); + stream_set_timeout($this->sock, 15); + $this->expect(220); + } + + private function greet(): void { + $me = gethostname() ?: 'localhost'; + $this->send_line('EHLO ' . $me); + $banner = $this->expect(250, true); + + if (($this->config['encryption'] ?? '') === 'starttls') { + $this->send_line('STARTTLS'); + $this->expect(220); + if (!@stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new \RuntimeException('STARTTLS: crypto negotiation failed'); + } + // EHLO again after the upgrade (RFC 3207 requires it) + $this->send_line('EHLO ' . $me); + $this->expect(250, true); + } + } + + private function authenticate(): void { + $user = $this->config['user'] ?? ''; + $pass = $this->config['password'] ?? ''; + if ($user === '') return; // unauthenticated relays are rare but valid + $this->send_line('AUTH LOGIN'); + $this->expect(334); + $this->send_line(base64_encode($user)); + $this->expect(334); + $this->send_line(base64_encode($pass)); + $this->expect(235); + } + + private function transmit(string $to, string $fromName, string $fromAddr, string $body, array $extraHeaders, array $options = []): string { + $this->send_line('MAIL FROM:<' . $fromAddr . '>'); + $this->expect(250); + $this->send_line('RCPT TO:<' . $to . '>'); + $this->expect(250); + $this->send_line('DATA'); + $this->expect(354); + + // Build RFC 5322 message + $host = parse_url('https://' . ($this->config['host'] ?? 'local'), PHP_URL_HOST) ?: 'local'; + $messageId = bin2hex(random_bytes(12)) . '@' . $host; + $date = date('r'); + $subject = $extraHeaders['Subject'] ?? 'Support reply'; + $html = $options['html'] ?? null; + $inline = $options['inlineImages'] ?? []; + + // Common top-of-message headers + $topHeaders = [ + 'Date: ' . $date, + 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromAddr . '>', + 'To: <' . $to . '>', + 'Subject: ' . $this->encodeHeader($subject), + 'Message-ID: <' . $messageId . '>', + 'MIME-Version: 1.0', + ]; + foreach ($extraHeaders as $k => $v) { + if ($k === 'Subject') continue; + $topHeaders[] = $k . ': ' . $v; + } + + if ($html === null) { + // Plain-text only (original fast path) + $topHeaders[] = 'Content-Type: text/plain; charset=utf-8'; + $topHeaders[] = 'Content-Transfer-Encoding: 8bit'; + $message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $this->dotStuff($body) . "\r\n."; + $this->send_line($message); + $this->expect(250); + return $messageId; + } + + // Multipart. text/plain + text/html alternative, optionally wrapped + // in multipart/related for CID-addressable inline images so the + // HTML version can reference them via . + $altBoundary = 'alt_' . bin2hex(random_bytes(8)); + $altParts = []; + $altParts[] = "--{$altBoundary}\r\n" + . "Content-Type: text/plain; charset=utf-8\r\n" + . "Content-Transfer-Encoding: 8bit\r\n\r\n" + . $this->dotStuff($body); + $altParts[] = "--{$altBoundary}\r\n" + . "Content-Type: text/html; charset=utf-8\r\n" + . "Content-Transfer-Encoding: 8bit\r\n\r\n" + . $this->dotStuff($html); + $altBody = implode("\r\n", $altParts) . "\r\n--{$altBoundary}--"; + + if (!$inline) { + $topHeaders[] = "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\""; + $message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $altBody . "\r\n."; + $this->send_line($message); + $this->expect(250); + return $messageId; + } + + $relBoundary = 'rel_' . bin2hex(random_bytes(8)); + $relParts = []; + // First related part: the alternative tree (plain + html) + $relParts[] = "--{$relBoundary}\r\n" + . "Content-Type: multipart/alternative; boundary=\"{$altBoundary}\"\r\n\r\n" + . $altBody; + // Remaining parts: inline images addressed by CID + foreach ($inline as $img) { + $cid = $img['cid'] ?? bin2hex(random_bytes(6)); + $mime = $img['mime'] ?? 'application/octet-stream'; + $path = $img['path'] ?? ''; + $data = ($path && is_file($path)) ? (string)file_get_contents($path) : ''; + if ($data === '') continue; // silently skip missing images + $b64 = chunk_split(base64_encode($data), 76, "\r\n"); + $name = basename($path); + $relParts[] = "--{$relBoundary}\r\n" + . "Content-Type: {$mime}; name=\"" . $this->encodeHeader($name) . "\"\r\n" + . "Content-Transfer-Encoding: base64\r\n" + . "Content-ID: <{$cid}>\r\n" + . "Content-Disposition: inline; filename=\"" . $this->encodeHeader($name) . "\"\r\n\r\n" + . $b64; + } + $relBody = implode("\r\n", $relParts) . "\r\n--{$relBoundary}--"; + + $topHeaders[] = "Content-Type: multipart/related; type=\"multipart/alternative\"; boundary=\"{$relBoundary}\""; + $message = implode("\r\n", $topHeaders) . "\r\n\r\n" . $relBody . "\r\n."; + $this->send_line($message); + $this->expect(250); + return $messageId; + } + + private function quit(): void { + $this->send_line('QUIT'); + // Don't hard-fail if the server misbehaves after our last command. + @fgets($this->sock); + $this->closeSocket(); + } + + private function closeSocket(): void { + if (is_resource($this->sock)) { @fclose($this->sock); } + $this->sock = null; + } + + /** Send a line (appends CRLF). */ + private function send_line(string $line): void { + $this->log[] = '> ' . $this->safeLogLine($line); + fwrite($this->sock, $line . "\r\n"); + } + + /** Read lines until a final reply (no '-' separator), assert the + * expected 3-digit code. With $multi=true accepts same code across + * continuation lines (e.g. EHLO's capability list). */ + private function expect(int $code, bool $multi = false): string { + $last = ''; + while (!feof($this->sock)) { + $line = fgets($this->sock, 1024); + if ($line === false) throw new \RuntimeException('read failed'); + $this->log[] = '< ' . rtrim($line); + $last .= $line; + if (strlen($line) < 4) throw new \RuntimeException('short reply'); + $actual = (int)substr($line, 0, 3); + if ($actual !== $code) throw new \RuntimeException("expected $code, got " . rtrim($line)); + // '-' means continuation, ' ' or end means final + if ($line[3] !== '-') break; + if (!$multi) break; + } + return $last; + } + + /** Encode a header value if it contains non-ASCII. Keeps the subject + * readable for clients that support RFC 2047 encoded-word. */ + private function encodeHeader(string $v): string { + if (preg_match('/[\x80-\xff]/', $v)) { + return '=?UTF-8?B?' . base64_encode($v) . '?='; + } + return $v; + } + + /** RFC 5321 §4.5.2 dot-stuffing: a leading dot on a body line would + * end the DATA transmission early. Double any such dot. */ + private function dotStuff(string $body): string { + // Normalise line endings to CRLF + $body = preg_replace("/\r\n|\r|\n/", "\r\n", $body); + return preg_replace('/^\./m', '..', $body); + } + + /** Redact the LOGIN base64 so a saved log doesn't leak credentials. */ + private function safeLogLine(string $line): string { + if (preg_match('/^[A-Za-z0-9+\/=]+$/', $line) && strlen($line) > 8) return ''; + return $line; + } +} diff --git a/support-relay/inc/auth.php b/support-relay/inc/auth.php new file mode 100644 index 0000000..3340d79 --- /dev/null +++ b/support-relay/inc/auth.php @@ -0,0 +1,13 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, +]); +$db->exec('PRAGMA journal_mode = WAL'); +$db->exec('PRAGMA synchronous = NORMAL'); +$db->exec('PRAGMA foreign_keys = ON'); + +// ── Schema ───────────────────────────────────────────────────────── + +$db->exec('CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +)'); + +// Tickets are the source of truth here. hyprcrd mirrors them as cards. +// status: open (user submitted) | answered (we sent a reply) | closed +// message_id: Message-ID of the first outbound reply, used for email +// threading so user replies land back under the same thread. +$db->exec('CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT "", + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT "open", + message_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_reply_at TEXT +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_tickets_updated ON tickets(updated_at DESC)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status, updated_at DESC)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_tickets_email ON tickets(email)'); + +// Per-ticket reply log. direction: in (from user via form) | out (from +// hyprcrd → SMTP). sent_at is populated on successful SMTP delivery. +$db->exec('CREATE TABLE IF NOT EXISTS replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + direction TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE +)'); +$db->exec('CREATE INDEX IF NOT EXISTS idx_replies_ticket ON replies(ticket_id, created_at)'); + +// Rate limits for the public form (IP-keyed, tumbling windows) +$db->exec('CREATE TABLE IF NOT EXISTS rate_limits ( + ip TEXT NOT NULL, + endpoint TEXT NOT NULL, + attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)'); + +// Lightweight cleanup on every request (runs only old rows) +$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-1 hour')"); + +// ── Encryption helpers (SMTP password is stored encrypted) ───────── + +function supportRelayEncrypt(string $plain, string $masterKeyHex): string { + $key = hex2bin($masterKeyHex); + $nonce = random_bytes(12); + $tag = ''; + $cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16); + return base64_encode($nonce . $cipher . $tag); +} +function supportRelayDecrypt(string $encoded, string $masterKeyHex): string { + $raw = base64_decode($encoded, true); + if (!$raw || strlen($raw) < 12 + 16 + 1) return ''; + $key = hex2bin($masterKeyHex); + $nonce = substr($raw, 0, 12); + $tag = substr($raw, -16); + $cipher = substr($raw, 12, -16); + return openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag) ?: ''; +} + +// ── Settings helpers ─────────────────────────────────────────────── + +function supportRelayGet(PDO $db, string $key, ?string $default = null): ?string { + $stmt = $db->prepare('SELECT value FROM settings WHERE key = :k'); + $stmt->execute(['k' => $key]); + $v = $stmt->fetchColumn(); + return $v === false ? $default : $v; +} +function supportRelaySet(PDO $db, string $key, ?string $value): void { + if ($value === null) { + $db->prepare('DELETE FROM settings WHERE key = :k')->execute(['k' => $key]); + } else { + $db->prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)') + ->execute(['k' => $key, 'v' => $value]); + } +} + +function supportRelaySetupDone(PDO $db): bool { + return supportRelayGet($db, 'setup_done') === '1'; +} + +function supportRelayIssueId(PDO $db, int $ticketId): string { + $prefix = supportRelayGet($db, 'issue_prefix', 'OUMTA') ?: 'OUMTA'; + return $prefix . '-' . $ticketId; +} +function supportRelayParseIssueId(PDO $db, string $s): ?int { + $prefix = supportRelayGet($db, 'issue_prefix', 'OUMTA') ?: 'OUMTA'; + $s = trim($s); + if (preg_match('/^' . preg_quote($prefix, '/') . '-(\d+)$/i', $s, $m)) return (int)$m[1]; + if (preg_match('/^\d+$/', $s)) return (int)$s; + return null; +} + +function supportRelaySmtpConfig(PDO $db, string $masterKeyHex): array { + $encPw = supportRelayGet($db, 'smtp_password', '') ?: ''; + return [ + 'host' => supportRelayGet($db, 'smtp_host', '') ?: '', + 'port' => (int)(supportRelayGet($db, 'smtp_port', '587') ?: 587), + 'user' => supportRelayGet($db, 'smtp_user', '') ?: '', + 'password' => $encPw ? supportRelayDecrypt($encPw, $masterKeyHex) : '', + 'from' => supportRelayGet($db, 'smtp_from', '') ?: '', + 'from_name' => supportRelayGet($db, 'smtp_from_name', 'Support') ?: 'Support', + 'encryption' => supportRelayGet($db, 'smtp_encryption', 'starttls') ?: 'starttls', + ]; +} + +// ── Rate limiting ────────────────────────────────────────────────── + +function supportRelayRateLimited(PDO $db, string $endpoint, int $max = 5, int $windowMinutes = 10): bool { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + if ($ip === '') return false; + $count = $db->prepare("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')"); + $count->execute(['ip' => $ip, 'ep' => $endpoint]); + if ((int)$count->fetchColumn() >= $max) return true; + $db->prepare('INSERT INTO rate_limits (ip, endpoint) VALUES (:ip, :ep)')->execute(['ip' => $ip, 'ep' => $endpoint]); + return false; +} + +// ── Response helper ──────────────────────────────────────────────── + +function supportRelayJson($data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} + +// ── Mail footer (configurable template) ─────────────────────────── + +/** Default footer if the admin hasn't set one in the config. */ +const SUPPORT_RELAY_FOOTER_DEFAULT = "---\n{brand} · Ticket {issue_id}\nReply via {public_url} (issue id: {issue_id})"; + +/** Substitute the supported placeholders in the footer template. + * Whole lines containing a placeholder that resolves to empty are + * dropped, so "Reply via {public_url}" vanishes cleanly when no + * public URL is configured. */ +function supportRelayRenderFooter(PDO $db, string $issueId): string { + $tpl = supportRelayGet($db, 'mail_footer', '') ?: SUPPORT_RELAY_FOOTER_DEFAULT; + $vars = [ + 'brand' => supportRelayGet($db, 'brand', 'Support') ?: 'Support', + 'issue_id' => $issueId, + 'public_url' => rtrim((string)(supportRelayGet($db, 'public_url', '') ?: ''), '/'), + ]; + $lines = []; + foreach (explode("\n", $tpl) as $line) { + // Identify the placeholders referenced on this line; drop the line + // if any of them is empty. Keeps the footer tidy when public_url + // or similar optional fields haven't been filled in. + if (preg_match_all('/\{([a-z_]+)\}/', $line, $m)) { + $skip = false; + foreach ($m[1] as $k) { if (!isset($vars[$k]) || $vars[$k] === '') { $skip = true; break; } } + if ($skip) continue; + } + $lines[] = strtr($line, array_combine( + array_map(fn($k) => '{' . $k . '}', array_keys($vars)), + array_values($vars) + )); + } + return implode("\n", $lines); +} + +// ── Mail rendering (plain + HTML + optional header logo) ─────────── + +/** Locate a header logo on disk. PNG wins over SVG (broadest mail-client + * support); SVG falls back for setups where only an SVG is shipped. + * Returns ['path', 'mime'] or null. */ +function supportRelayLogo(): ?array { + $root = dirname(__DIR__); + $candidates = [ + ['path' => $root . '/logo2026col.png', 'mime' => 'image/png'], + ['path' => $root . '/logo2026col.svg', 'mime' => 'image/svg+xml'], + ]; + foreach ($candidates as $c) { + if (is_file($c['path'])) return $c; + } + return null; +} + +/** Wrap a plain-text body in a tiny HTML shell with the brand logo + * floated at the top. Escapes text, preserves line breaks. */ +function supportRelayHtmlWrap(string $plain, string $brand, bool $hasLogo): string { + $safe = htmlspecialchars($plain, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $safe = nl2br($safe); + $logo = $hasLogo + ? '' . htmlspecialchars($brand, ENT_QUOTES) . '' + : ''; + return '' + . '' + . $logo + . '
' . $safe . '
' + . ''; +} + +/** Build the $options array that SmtpClient::send expects for a + * logo-branded mail. Returns an empty array when there's no logo on + * disk — the message then goes out as plain-text only (same as + * before this feature existed). */ +function supportRelayMailOptions(string $plain, string $brand): array { + $logo = supportRelayLogo(); + $html = supportRelayHtmlWrap($plain, $brand, $logo !== null); + $opts = ['html' => $html]; + if ($logo) $opts['inlineImages'] = [['cid' => 'logo', 'path' => $logo['path'], 'mime' => $logo['mime']]]; + return $opts; +} diff --git a/support-relay/inc/routes/api.php b/support-relay/inc/routes/api.php new file mode 100644 index 0000000..a05f15a --- /dev/null +++ b/support-relay/inc/routes/api.php @@ -0,0 +1,139 @@ + 'relay not configured'], 503); + if (!supportRelayApiAuth($db)) supportRelayJson(['error' => 'unauthorized'], 401); +} + +// Poll: tickets updated after `since` (ISO timestamp). Client stores +// the max updated_at it sees and sends that as `since` next time. +if ($route === '/api/hyprcrd/tickets' && $method === 'GET') { + $since = trim((string)($_GET['since'] ?? '')); + $sql = 'SELECT * FROM tickets'; + $params = []; + if ($since !== '') { $sql .= ' WHERE updated_at > :s'; $params['s'] = $since; } + $sql .= ' ORDER BY updated_at ASC LIMIT 500'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $tickets = $stmt->fetchAll(); + $out = []; + $ids = []; + foreach ($tickets as $t) { + $t['issue_id'] = supportRelayIssueId($db, (int)$t['id']); + $out[] = $t; + $ids[] = (int)$t['id']; + } + $replies = []; + if ($ids) { + $ph = implode(',', array_fill(0, count($ids), '?')); + $rstmt = $db->prepare("SELECT id, ticket_id, direction, body, sent_at, created_at FROM replies WHERE ticket_id IN ($ph) ORDER BY ticket_id, created_at"); + $rstmt->execute($ids); + foreach ($rstmt->fetchAll() as $r) { + $tid = (int)$r['ticket_id']; + if (!isset($replies[$tid])) $replies[$tid] = []; + $replies[$tid][] = $r; + } + } + foreach ($out as &$t) { + $t['replies'] = $replies[(int)$t['id']] ?? []; + } + unset($t); + supportRelayJson(['tickets' => $out]); +} + +// Push an outgoing reply. Relay generates the RFC 5322 message, sends +// via SMTP, logs the reply. Status flips to "answered" on success. +if (preg_match('#^/api/hyprcrd/tickets/(\d+)/reply$#', $route, $m) && $method === 'POST') { + $id = (int)$m[1]; + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $body = trim((string)($input['body'] ?? '')); + if ($body === '') supportRelayJson(['error' => 'body required'], 400); + + $t = $db->prepare('SELECT * FROM tickets WHERE id = :id'); + $t->execute(['id' => $id]); + $ticket = $t->fetch(); + if (!$ticket) supportRelayJson(['error' => 'ticket not found'], 404); + + $smtp = supportRelaySmtpConfig($db, $MASTER_KEY); + if ($smtp['host'] === '' || $smtp['from'] === '') supportRelayJson(['error' => 'SMTP not configured'], 500); + $brand = supportRelayGet($db, 'brand', 'Support'); + $issueId = supportRelayIssueId($db, $id); + $subject = ($ticket['subject'] ?: '(no subject)') . ' [' . $issueId . ']'; + $message = $body . "\n\n" . supportRelayRenderFooter($db, $issueId); + + $extraHeaders = ['Subject' => $subject, 'X-Support-Ticket' => $issueId]; + if (!empty($ticket['message_id'])) $extraHeaders['In-Reply-To'] = '<' . $ticket['message_id'] . '>'; + + $client = new SmtpClient($smtp); + $res = $client->send( + $ticket['email'], $smtp['from_name'], $smtp['from'], $message, $extraHeaders, + supportRelayMailOptions($message, $brand) + ); + if (!$res['ok']) supportRelayJson(['error' => 'send failed: ' . $res['error']], 500); + + $db->prepare('INSERT INTO replies (ticket_id, direction, body, sent_at) VALUES (:t, "out", :b, datetime("now"))') + ->execute(['t' => $id, 'b' => $body]); + $updateMid = empty($ticket['message_id']) && !empty($res['message_id']); + if ($updateMid) { + $db->prepare('UPDATE tickets SET status = "answered", updated_at = datetime("now"), last_reply_at = datetime("now"), message_id = :mid WHERE id = :id') + ->execute(['id' => $id, 'mid' => $res['message_id']]); + } else { + $db->prepare('UPDATE tickets SET status = "answered", updated_at = datetime("now"), last_reply_at = datetime("now") WHERE id = :id') + ->execute(['id' => $id]); + } + supportRelayJson(['ok' => true, 'message_id' => $res['message_id'] ?? null]); +} + +// Status change (open / answered / closed) +if (preg_match('#^/api/hyprcrd/tickets/(\d+)/status$#', $route, $m) && $method === 'PATCH') { + $id = (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)) supportRelayJson(['error' => 'invalid status'], 400); + $db->prepare('UPDATE tickets SET status = :s, updated_at = datetime("now") WHERE id = :id') + ->execute(['s' => $status, 'id' => $id]); + supportRelayJson(['ok' => true]); +} + +// Delete a ticket — rarely needed but useful for cleanup +if (preg_match('#^/api/hyprcrd/tickets/(\d+)$#', $route, $m) && $method === 'DELETE') { + $id = (int)$m[1]; + $db->prepare('DELETE FROM tickets WHERE id = :id')->execute(['id' => $id]); + supportRelayJson(['ok' => true]); +} + +// Basic health check so hyprcrd can verify connectivity during settings +// config without pulling a real payload. +if ($route === '/api/hyprcrd/ping' && $method === 'GET') { + supportRelayJson(['ok' => true, 'brand' => supportRelayGet($db, 'brand', 'Support')]); +} + +// Subset of editable relay settings that the admin (via hyprcrd) is +// allowed to change post-setup. SMTP credentials + API key stay locked +// to the setup wizard — editing those from an authenticated hyprcrd is +// out of scope for V1 and would need stricter handling. +if ($route === '/api/hyprcrd/settings' && $method === 'GET') { + supportRelayJson([ + 'brand' => supportRelayGet($db, 'brand', 'Support'), + 'public_url' => supportRelayGet($db, 'public_url', ''), + 'mail_footer' => supportRelayGet($db, 'mail_footer', '') ?: SUPPORT_RELAY_FOOTER_DEFAULT, + 'default_footer' => SUPPORT_RELAY_FOOTER_DEFAULT, + ]); +} +if ($route === '/api/hyprcrd/settings' && $method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true) ?: []; + if (array_key_exists('brand', $input)) supportRelaySet($db, 'brand', trim((string)$input['brand']) ?: 'Support'); + if (array_key_exists('public_url', $input)) supportRelaySet($db, 'public_url', rtrim((string)$input['public_url'], '/')); + if (array_key_exists('mail_footer', $input)) { + $v = (string)$input['mail_footer']; + // Empty string falls back to the built-in default at render time. + supportRelaySet($db, 'mail_footer', $v === '' ? null : $v); + } + supportRelayJson(['ok' => true]); +} diff --git a/support-relay/inc/routes/public.php b/support-relay/inc/routes/public.php new file mode 100644 index 0000000..ddfd56d --- /dev/null +++ b/support-relay/inc/routes/public.php @@ -0,0 +1,100 @@ + 'relay not configured'], 503); + if (supportRelayRateLimited($db, 'submit', 5, 60)) supportRelayJson(['error' => 'Too many requests. Try again in an hour.'], 429); + + $input = json_decode(file_get_contents('php://input'), true) ?: []; + // Honeypot: a hidden field browsers won't fill but bots will. If it's + // set, pretend success and drop the submission silently. + if (!empty($input['website'])) supportRelayJson(['ok' => true, 'issue_id' => 'OUMTA-0']); + + $email = trim((string)($input['email'] ?? '')); + $subject = trim((string)($input['subject'] ?? '')); + $body = trim((string)($input['body'] ?? '')); + $issueHint = trim((string)($input['issue_id'] ?? '')); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) supportRelayJson(['error' => 'valid email required'], 400); + if (mb_strlen($body) < 10) supportRelayJson(['error' => 'message too short'], 400); + if (mb_strlen($body) > 10000) supportRelayJson(['error' => 'message too long (max 10k)'], 400); + if (mb_strlen($subject) > 200) supportRelayJson(['error' => 'subject too long'], 400); + + // If the user claims to reply to an existing ticket, verify the email + // matches before appending. Mismatch → treat it as a brand-new ticket + // so we don't ghost-route hostile submissions to someone else's thread. + $targetId = $issueHint !== '' ? supportRelayParseIssueId($db, $issueHint) : null; + if ($targetId) { + $t = $db->prepare('SELECT id, email, subject FROM tickets WHERE id = :id'); + $t->execute(['id' => $targetId]); + $row = $t->fetch(); + if ($row && strtolower($row['email']) === strtolower($email)) { + $db->prepare('INSERT INTO replies (ticket_id, direction, body) VALUES (:t, "in", :b)') + ->execute(['t' => $targetId, 'b' => $body]); + $db->prepare('UPDATE tickets SET status = "open", updated_at = datetime("now"), last_reply_at = datetime("now") WHERE id = :id') + ->execute(['id' => $targetId]); + supportRelayJson([ + 'ok' => true, + 'issue_id' => supportRelayIssueId($db, $targetId), + 'appended' => true, + ]); + } + // Wrong email → fall through to new-ticket path. Don't leak that + // the id existed. + } + + // New ticket + $db->prepare('INSERT INTO tickets (email, subject, body, status) VALUES (:e, :s, :b, "open")') + ->execute(['e' => strtolower($email), 's' => $subject, 'b' => $body]); + $id = (int)$db->lastInsertId(); + $db->prepare('INSERT INTO replies (ticket_id, direction, body) VALUES (:t, "in", :b)') + ->execute(['t' => $id, 'b' => $body]); + $issueId = supportRelayIssueId($db, $id); + + // Confirmation mail — best-effort, swallow SMTP failures so the user + // still gets their issue id even if the mail gateway is misbehaving. + $smtp = supportRelaySmtpConfig($db, $MASTER_KEY); + if ($smtp['host'] !== '' && $smtp['from'] !== '') { + $brand = supportRelayGet($db, 'brand', 'Support'); + $body = "Hi,\n\nwe received your support request.\n\nIssue: " . $issueId + . "\nSubject: " . ($subject ?: '(no subject)') + . "\n\n" . supportRelayRenderFooter($db, $issueId); + $client = new SmtpClient($smtp); + $brandName = supportRelayGet($db, 'brand', 'Support'); + $client->send( + $email, $smtp['from_name'], $smtp['from'], $body, + ['Subject' => '[' . $issueId . '] ' . ($subject !== '' ? $subject : 'Support request received')], + supportRelayMailOptions($body, $brandName) + ); + } + + supportRelayJson(['ok' => true, 'issue_id' => $issueId]); +} + +if (preg_match('#^/api/ticket/([A-Za-z0-9-]+)$#', $route, $m) && $method === 'GET') { + if ($isSetup) supportRelayJson(['error' => 'relay not configured'], 503); + $email = trim((string)($_GET['email'] ?? '')); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) supportRelayJson(['error' => 'email required'], 400); + $id = supportRelayParseIssueId($db, $m[1]); + if (!$id) supportRelayJson(['error' => 'not found'], 404); + $t = $db->prepare('SELECT id, email, subject, status, created_at, updated_at FROM tickets WHERE id = :id'); + $t->execute(['id' => $id]); + $row = $t->fetch(); + if (!$row || strtolower($row['email']) !== strtolower($email)) supportRelayJson(['error' => 'not found'], 404); + // Public status view: only the outbound replies (what we answered). + // Internal notes / inbound replay stays hidden. + $reps = $db->prepare('SELECT body, created_at FROM replies WHERE ticket_id = :id AND direction = "out" ORDER BY created_at ASC'); + $reps->execute(['id' => $id]); + supportRelayJson([ + 'issue_id' => supportRelayIssueId($db, $id), + 'subject' => $row['subject'], + 'status' => $row['status'], + 'created_at' => $row['created_at'], + 'updated_at' => $row['updated_at'], + 'replies' => $reps->fetchAll(), + ]); +} diff --git a/support-relay/inc/routes/public_html.php b/support-relay/inc/routes/public_html.php new file mode 100644 index 0000000..f5c8511 --- /dev/null +++ b/support-relay/inc/routes/public_html.php @@ -0,0 +1,17 @@ + 'already configured'], 403); + if (supportRelayRateLimited($db, 'setup', 10, 10)) supportRelayJson(['error' => 'too many attempts'], 429); + + $input = json_decode(file_get_contents('php://input'), true) ?: []; + // Required fields + $brand = trim((string)($input['brand'] ?? 'Oumta Support')); + $prefix = strtoupper(preg_replace('/[^A-Z0-9]/i', '', (string)($input['issue_prefix'] ?? 'OUMTA'))); + $publicUrl = rtrim((string)($input['public_url'] ?? ''), '/'); + $footer = trim((string)($input['mail_footer'] ?? '')); + $smtpHost = trim((string)($input['smtp_host'] ?? '')); + $smtpPort = (int)($input['smtp_port'] ?? 587); + $smtpUser = trim((string)($input['smtp_user'] ?? '')); + $smtpPw = (string)($input['smtp_password'] ?? ''); + $smtpFrom = trim((string)($input['smtp_from'] ?? '')); + $smtpFromN = trim((string)($input['smtp_from_name'] ?? $brand)); + $smtpEnc = in_array($input['smtp_encryption'] ?? 'starttls', ['starttls', 'tls', 'none'], true) + ? $input['smtp_encryption'] : 'starttls'; + + if ($smtpHost === '') supportRelayJson(['error' => 'SMTP host required'], 400); + if (!filter_var($smtpFrom, FILTER_VALIDATE_EMAIL)) supportRelayJson(['error' => 'SMTP from must be a valid email'], 400); + if ($prefix === '' || strlen($prefix) > 12) supportRelayJson(['error' => 'issue prefix 1-12 chars, letters/digits only'], 400); + + // Generate the API key now, show it back ONCE — admin copies it to + // hyprcrd. A second request after setup can't re-read or rotate it + // except through a re-install (clear setup_done). That's fine for a + // single-user admin workflow. + $apiKey = bin2hex(random_bytes(24)); + + supportRelaySet($db, 'brand', $brand); + supportRelaySet($db, 'issue_prefix', $prefix); + if ($publicUrl !== '') supportRelaySet($db, 'public_url', $publicUrl); + if ($footer !== '') supportRelaySet($db, 'mail_footer', $footer); + supportRelaySet($db, 'smtp_host', $smtpHost); + supportRelaySet($db, 'smtp_port', (string)$smtpPort); + supportRelaySet($db, 'smtp_user', $smtpUser); + if ($smtpPw !== '') supportRelaySet($db, 'smtp_password', supportRelayEncrypt($smtpPw, $MASTER_KEY)); + supportRelaySet($db, 'smtp_from', $smtpFrom); + supportRelaySet($db, 'smtp_from_name', $smtpFromN); + supportRelaySet($db, 'smtp_encryption', $smtpEnc); + supportRelaySet($db, 'api_key', $apiKey); + supportRelaySet($db, 'setup_done', '1'); + + supportRelayJson(['ok' => true, 'api_key' => $apiKey]); +} diff --git a/support-relay/index.php b/support-relay/index.php new file mode 100644 index 0000000..15435b5 --- /dev/null +++ b/support-relay/index.php @@ -0,0 +1,61 @@ + 524288) { http_response_code(413); echo json_encode(['error' => 'payload too large']); exit; } + + require __DIR__ . '/inc/routes/setup.php'; + require __DIR__ . '/inc/routes/public.php'; + require __DIR__ . '/inc/routes/api.php'; + + http_response_code(404); + echo json_encode(['error' => 'not found']); + exit; +} + +// Setup UI (only while not yet configured) +if ($isSetup) { + require __DIR__ . '/ui/setup.php'; + exit; +} + +// Public form + ticket status +require __DIR__ . '/inc/routes/public_html.php'; + +// Fallback: 404 +http_response_code(404); +require __DIR__ . '/ui/notfound.php'; diff --git a/support-relay/logo2026col.svg b/support-relay/logo2026col.svg new file mode 100644 index 0000000..7d2f6ae --- /dev/null +++ b/support-relay/logo2026col.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + diff --git a/support-relay/ui/form.php b/support-relay/ui/form.php new file mode 100644 index 0000000..cd639b3 --- /dev/null +++ b/support-relay/ui/form.php @@ -0,0 +1,86 @@ + + + + + +<?= htmlspecialchars($brand) ?> + + + + +
+

+

Describe your issue and we'll reply by email. If you already opened a ticket, enter the -… id so we can keep the thread together.

+ + + +
+
+ + +
+
+ + +
+
+ + + Markdown is supported. The more specific the better. +
+
+ + + Leave empty for a new request. +
+ + +
+
+ + + diff --git a/support-relay/ui/notfound.php b/support-relay/ui/notfound.php new file mode 100644 index 0000000..0f638b7 --- /dev/null +++ b/support-relay/ui/notfound.php @@ -0,0 +1,15 @@ + + + + +Not found + + + + +
+

Not found

+

This page doesn't exist. Try the support form.

+
+ + diff --git a/support-relay/ui/setup.php b/support-relay/ui/setup.php new file mode 100644 index 0000000..63a5344 --- /dev/null +++ b/support-relay/ui/setup.php @@ -0,0 +1,125 @@ + + + + + +Support relay — setup + + + + +
+

Relay setup

+

First-run configuration. Once you save, this page disappears and the relay starts accepting tickets.

+ + + +
+

Branding

+
+ + + Shown on the public form, in email subjects, and at the bottom of replies. +
+
+ + + Tickets will look like OUMTA-42. +
+
+ + + Shown in outbound mail footers so users can reply via the form with their issue id. +
+
+ + + Appended to every outbound mail (confirmations + replies). Supports placeholders {brand}, {issue_id}, {public_url}. Lines with an empty placeholder are dropped. Leave empty to use the default. +
+ +

SMTP

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + diff --git a/support-relay/ui/status.php b/support-relay/ui/status.php new file mode 100644 index 0000000..ed0734c --- /dev/null +++ b/support-relay/ui/status.php @@ -0,0 +1,65 @@ + + + + + +Ticket status — <?= htmlspecialchars($brand) ?> + + + + +
+

Ticket status

+

Enter the email you submitted with to see the latest updates on your ticket.

+ + + +
+
+ + +
+ +
+ + +
+ + + diff --git a/support-relay/ui/style.css b/support-relay/ui/style.css new file mode 100644 index 0000000..7aec66f --- /dev/null +++ b/support-relay/ui/style.css @@ -0,0 +1,118 @@ +/* support-relay public + setup styles. Minimalist, self-contained — + * no font loading, no external deps, passes through whatever the + * system UI stack looks like. Dark + light friendly. */ + +:root { + --bg: #0f1726; + --surface: #1a2438; + --border: #2a3650; + --text: #cbd5e1; + --bright: #f1f5f9; + --dim: #64748b; + --accent: #38bdf8; + --ok: #4ade80; + --danger: #fb7185; + --radius: 6px; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + font-size: 15px; + line-height: 1.55; +} +main { + max-width: 560px; + margin: 0 auto; + padding: 48px 20px 64px; +} +h1 { color: var(--bright); font-size: 26px; margin-bottom: 6px; } +h2 { color: var(--bright); font-size: 18px; margin: 20px 0 8px; } +.lede { color: var(--dim); margin-bottom: 28px; font-size: 14px; } + +form { display: flex; flex-direction: column; gap: 16px; } +.field { display: flex; flex-direction: column; gap: 6px; } +label { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; } +input, textarea, select { + background: var(--surface); + border: 1px solid var(--border); + color: var(--bright); + padding: 10px 12px; + border-radius: var(--radius); + font: inherit; + font-size: 14px; + outline: none; + transition: border-color .15s; +} +input:focus, textarea:focus, select:focus { border-color: var(--accent); } +textarea { resize: vertical; min-height: 140px; font-family: inherit; } +.hint { font-size: 12px; color: var(--dim); } + +button[type="submit"], .btn { + appearance: none; + background: var(--accent); + color: #001526; + border: none; + font-size: 14px; + font-weight: 600; + padding: 12px 20px; + border-radius: var(--radius); + cursor: pointer; + transition: filter .15s, transform .05s; +} +button[type="submit"]:hover, .btn:hover { filter: brightness(1.1); } +button[type="submit"]:active, .btn:active { transform: translateY(1px); } + +.honey { + position: absolute; + left: -9999px; + width: 1px; height: 1px; + overflow: hidden; + opacity: 0; +} + +.banner { + padding: 12px 16px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + margin-bottom: 18px; + font-size: 13px; +} +.banner.ok { border-color: var(--ok); color: var(--ok); } +.banner.err { border-color: var(--danger); color: var(--danger); } +.issue-id { + font-family: ui-monospace, 'JetBrains Mono', monospace; + background: rgba(56, 189, 248, 0.12); + padding: 2px 6px; + border-radius: 3px; + color: var(--accent); +} +a { color: var(--accent); } + +/* Status / reply cards */ +.ticket-header { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 16px; } +.ticket-header h2 { margin: 0 0 4px; font-size: 15px; } +.ticket-meta { color: var(--dim); font-size: 12px; } +.reply { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + margin-bottom: 10px; + white-space: pre-wrap; +} +.reply-time { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } + +.setup-note { + background: rgba(74, 222, 128, 0.12); + border: 1px solid var(--ok); + color: var(--ok); + padding: 14px 16px; + border-radius: var(--radius); + font-family: ui-monospace, monospace; + font-size: 13px; + word-break: break-all; +}