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>
This commit is contained in:
1 parent
2d58cd993e
commit
7bce6a3a36
33 files changed
+2915
-10
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '2.0.10');
|
||||
define('APP_VERSION', '2.0.12');
|
||||
|
||||
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||
// Off by default; enable via env for local troubleshooting only.
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Tear down the full dev stack and wipe the support-relay data (DB +
|
||||
# master key). Hyprcrd and the main relay keep their data — this is
|
||||
# meant for iterating on support-relay setup / API-key rotation.
|
||||
#
|
||||
# Usage: ./dev-down.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# If the stand-alone support-relay/docker-compose.yml was used earlier
|
||||
# (before we folded it into the main compose), stop that too.
|
||||
if [ -f support-relay/docker-compose.yml ]; then
|
||||
( cd support-relay && docker compose down --remove-orphans ) 2>/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."
|
||||
@@ -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 ""
|
||||
@@ -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:
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Support Plugin: backend (hyprcrd side).
|
||||
*
|
||||
* Talks to a standalone support-relay via HTTP. Tickets live on the
|
||||
* relay; this plugin keeps a local cache + links each ticket to a
|
||||
* hyprcrd card so the user can answer from the card detail view.
|
||||
* KB fragments stay purely in hyprcrd (cards in a configured bucket).
|
||||
*
|
||||
* Routes (auth-protected, registered via the plugin dispatch):
|
||||
* GET /api/support/tickets list from local cache
|
||||
* GET /api/support/tickets/{id} single ticket w/ replies
|
||||
* POST /api/support/tickets/{id}/reply forward reply to the relay
|
||||
* PATCH /api/support/tickets/{id}/status forward status change
|
||||
* POST /api/support/poll pull tickets from the relay
|
||||
* GET /api/support/kb?q=... search KB fragments (local cards)
|
||||
* POST /api/support/kb save a new KB fragment
|
||||
* GET /api/support/settings read plugin config (no secret)
|
||||
* POST /api/support/settings write plugin config
|
||||
* POST /api/support/settings/test ping the configured relay
|
||||
*/
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
$db = Database::connect();
|
||||
// Local cache of tickets that live on the relay. We map relay_id
|
||||
// (authoritative) to hyprcrd card_id so the card detail view can
|
||||
// surface the ticket + its reply history.
|
||||
$db->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.
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,35 @@
|
||||
<!-- Support plugin overlay: ticket inbox + reply composer + KB panel.
|
||||
Rendered into the document root; shown/hidden via the .open class. -->
|
||||
<div id="support-overlay" class="support-overlay">
|
||||
<header class="support-header">
|
||||
<div class="support-header-left">
|
||||
<i class="ph ph-lifebuoy"></i>
|
||||
<span class="font-pixel text-xs text-t-bright">Support inbox</span>
|
||||
<button id="support-poll" class="support-btn support-btn-ghost" title="Poll the relay for new tickets"><i class="ph ph-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
<div class="support-header-right">
|
||||
<div class="support-filter">
|
||||
<button class="support-pill active" data-filter="">all</button>
|
||||
<button class="support-pill" data-filter="open">open</button>
|
||||
<button class="support-pill" data-filter="answered">answered</button>
|
||||
<button class="support-pill" data-filter="closed">closed</button>
|
||||
</div>
|
||||
<button id="support-close" class="support-btn support-btn-ghost" title="Close (Esc)"><i class="ph ph-x"></i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="support-body">
|
||||
<!-- Left: ticket list -->
|
||||
<aside id="support-list" class="support-list">
|
||||
<div class="support-empty">Loading…</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: active ticket detail + reply composer -->
|
||||
<section id="support-detail" class="support-detail">
|
||||
<div class="support-empty-hero">
|
||||
<i class="ph ph-envelope-open"></i>
|
||||
<p>Pick a ticket on the left.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = `<div class="support-empty" style="color:var(--t-danger);">Couldn't load: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<div class="support-empty" style="color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 = `<div class="support-empty">No tickets${state.filter ? ' with status ' + state.filter : ''}.</div>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = shown.map(t => `
|
||||
<div class="support-ticket ${t.relay_id == state.activeId ? 'active' : ''}" data-id="${t.relay_id}">
|
||||
<div class="support-ticket-row">
|
||||
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
||||
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
||||
</div>
|
||||
<div class="support-ticket-title">${escapeHtml(t.subject || '(no subject)')}</div>
|
||||
<div class="support-ticket-meta">
|
||||
<span>${escapeHtml(t.email)}</span>
|
||||
<span>${escapeHtml(formatRel(t.last_reply_at || t.updated_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).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 = `<div class="support-empty-hero"><i class="ph ph-envelope-open"></i><p>Pick a ticket on the left.</p></div>`;
|
||||
return;
|
||||
}
|
||||
const replies = (t.replies || []).map(r => `
|
||||
<div class="support-msg" data-dir="${escapeHtml(r.direction)}">
|
||||
<div class="support-msg-label" data-dir="${escapeHtml(r.direction)}">
|
||||
<span class="tag">${r.direction === 'out' ? 'sent' : 'received'}</span>
|
||||
<span>${escapeHtml(r.created_at)}</span>
|
||||
</div>${escapeHtml(r.body)}
|
||||
</div>
|
||||
`).join('');
|
||||
host.innerHTML = `
|
||||
<div class="support-ticket-header">
|
||||
<h2>${escapeHtml(t.subject || '(no subject)')}</h2>
|
||||
<div class="meta">
|
||||
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
||||
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
||||
<span class="email">${escapeHtml(t.email)}</span>
|
||||
<span>opened ${escapeHtml(formatRel(t.created_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="support-thread">
|
||||
${replies || '<div class="support-empty" style="padding:12px;">No messages yet.</div>'}
|
||||
</div>
|
||||
<div class="support-composer">
|
||||
<div class="support-composer-head">
|
||||
<span class="font-pixel">Reply</span>
|
||||
<input class="support-kb-search" placeholder="Search fragments…" id="support-kb-input">
|
||||
<button class="support-btn support-btn-ghost" id="support-kb-toggle" title="Toggle fragment list"><i class="ph ph-books"></i></button>
|
||||
</div>
|
||||
<div id="support-kb-results" class="support-kb-results"></div>
|
||||
<textarea id="support-reply-body" placeholder="Type your reply. Click a fragment to insert it at the cursor."></textarea>
|
||||
<div class="support-composer-actions">
|
||||
<span id="support-composer-status" class="support-composer-status"></span>
|
||||
<button class="support-btn" id="support-save-fragment"><i class="ph ph-plus-circle"></i> Save as fragment</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="support-btn support-btn-ghost" id="support-status-closed" title="Close without replying">Close ticket</button>
|
||||
<button class="support-btn support-btn-primary" id="support-send-btn"><i class="ph ph-paper-plane-tilt"></i> Send reply</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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) => `
|
||||
<div class="support-kb-result" data-idx="${i}">
|
||||
<div class="support-kb-result-title">${escapeHtml(f.title)}</div>
|
||||
<div class="support-kb-result-preview">${escapeHtml(stripMd(f.body).slice(0, 140))}</div>
|
||||
</div>
|
||||
`).join('') || `<div class="support-empty" style="padding:12px;">No fragments yet.</div>`;
|
||||
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 = `<div class="support-empty" style="padding:12px;color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
||||
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 = '<i class="ph ph-lifebuoy text-sm"></i><span class="font-pixel text-[10px]">Support</span>';
|
||||
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 = `
|
||||
<p class="support-settings-help" style="margin-bottom:14px;">
|
||||
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.
|
||||
</p>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-url">Relay URL</label>
|
||||
<input id="support-set-url" type="url" placeholder="https://support.example.com">
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-key">API key</label>
|
||||
<input id="support-set-key" type="password" placeholder="paste the key shown by the relay setup" autocomplete="off">
|
||||
<div class="support-settings-help">Leave empty to keep the currently stored key.</div>
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-inbox">Inbox bucket</label>
|
||||
<select id="support-set-inbox"><option value="0">(none — keep cards unsorted)</option></select>
|
||||
<div class="support-settings-help">Where newly-pulled ticket cards will land.</div>
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-set-kb">KB bucket</label>
|
||||
<select id="support-set-kb"><option value="0">(none — disable fragment library)</option></select>
|
||||
<div class="support-settings-help">Cards in this bucket become reusable reply fragments.</div>
|
||||
</div>
|
||||
<div class="support-settings-row">
|
||||
<button id="support-set-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save</button>
|
||||
<button id="support-set-test" class="support-btn"><i class="ph ph-pulse"></i> Test connection</button>
|
||||
<span id="support-set-status" class="support-settings-help"></span>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:20px;font-family:var(--t-font-pixel);font-size:12px;color:var(--t-accent);text-transform:uppercase;letter-spacing:0.5px;">Relay config</h2>
|
||||
<p class="support-settings-help" style="margin-bottom:14px;">
|
||||
Editable post-setup values on the connected relay. Mail footer supports
|
||||
<code>{brand}</code>, <code>{issue_id}</code>, <code>{public_url}</code>
|
||||
placeholders — lines containing an empty placeholder are dropped.
|
||||
</p>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-rs-brand">Brand</label>
|
||||
<input id="support-rs-brand" type="text">
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-rs-public">Public form URL</label>
|
||||
<input id="support-rs-public" type="url" placeholder="https://support.oumta.de">
|
||||
</div>
|
||||
<div class="support-settings-field">
|
||||
<label for="support-rs-footer">Mail footer template</label>
|
||||
<textarea id="support-rs-footer" rows="4" style="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;resize:vertical;"></textarea>
|
||||
<div class="support-settings-help" id="support-rs-footer-default"></div>
|
||||
</div>
|
||||
<div class="support-settings-row">
|
||||
<button id="support-rs-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save relay config</button>
|
||||
<span id="support-rs-status" class="support-settings-help"></span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Populate bucket selects from state.buckets
|
||||
const opts = (window.state?.buckets || []).map(b => `<option value="${b.id}">${escapeHtml(b.name)}</option>`).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' });
|
||||
@@ -0,0 +1,6 @@
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/master.key
|
||||
.git
|
||||
.gitignore
|
||||
docker-compose.yml
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
@@ -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 🌙
|
||||
Whitespace-only changes.
@@ -0,0 +1,2 @@
|
||||
# Block all direct access to the data directory (SQLite, master key).
|
||||
Require all denied
|
||||
@@ -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
|
||||
Executable
+12
@@ -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
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="232"
|
||||
height="232"
|
||||
viewBox="0 0 232 232"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs9" />
|
||||
<path
|
||||
d="M 0,116 C 0,24.7544 24.7544,0 116,0 207.246,0 232,24.7544 232,116 232,207.246 207.246,232 116,232 24.7544,232 0,207.246 0,116 Z"
|
||||
fill="#1abc9c"
|
||||
id="path1" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 104.987,130.658 6.13,9.239 -27.77,42.888 C 81.5798,185.518 78.2916,186.513 76,185 l -0.1883,-0.123 c -2.2916,-1.509 -2.7175,-4.947 -0.9502,-7.682 z m 20.23,-12.392 -6.131,-9.239 12.802,-19.9097 c 0.307,-0.4739 0.628,-0.9391 0.961,-1.3955 1.727,-2.3521 7.55,-10.2833 11.519,-15.754 2.048,-2.791 3.375,-6.0559 3.807,-9.3822 0.304,-2.1825 1.199,-4.5844 2.668,-6.8604 3.523,-5.4533 9.033,-8.1331 12.295,-5.9828 3.265,2.1474 3.054,8.3203 -0.469,13.7735 -1.284,1.9835 -2.828,3.6013 -4.479,4.6633 -3.494,2.475 -6.446,5.8189 -8.561,9.6894 l -8.291,15.2011 c -0.336,0.6173 -0.696,1.2229 -1.078,1.8139 z M 80.4848,77.8686 c -2.1119,-3.8705 -5.067,-7.2144 -8.5579,-9.6894 -1.6514,-1.062 -3.1955,-2.6798 -4.4789,-4.6633 -3.5228,-5.4532 -3.7343,-11.6261 -0.4693,-13.7735 3.2621,-2.1503 8.7723,0.5295 12.2952,5.9828 1.4688,2.276 2.3611,4.6779 2.6681,6.8604 0.4317,3.3263 1.7586,6.5912 3.8068,9.3822 3.969,5.4707 9.7921,13.4019 11.5187,15.754 0.3332,0.4564 0.6548,0.9216 0.9619,1.3955 l 57.2566,88.2917 c 1.767,2.736 1.341,6.173 -0.951,7.683 l -0.188,0.123 c -2.292,1.512 -5.58,0.517 -7.347,-2.215 L 89.8568,94.8836 c -0.3824,-0.591 -0.7416,-1.1966 -1.0777,-1.8139 z"
|
||||
fill="#ffffff"
|
||||
id="path2" />
|
||||
<path
|
||||
d="m 46.3615,124.961 c -6.3649,0 -7.3615,-2.279 -7.3615,-5.087 0,-2.809 0.9966,-5.09 7.3615,-5.09 6.3619,0 15.6992,5.09 15.6992,5.09 0,0 -9.3373,5.087 -15.6992,5.087 z"
|
||||
fill="#ffffff"
|
||||
id="path3" />
|
||||
<path
|
||||
d="m 48.3349,91.6457 c -5.6667,-2.9285 -5.5247,-5.4181 -4.2587,-7.9165 1.2689,-2.5014 3.1839,-4.0724 8.8505,-1.1439 5.6638,2.9284 11.6781,11.7519 11.6781,11.7519 0,0 -10.6062,0.234 -16.2699,-2.6915 z"
|
||||
fill="#ffffff"
|
||||
id="path4" />
|
||||
<path
|
||||
d="m 53,156.679 c -5.6667,2.929 -7.5816,1.358 -8.8506,-1.144 -1.266,-2.498 -1.4079,-4.988 4.2587,-7.916 5.6638,-2.928 16.27,-2.692 16.27,-2.692 0,0 -6.0143,8.824 -11.6781,11.752 z"
|
||||
fill="#ffffff"
|
||||
id="path5" />
|
||||
<path
|
||||
d="m 183.317,124.961 c 6.364,0 7.361,-2.279 7.361,-5.087 0,-2.809 -0.997,-5.09 -7.361,-5.09 -6.362,0 -15.7,5.09 -15.7,5.09 0,0 9.338,5.087 15.7,5.087 z"
|
||||
fill="#ffffff"
|
||||
id="path6" />
|
||||
<path
|
||||
d="m 181.343,91.6457 c 5.667,-2.9285 5.525,-5.4181 4.259,-7.9165 -1.269,-2.5014 -3.184,-4.0724 -8.851,-1.1439 -5.663,2.9284 -11.678,11.7519 -11.678,11.7519 0,0 10.606,0.234 16.27,-2.6915 z"
|
||||
fill="#ffffff"
|
||||
id="path7" />
|
||||
<path
|
||||
d="m 176.678,156.679 c 5.667,2.929 7.582,1.358 8.851,-1.144 1.266,-2.498 1.408,-4.988 -4.259,-7.916 -5.664,-2.928 -16.27,-2.692 -16.27,-2.692 0,0 6.014,8.824 11.678,11.752 z"
|
||||
fill="#ffffff"
|
||||
id="path8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
// Minimal SMTP client. No dependencies, just stream_socket_client +
|
||||
// STARTTLS. Covers the narrow case this project needs: plain-text
|
||||
// ASCII/UTF-8 mail, one recipient, AUTH LOGIN. Deliberately does not
|
||||
// aim to replace PHPMailer — just enough SMTP for outbound replies.
|
||||
|
||||
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
class SmtpClient {
|
||||
private array $config;
|
||||
private $sock;
|
||||
private array $log = [];
|
||||
|
||||
public function __construct(array $config) { $this->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 <img src="cid:logo">
|
||||
* 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 <img src="cid:...">.
|
||||
$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 '<base64 redacted>';
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
// API-key auth for hyprcrd-facing routes. One shared secret, sent via
|
||||
// X-Support-Key. Compared with hash_equals to avoid timing leaks.
|
||||
|
||||
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
function supportRelayApiAuth(PDO $db): bool {
|
||||
$key = $_SERVER['HTTP_X_SUPPORT_KEY'] ?? '';
|
||||
if ($key === '') return false;
|
||||
$stored = supportRelayGet($db, 'api_key', '') ?: '';
|
||||
if ($stored === '') return false;
|
||||
return hash_equals($stored, $key);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
// hyprcrd-facing API. Pulls new/updated tickets, pushes outbound replies
|
||||
// (which the relay then sends via SMTP), syncs status changes. Shared
|
||||
// secret auth via X-Support-Key.
|
||||
|
||||
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
// All /api/hyprcrd/* paths require the API key. Guard once up front.
|
||||
if (str_starts_with($route, '/api/hyprcrd/')) {
|
||||
if ($isSetup) supportRelayJson(['error' => '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]);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
// Public-facing routes used by the form JS: submit a ticket, check
|
||||
// ticket status. No auth — rate-limited + honeypot + issue-ID-email
|
||||
// guard against abuse.
|
||||
|
||||
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
if ($route === '/api/submit' && $method === 'POST') {
|
||||
if ($isSetup) supportRelayJson(['error' => '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(),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
// Non-API routes for the human-facing pages: the submit form at `/`,
|
||||
// the "check my ticket" status page at `/ticket/XXX`.
|
||||
|
||||
if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
if ($route === '/' || $route === '/index.php' || $route === '') {
|
||||
require __DIR__ . '/../../ui/form.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if (preg_match('#^/ticket/([A-Za-z0-9-]+)$#', $route, $m)) {
|
||||
// Render the public status view. Actual data comes via the JS call
|
||||
// to /api/ticket/{id}?email=… once the user types their email.
|
||||
require __DIR__ . '/../../ui/status.php';
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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]);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?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';
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1079"
|
||||
height="232"
|
||||
viewBox="0 0 1079 232"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs9" />
|
||||
<path
|
||||
d="M 0,116 C 0,24.7544 24.7544,0 116,0 207.246,0 232,24.7544 232,116 232,207.246 207.246,232 116,232 24.7544,232 0,207.246 0,116 Z"
|
||||
fill="#1abc9c"
|
||||
id="path1" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 104.987,130.658 6.13,9.239 -27.77,42.888 C 81.5798,185.518 78.2916,186.513 76,185 l -0.1883,-0.123 c -2.2916,-1.509 -2.7175,-4.947 -0.9502,-7.682 z m 20.23,-12.392 -6.131,-9.239 12.802,-19.9097 c 0.307,-0.4739 0.628,-0.9391 0.961,-1.3955 1.727,-2.3521 7.55,-10.2833 11.519,-15.754 2.048,-2.791 3.375,-6.0559 3.807,-9.3822 0.304,-2.1825 1.199,-4.5844 2.668,-6.8604 3.523,-5.4533 9.033,-8.1331 12.295,-5.9828 3.265,2.1474 3.054,8.3203 -0.469,13.7735 -1.284,1.9835 -2.828,3.6013 -4.479,4.6633 -3.494,2.475 -6.446,5.8189 -8.561,9.6894 l -8.291,15.2011 c -0.336,0.6173 -0.696,1.2229 -1.078,1.8139 z M 80.4848,77.8686 c -2.1119,-3.8705 -5.067,-7.2144 -8.5579,-9.6894 -1.6514,-1.062 -3.1955,-2.6798 -4.4789,-4.6633 -3.5228,-5.4532 -3.7343,-11.6261 -0.4693,-13.7735 3.2621,-2.1503 8.7723,0.5295 12.2952,5.9828 1.4688,2.276 2.3611,4.6779 2.6681,6.8604 0.4317,3.3263 1.7586,6.5912 3.8068,9.3822 3.969,5.4707 9.7921,13.4019 11.5187,15.754 0.3332,0.4564 0.6548,0.9216 0.9619,1.3955 l 57.2566,88.2917 c 1.767,2.736 1.341,6.173 -0.951,7.683 l -0.188,0.123 c -2.292,1.512 -5.58,0.517 -7.347,-2.215 L 89.8568,94.8836 c -0.3824,-0.591 -0.7416,-1.1966 -1.0777,-1.8139 z"
|
||||
fill="#ffffff"
|
||||
id="path2" />
|
||||
<path
|
||||
d="m 46.3615,124.961 c -6.3649,0 -7.3615,-2.279 -7.3615,-5.087 0,-2.809 0.9966,-5.09 7.3615,-5.09 6.3619,0 15.6992,5.09 15.6992,5.09 0,0 -9.3373,5.087 -15.6992,5.087 z"
|
||||
fill="#ffffff"
|
||||
id="path3" />
|
||||
<path
|
||||
d="m 48.3349,91.6457 c -5.6667,-2.9285 -5.5247,-5.4181 -4.2587,-7.9165 1.2689,-2.5014 3.1839,-4.0724 8.8505,-1.1439 5.6638,2.9284 11.6781,11.7519 11.6781,11.7519 0,0 -10.6062,0.234 -16.2699,-2.6915 z"
|
||||
fill="#ffffff"
|
||||
id="path4" />
|
||||
<path
|
||||
d="m 53,156.679 c -5.6667,2.929 -7.5816,1.358 -8.8506,-1.144 -1.266,-2.498 -1.4079,-4.988 4.2587,-7.916 5.6638,-2.928 16.27,-2.692 16.27,-2.692 0,0 -6.0143,8.824 -11.6781,11.752 z"
|
||||
fill="#ffffff"
|
||||
id="path5" />
|
||||
<path
|
||||
d="m 183.317,124.961 c 6.364,0 7.361,-2.279 7.361,-5.087 0,-2.809 -0.997,-5.09 -7.361,-5.09 -6.362,0 -15.7,5.09 -15.7,5.09 0,0 9.338,5.087 15.7,5.087 z"
|
||||
fill="#ffffff"
|
||||
id="path6" />
|
||||
<path
|
||||
d="m 181.343,91.6457 c 5.667,-2.9285 5.525,-5.4181 4.259,-7.9165 -1.269,-2.5014 -3.184,-4.0724 -8.851,-1.1439 -5.663,2.9284 -11.678,11.7519 -11.678,11.7519 0,0 10.606,0.234 16.27,-2.6915 z"
|
||||
fill="#ffffff"
|
||||
id="path7" />
|
||||
<path
|
||||
d="m 176.678,156.679 c 5.667,2.929 7.582,1.358 8.851,-1.144 1.266,-2.498 1.408,-4.988 -4.259,-7.916 -5.664,-2.928 -16.27,-2.692 -16.27,-2.692 0,0 6.014,8.824 11.678,11.752 z"
|
||||
fill="#ffffff"
|
||||
id="path8" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="m 583.571,117.678 c 0,34.947 -28.353,63.322 -63.277,63.322 -34.925,0 -63.278,-28.375 -63.278,-63.322 V 73.4908 c 0,-8.2811 6.717,-15.0031 14.992,-15.0031 8.274,0 14.991,6.722 14.991,15.0031 v 44.1872 c 0,18.389 14.919,33.32 33.295,33.32 18.375,0 33.294,-14.931 33.294,-33.32 V 73.4908 c 0,-8.2811 6.717,-15.0031 14.991,-15.0031 8.275,0 14.992,6.722 14.992,15.0031 z m 368.875,-2.352 C 952.446,80.3746 980.802,52 1015.72,52 c 34.93,0 63.28,28.3746 63.28,63.326 v 44.183 c 0,8.281 -6.72,15.003 -14.99,15.003 -8.28,0 -14.99,-6.722 -14.99,-15.003 v -26.922 h -66.592 v 28.23 c 0,8.281 -6.716,15.003 -14.991,15.003 -8.27,0 -14.991,-6.722 -14.991,-15.003 z m 96.064,-5.801 c -2.74,-15.6297 -16.39,-27.5189 -32.79,-27.5189 -16.393,0 -30.04,11.8892 -32.785,27.5189 z m -332.344,1.793 v 50.261 c 0,8.282 -6.717,15.004 -14.991,15.004 -8.275,0 -14.992,-6.722 -14.992,-15.004 v -51.603 c 0,-16.9838 -11.543,-26.6749 -25.926,-26.6749 -14.383,0 -25.922,9.6911 -25.922,26.6749 v 50.832 c 0,8.282 -6.717,15.004 -14.992,15.004 -8.274,0 -14.991,-6.722 -14.991,-15.004 v -50.832 c 0,-36.3532 25.122,-56.6768 55.905,-56.6768 16.141,0 30.723,5.5847 40.948,16.1405 10.219,-10.5558 24.806,-16.1405 40.943,-16.1405 30.783,0 55.909,20.3236 55.909,56.6768 v 50.832 c 0,8.282 -6.721,15.004 -14.991,15.004 -8.275,0 -14.992,-6.722 -14.992,-15.004 v -50.832 c 0,-16.9838 -11.543,-26.6749 -25.926,-26.6749 -14.383,0 -25.922,9.6911 -25.922,26.6749 0,0.452 -0.022,0.899 -0.06,1.342 z M 863.327,87.3991 h -39.262 c -8.275,0 -14.992,-6.722 -14.992,-15.0031 0,-8.2768 6.717,-15.0031 14.992,-15.0031 h 105.314 c 8.275,0 14.992,6.7263 14.992,15.0031 0,8.2811 -6.717,15.0031 -14.992,15.0031 H 893.31 v 73.2139 c 0,8.281 -6.717,15.003 -14.992,15.003 -8.275,0 -14.991,-6.722 -14.991,-15.003 z M 373.277,54.3514 c 34.925,0 63.277,28.3746 63.277,63.3266 0,34.947 -28.352,63.322 -63.277,63.322 C 338.353,181 310,152.625 310,117.678 310,82.726 338.353,54.3514 373.277,54.3514 Z m 0,30.0061 c -18.375,0 -33.294,14.9307 -33.294,33.3205 0,18.389 14.919,33.32 33.294,33.32 18.376,0 33.295,-14.931 33.295,-33.32 0,-18.3898 -14.919,-33.3205 -33.295,-33.3205 z"
|
||||
fill="#bbbbbb"
|
||||
id="path9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,86 @@
|
||||
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
$brand = supportRelayGet($db, 'brand', 'Support');
|
||||
$prefix = supportRelayGet($db, 'issue_prefix', 'OUMTA');
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($brand) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="ui/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1><?= htmlspecialchars($brand) ?></h1>
|
||||
<p class="lede">Describe your issue and we'll reply by email. If you already opened a ticket, enter the <span class="issue-id"><?= htmlspecialchars($prefix) ?>-…</span> id so we can keep the thread together.</p>
|
||||
|
||||
<div id="banner"></div>
|
||||
|
||||
<form id="support-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="email">Your email</label>
|
||||
<input id="email" name="email" type="email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subject">Subject</label>
|
||||
<input id="subject" name="subject" type="text" maxlength="200">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="body">Message</label>
|
||||
<textarea id="body" name="body" required minlength="10" maxlength="10000"></textarea>
|
||||
<span class="hint">Markdown is supported. The more specific the better.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="issue_id">Reply to existing ticket (optional)</label>
|
||||
<input id="issue_id" name="issue_id" type="text" placeholder="<?= htmlspecialchars($prefix) ?>-42" autocomplete="off">
|
||||
<span class="hint">Leave empty for a new request.</span>
|
||||
</div>
|
||||
<div class="honey" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const banner = $('banner');
|
||||
function setBanner(text, kind = '') {
|
||||
banner.className = 'banner ' + kind;
|
||||
banner.textContent = text;
|
||||
}
|
||||
|
||||
$('support-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
btn.disabled = true; btn.textContent = 'Sending…';
|
||||
const payload = {
|
||||
email: $('email').value.trim(),
|
||||
subject: $('subject').value.trim(),
|
||||
body: $('body').value,
|
||||
issue_id: $('issue_id').value.trim(),
|
||||
website: $('website').value,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Submission failed.');
|
||||
const msg = data.appended
|
||||
? `Added to your existing ticket ${data.issue_id}. We'll get back to you by email.`
|
||||
: `Received. Your issue id is ${data.issue_id} — check your inbox for the confirmation.`;
|
||||
setBanner(msg, 'ok');
|
||||
e.target.reset();
|
||||
} catch (err) {
|
||||
setBanner(err.message || 'Something went wrong.', 'err');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Send';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } ?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Not found</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="ui/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Not found</h1>
|
||||
<p class="lede">This page doesn't exist. Try <a href=".">the support form</a>.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } ?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Support relay — setup</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="ui/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Relay setup</h1>
|
||||
<p class="lede">First-run configuration. Once you save, this page disappears and the relay starts accepting tickets.</p>
|
||||
|
||||
<div id="banner"></div>
|
||||
|
||||
<form id="setup-form" novalidate>
|
||||
<h2>Branding</h2>
|
||||
<div class="field">
|
||||
<label for="brand">Display name</label>
|
||||
<input id="brand" type="text" value="Oumta Support" required>
|
||||
<span class="hint">Shown on the public form, in email subjects, and at the bottom of replies.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="issue_prefix">Issue-id prefix</label>
|
||||
<input id="issue_prefix" type="text" value="OUMTA" pattern="[A-Za-z0-9]{1,12}" required>
|
||||
<span class="hint">Tickets will look like <span class="issue-id">OUMTA-42</span>.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="public_url">Public form URL (optional)</label>
|
||||
<input id="public_url" type="url" placeholder="https://support.oumta.de">
|
||||
<span class="hint">Shown in outbound mail footers so users can reply via the form with their issue id.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mail_footer">Mail footer (optional)</label>
|
||||
<textarea id="mail_footer" rows="4" placeholder="--- {brand} · Ticket {issue_id} Reply via {public_url} (issue id: {issue_id})"></textarea>
|
||||
<span class="hint">Appended to every outbound mail (confirmations + replies). Supports placeholders <code>{brand}</code>, <code>{issue_id}</code>, <code>{public_url}</code>. Lines with an empty placeholder are dropped. Leave empty to use the default.</span>
|
||||
</div>
|
||||
|
||||
<h2>SMTP</h2>
|
||||
<div class="field">
|
||||
<label for="smtp_host">Host</label>
|
||||
<input id="smtp_host" type="text" placeholder="smtp.example.com" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp_port">Port</label>
|
||||
<input id="smtp_port" type="number" value="587">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp_encryption">Encryption</label>
|
||||
<select id="smtp_encryption">
|
||||
<option value="starttls" selected>STARTTLS (587)</option>
|
||||
<option value="tls">Implicit TLS (465)</option>
|
||||
<option value="none">None (25, not recommended)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp_user">Username</label>
|
||||
<input id="smtp_user" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp_password">Password</label>
|
||||
<input id="smtp_password" type="password" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp_from">From address</label>
|
||||
<input id="smtp_from" type="email" placeholder="support@oumta.de" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="smtp_from_name">From name</label>
|
||||
<input id="smtp_from_name" type="text" placeholder="Oumta Support">
|
||||
</div>
|
||||
|
||||
<button type="submit">Save & generate API key</button>
|
||||
</form>
|
||||
|
||||
<div id="done" style="display:none;">
|
||||
<h2>Relay is configured.</h2>
|
||||
<p class="lede">Paste this API key into the hyprcrd <strong>support</strong> plugin settings, next to the relay URL. It's shown once — copy it now.</p>
|
||||
<div class="setup-note" id="apikey"></div>
|
||||
<p class="lede" style="margin-top:20px;">Once hyprcrd is connected, the public form is live at this URL.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const banner = $('banner');
|
||||
function setBanner(text, kind = 'err') {
|
||||
banner.className = 'banner ' + kind;
|
||||
banner.textContent = text;
|
||||
}
|
||||
|
||||
$('setup-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
brand: $('brand').value.trim(),
|
||||
issue_prefix: $('issue_prefix').value.trim().toUpperCase(),
|
||||
public_url: $('public_url').value.trim(),
|
||||
mail_footer: $('mail_footer').value,
|
||||
smtp_host: $('smtp_host').value.trim(),
|
||||
smtp_port: parseInt($('smtp_port').value) || 587,
|
||||
smtp_encryption: $('smtp_encryption').value,
|
||||
smtp_user: $('smtp_user').value.trim(),
|
||||
smtp_password: $('smtp_password').value,
|
||||
smtp_from: $('smtp_from').value.trim(),
|
||||
smtp_from_name: $('smtp_from_name').value.trim() || $('brand').value.trim(),
|
||||
};
|
||||
try {
|
||||
const res = await fetch('api/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Setup failed.');
|
||||
$('setup-form').style.display = 'none';
|
||||
$('apikey').textContent = data.api_key;
|
||||
$('done').style.display = '';
|
||||
banner.textContent = '';
|
||||
} catch (err) {
|
||||
setBanner(err.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php if (!defined('SUPPORT_RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
$brand = supportRelayGet($db, 'brand', 'Support');
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ticket status — <?= htmlspecialchars($brand) ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="ui/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Ticket status</h1>
|
||||
<p class="lede">Enter the email you submitted with to see the latest updates on your ticket.</p>
|
||||
|
||||
<div id="banner"></div>
|
||||
|
||||
<form id="status-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="email">Your email</label>
|
||||
<input id="email" type="email" required autocomplete="email">
|
||||
</div>
|
||||
<button type="submit">View</button>
|
||||
</form>
|
||||
|
||||
<div id="ticket" style="display:none;margin-top:24px;"></div>
|
||||
</main>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const banner = $('banner');
|
||||
const issueId = location.pathname.replace(/.*\/ticket\//, '');
|
||||
|
||||
function esc(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
$('status-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const email = $('email').value.trim();
|
||||
banner.className = ''; banner.textContent = '';
|
||||
const res = await fetch('api/ticket/' + encodeURIComponent(issueId) + '?email=' + encodeURIComponent(email));
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
banner.className = 'banner err';
|
||||
banner.textContent = data.error || 'Not found.';
|
||||
$('ticket').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const replies = (data.replies || []).map(r => `
|
||||
<div class="reply">
|
||||
<div class="reply-time">${esc(r.created_at)}</div>
|
||||
${esc(r.body).replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
`).join('') || '<p class="lede">No replies yet. We\'ll email you as soon as there is news.</p>';
|
||||
$('ticket').innerHTML = `
|
||||
<div class="ticket-header">
|
||||
<h2>${esc(data.subject || '(no subject)')}</h2>
|
||||
<div class="ticket-meta"><span class="issue-id">${esc(data.issue_id)}</span> · status: <strong>${esc(data.status)}</strong> · opened ${esc(data.created_at)}</div>
|
||||
</div>
|
||||
${replies}
|
||||
`;
|
||||
$('ticket').style.display = '';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user