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

62 lines
2.0 KiB
PHP

<?php
// ── hyprcrd support-relay ──
//
// Standalone PHP service. Hosts the public support form, queues incoming
// tickets, and exposes an API that the hyprcrd "support" plugin polls +
// pushes replies through. SMTP (confirmation + outbound replies) lives
// here because the relay is the network-facing component; hyprcrd stays
// private. Single-tenant by design — one admin, one API key.
define('SUPPORT_RELAY_BOOTSTRAPPED', true);
require_once __DIR__ . '/inc/bootstrap.php';
require_once __DIR__ . '/inc/SmtpClient.php';
require_once __DIR__ . '/inc/auth.php';
$route = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
if ($base !== '/') $route = substr($route, strlen($base));
$route = '/' . ltrim($route ?: '/', '/');
$method = $_SERVER['REQUEST_METHOD'];
$isSetup = !supportRelaySetupDone($db);
// Security headers for every response
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
if (!empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// API endpoints
if (str_starts_with($route, '/api/')) {
header('Content-Type: application/json');
// Payload size caps. Form body is small; reply pushes from hyprcrd can
// contain a full markdown answer.
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($contentLength > 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';