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
@@ -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