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; }