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>
54 lines
3.0 KiB
PHP
54 lines
3.0 KiB
PHP
<?php
|
|
// /api/setup — one-shot configuration on first install. After the admin
|
|
// completes this form the API key becomes required for every /api/*
|
|
// route and the setup endpoint returns 403. Designed to be impossible
|
|
// to re-trigger without clearing `settings.setup_done` in the DB.
|
|
|
|
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
if ($route === '/api/setup' && $method === 'POST') {
|
|
if (!$isSetup) supportRelayJson(['error' => '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]);
|
|
}
|