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

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

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

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

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

243 lines
11 KiB
PHP

<?php
// DB, master-key, schema, shared helpers. All include files require
// SUPPORT_RELAY_BOOTSTRAPPED to be defined by index.php.
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
error_reporting(0);
ini_set('display_errors', '0');
$SUPPORT_ROOT = dirname(__DIR__);
$dbPath = $SUPPORT_ROOT . '/data/support.db';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);
// Master key for encrypting SMTP creds + admin password hash context
$masterKeyPath = $SUPPORT_ROOT . '/data/master.key';
if (!file_exists($masterKeyPath)) {
file_put_contents($masterKeyPath, bin2hex(random_bytes(32)));
@chmod($masterKeyPath, 0600);
}
$MASTER_KEY = trim(file_get_contents($masterKeyPath));
$db = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => 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
? '<img src="cid:logo" alt="' . htmlspecialchars($brand, ENT_QUOTES) . '" style="max-height:48px;display:block;margin-bottom:18px;">'
: '';
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
. '<body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#1a2438;line-height:1.55;padding:20px;max-width:620px;margin:0 auto;">'
. $logo
. '<div style="font-size:14px;">' . $safe . '</div>'
. '</body></html>';
}
/** 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;
}