Compare commits
22
Commits
6fa42eb267
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a216906da | ||
|
|
130a1c660b | ||
|
|
4baeee159e | ||
|
|
8cbec8d601 | ||
|
|
7bce6a3a36 | ||
|
|
2d58cd993e | ||
|
|
87ebcd156b | ||
|
|
4aca21812e | ||
|
|
9f90ea516a | ||
|
|
0553960a57 | ||
|
|
1e51bdec7c | ||
|
|
f986a41ccc | ||
|
|
48b5907f4c | ||
|
|
89d84d3538 | ||
|
|
29e818ac64 | ||
|
|
53ce58a5aa | ||
|
|
72a7c7c7cc | ||
|
|
1dfffa5d6d | ||
|
|
d83de30d6d | ||
|
|
d50f9fe789 | ||
|
|
8e738ed9d4 | ||
|
|
c934673c4a |
No files matched your search
@@ -15,3 +15,4 @@ data/hyprcrd.db-wal
|
|||||||
/oumta-dashboard/
|
/oumta-dashboard/
|
||||||
/oumta-dashboard-ui/
|
/oumta-dashboard-ui/
|
||||||
|
|
||||||
|
relay/data
|
||||||
+13
-1
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.26');
|
define('APP_VERSION', '2.0.16');
|
||||||
|
|
||||||
|
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||||
|
// Off by default; enable via env for local troubleshooting only.
|
||||||
|
define('APP_DEBUG', getenv('APP_DEBUG') === '1' || getenv('APP_DEBUG') === 'true');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
@@ -14,3 +18,11 @@ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|||||||
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
$basePath = ($scriptDir === '/' || $scriptDir === '\\') ? '' : $scriptDir;
|
||||||
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
define('BASE_URL', $protocol . '://' . $host . $basePath);
|
||||||
|
|
||||||
|
// Oumta dashboard upstream. The browser only talks to /api/dashboard/*
|
||||||
|
// same-origin; the PHP proxy below forwards to this URL. Override via env
|
||||||
|
// for remote deployments. Inside Docker on Linux, localhost points at the
|
||||||
|
// container itself — use http://host.docker.internal:8085 (needs
|
||||||
|
// extra_hosts) or a routable URL to reach a host-side dashboard.
|
||||||
|
define('OUMTA_DASHBOARD_URL', getenv('OUMTA_DASHBOARD_URL') ?: 'http://localhost:8085');
|
||||||
|
define('OUMTA_DASHBOARD_TOKEN', getenv('OUMTA_DASHBOARD_TOKEN') ?: '');
|
||||||
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
|
#!/bin/bash
|
||||||
# Start hyprcrd + relay for local development. The relay service is
|
# Start hyprcrd + relay + support-relay for local development. Both
|
||||||
# behind a compose profile since it's opt-in — this script explicitly
|
# auxiliary services sit behind compose profiles — this script opts
|
||||||
# opts in so you get both containers.
|
# into all of them so a dev can exercise the full sync + support flows
|
||||||
|
# without juggling profile flags.
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
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 ""
|
||||||
echo " hyprcrd: http://localhost:8088"
|
echo " hyprcrd: http://localhost:8088"
|
||||||
echo " relay: http://localhost:8081"
|
#echo " relay: http://localhost:8081"
|
||||||
|
echo " support-relay: http://localhost:8090"
|
||||||
echo ""
|
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 ""
|
||||||
echo " (for app-only, drop the relay profile: docker compose up -d)"
|
echo " (for app-only, drop the profiles: docker compose up -d)"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -3,6 +3,15 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8088:80"
|
- "8088:80"
|
||||||
|
environment:
|
||||||
|
# `host.docker.internal` resolves to the Docker host via the
|
||||||
|
# extra_hosts mapping below, so the PHP proxy can reach a dashboard
|
||||||
|
# running directly on the host. Swap to a routable URL when the
|
||||||
|
# dashboard moves to a remote server.
|
||||||
|
OUMTA_DASHBOARD_URL: http://host.docker.internal:8085
|
||||||
|
# OUMTA_DASHBOARD_TOKEN: "" # Keycloak bearer, when auth is on
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/var/www/html/data
|
- ./data:/var/www/html/data
|
||||||
- ./src:/var/www/html/src
|
- ./src:/var/www/html/src
|
||||||
@@ -25,5 +34,19 @@ services:
|
|||||||
- ./relay/ui:/var/www/html/ui
|
- ./relay/ui:/var/www/html/ui
|
||||||
- relay-data:/var/www/html/data
|
- 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:
|
volumes:
|
||||||
relay-data:
|
relay-data:
|
||||||
|
support-relay-data:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.26",
|
"version": "2.0.14",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+52
-11
@@ -109,17 +109,14 @@ select[class*="border-t-accent"] {
|
|||||||
.prose img { border-radius: var(--ui-radius-image); }
|
.prose img { border-radius: var(--ui-radius-image); }
|
||||||
.board-card.card-has-cover { border-radius: var(--ui-radius-card); }
|
.board-card.card-has-cover { border-radius: var(--ui-radius-card); }
|
||||||
|
|
||||||
/* Modal containers (the inner box of each overlay) */
|
/* Modal containers (the inner box of each overlay). Only for floating /
|
||||||
#modal > div,
|
* framed modals — the fullscreen overlays (modal, detail, search, tasks,
|
||||||
#detail > div,
|
* calendar, gallery, and the support / plugin overlays) fill the whole
|
||||||
|
* viewport now and don't want the rounded-corner + overflow:hidden
|
||||||
|
* combo, which would clip the top color bar and soften the edge-to-edge
|
||||||
|
* look. */
|
||||||
#settings-overlay > div,
|
#settings-overlay > div,
|
||||||
#search > div,
|
#dialog > div {
|
||||||
#tasks > div,
|
|
||||||
#calendar > div,
|
|
||||||
#gallery > div,
|
|
||||||
#dialog > div,
|
|
||||||
#detail > div,
|
|
||||||
#modal > div {
|
|
||||||
border-radius: var(--ui-radius-modal);
|
border-radius: var(--ui-radius-modal);
|
||||||
box-shadow: var(--ui-shadow-modal);
|
box-shadow: var(--ui-shadow-modal);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -395,14 +392,28 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
|||||||
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
||||||
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
.due-pill { display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border-radius: 8px; font-size: 9px; line-height: 16px; color: var(--t-bg); font-family: var(--t-font-pixel); }
|
||||||
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
||||||
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
||||||
|
/* Rounded-cards variant: the corner triangle gets clipped by the
|
||||||
|
* rounded border, so toggle to a circle pill that sits comfortably
|
||||||
|
* inside any radius. JS adds `rounded-cards` on body whenever
|
||||||
|
* --ui-radius-card is > 0. */
|
||||||
|
body.rounded-cards .card-done-badge {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
bottom: 6px; right: 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
body.rounded-cards .card-done-badge i { position: static; bottom: auto; right: auto; font-size: 11px; color: #000; font-weight: bold; }
|
||||||
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.card-spinner { animation: spin 0.8s linear infinite; }
|
.card-spinner { animation: spin 0.8s linear infinite; display: inline-block; }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
||||||
@@ -513,6 +524,36 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
|||||||
animation: card-remove 0.25s ease-in forwards;
|
animation: card-remove 0.25s ease-in forwards;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
/* Move-in-flight: card ghosted with a spinner overlay while the
|
||||||
|
* PATCH + reloadAll round-trip completes. The pseudo-elements paint
|
||||||
|
* a dim scrim and a centred spinning ring so the origin bucket
|
||||||
|
* clearly shows the card is "leaving". Removed by the next
|
||||||
|
* renderBoard() since the DOM is rebuilt from scratch. */
|
||||||
|
.board-card.card-moving {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.board-card.card-moving::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.board-card.card-moving::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
margin: -11px 0 0 -11px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: var(--t-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.box-select {
|
.box-select {
|
||||||
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
||||||
pointer-events: none; z-index: 10;
|
pointer-events: none; z-index: 10;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+392
-84
@@ -55,6 +55,22 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
error_reporting(0);
|
error_reporting(0);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// IP-keyed rate limiter for auth endpoints — session cookies are trivial
|
||||||
|
// to rotate, so the in-session counter alone is bypassable.
|
||||||
|
$hyprcrdClientIp = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-10 minutes')");
|
||||||
|
$hyprcrdIpRateLimited = function (string $endpoint, int $max, int $windowMinutes = 5) use ($db, $hyprcrdClientIp): bool {
|
||||||
|
if ($hyprcrdClientIp === '') return false;
|
||||||
|
$q = $db->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')");
|
||||||
|
$q->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
|
||||||
|
return (int)$q->fetchColumn() >= $max;
|
||||||
|
};
|
||||||
|
$hyprcrdRecordAttempt = function (string $endpoint) use ($db, $hyprcrdClientIp): void {
|
||||||
|
if ($hyprcrdClientIp === '') return;
|
||||||
|
$db->prepare('INSERT INTO login_attempts (ip, endpoint) VALUES (:ip, :ep)')
|
||||||
|
->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
|
||||||
|
};
|
||||||
|
|
||||||
// GET /api/auth/status
|
// GET /api/auth/status
|
||||||
if ($route === '/api/auth/status' && $method === 'GET') {
|
if ($route === '/api/auth/status' && $method === 'GET') {
|
||||||
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
|
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
|
||||||
@@ -63,11 +79,20 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
|
|
||||||
// POST /api/auth/setup — set initial password
|
// POST /api/auth/setup — set initial password
|
||||||
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
|
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
|
||||||
|
// CSRF: same-origin + same-session guarantee. A drive-by POST from
|
||||||
|
// another site won't have the csrf cookie-bound token, so it can't
|
||||||
|
// silently claim a freshly-provisioned instance.
|
||||||
|
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'invalid csrf token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$pw = $input['password'] ?? '';
|
$pw = $input['password'] ?? '';
|
||||||
if (strlen($pw) < 4) {
|
if (strlen($pw) < 10) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'password too short (min 4)']);
|
echo json_encode(['error' => 'password too short (min 10)']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
||||||
@@ -79,13 +104,11 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auth/login (rate limited)
|
// POST /api/auth/login (IP-keyed rate limit)
|
||||||
if ($route === '/api/auth/login' && $method === 'POST') {
|
if ($route === '/api/auth/login' && $method === 'POST') {
|
||||||
$attempts = $_SESSION['login_attempts'] ?? 0;
|
if ($hyprcrdIpRateLimited('login', 10, 5)) {
|
||||||
$lastAttempt = $_SESSION['login_last'] ?? 0;
|
|
||||||
if ($attempts >= 5 && time() - $lastAttempt < 60) {
|
|
||||||
http_response_code(429);
|
http_response_code(429);
|
||||||
echo json_encode(['error' => 'too many attempts, wait 60s']);
|
echo json_encode(['error' => 'too many attempts, try again later']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -94,11 +117,9 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['hyprcrd_auth'] = true;
|
$_SESSION['hyprcrd_auth'] = true;
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
$_SESSION['login_attempts'] = 0;
|
|
||||||
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
|
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
|
||||||
} else {
|
} else {
|
||||||
$_SESSION['login_attempts'] = $attempts + 1;
|
$hyprcrdRecordAttempt('login');
|
||||||
$_SESSION['login_last'] = time();
|
|
||||||
usleep(300000); // 300ms delay against timing attacks
|
usleep(300000); // 300ms delay against timing attacks
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'wrong password']);
|
echo json_encode(['error' => 'wrong password']);
|
||||||
@@ -159,18 +180,24 @@ if (str_starts_with($route, '/api/auth')) {
|
|||||||
|
|
||||||
// POST /api/auth/password — change password (requires current password)
|
// POST /api/auth/password — change password (requires current password)
|
||||||
if ($route === '/api/auth/password' && $method === 'POST') {
|
if ($route === '/api/auth/password' && $method === 'POST') {
|
||||||
|
if ($hyprcrdIpRateLimited('password', 10, 5)) {
|
||||||
|
http_response_code(429);
|
||||||
|
echo json_encode(['error' => 'too many attempts, try again later']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$current = $input['current'] ?? '';
|
$current = $input['current'] ?? '';
|
||||||
$newPass = $input['new'] ?? '';
|
$newPass = $input['new'] ?? '';
|
||||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
if (!password_verify($current, $hash)) {
|
if (!password_verify($current, $hash)) {
|
||||||
|
$hyprcrdRecordAttempt('password');
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'wrong password']);
|
echo json_encode(['error' => 'wrong password']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if (strlen($newPass) < 4) {
|
if (strlen($newPass) < 10) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'min 4 characters']);
|
echo json_encode(['error' => 'min 10 characters']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
|
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
|
||||||
@@ -246,7 +273,113 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
|
function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; }
|
||||||
function extractTags($body) {
|
function extractTags($body) {
|
||||||
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches);
|
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches);
|
||||||
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
|
// English ↔ German tag aliases: the calendar + journal logic is
|
||||||
|
// hardcoded against 'geburtstag' / 'termin', so accept the English
|
||||||
|
// synonyms and normalise to the canonical German form at write
|
||||||
|
// time. Keeps every downstream check (card.tags includes 'geburtstag')
|
||||||
|
// working without touching dozens of JS call sites.
|
||||||
|
$aliases = ['birthday' => 'geburtstag', 'appointment' => 'termin'];
|
||||||
|
$tags = array_map(fn($t) => $aliases[$t] ?? $t, array_map('strtolower', $matches[1] ?? []));
|
||||||
|
return implode(',', array_unique($tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-later push helpers — called after card insert/update when
|
||||||
|
// cards.read_push is 1, or when the user toggles it off and we need
|
||||||
|
// to wipe the remote copy. Failures are logged and swallowed so
|
||||||
|
// relay trouble never blocks a local save.
|
||||||
|
function pushCardToRelayRead(array $card, PDO $db): void {
|
||||||
|
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
|
||||||
|
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
|
||||||
|
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
|
||||||
|
if (!$relayUrl || !$relayKey || !$relayUser) return;
|
||||||
|
|
||||||
|
$relayUid = $card['relay_uid'] ?? null;
|
||||||
|
if (!$relayUid) {
|
||||||
|
$relayUid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $card['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (string)($card['body'] ?? '');
|
||||||
|
// Hero = first uploads/<hex>.<ext> reference. All inline images
|
||||||
|
// get pushed as attachments so the reader can resolve them.
|
||||||
|
preg_match_all('#uploads/([a-f0-9]+\.[a-z0-9]+)#i', $body, $matches);
|
||||||
|
$imageFilenames = array_unique(array_map('strtolower', $matches[1] ?? []));
|
||||||
|
$heroFilename = $imageFilenames[0] ?? '';
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'relay_uid' => $relayUid,
|
||||||
|
'title' => hyprcrdRelayEncrypt((string)($card['title'] ?? ''), $relayKey),
|
||||||
|
'body' => hyprcrdRelayEncrypt($body, $relayKey),
|
||||||
|
'hero_filename' => $heroFilename,
|
||||||
|
]);
|
||||||
|
// Aggressive timeouts: read-later is opt-in + best-effort. If the
|
||||||
|
// relay is slow or unreachable we must NOT block the user's save —
|
||||||
|
// this function still runs inline from the PATCH handler (we'll
|
||||||
|
// also flush the response before invoking it), so the shorter
|
||||||
|
// timeout caps the worst-case backlog even if flushing fails.
|
||||||
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status < 200 || $status >= 300) {
|
||||||
|
@error_log('[hyprcrd] read push failed: HTTP ' . $status . ' ' . substr((string)$resp, 0, 200));
|
||||||
|
return; // don't bother uploading images if the item didn't land
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload referenced images as read-attachments (encrypted blob,
|
||||||
|
// same envelope format as collab-attachments).
|
||||||
|
$uploadDir = UPLOAD_DIR;
|
||||||
|
foreach ($imageFilenames as $fn) {
|
||||||
|
$filePath = $uploadDir . '/' . $fn;
|
||||||
|
if (!is_file($filePath)) continue;
|
||||||
|
$bytes = file_get_contents($filePath);
|
||||||
|
if ($bytes === false) continue;
|
||||||
|
$attPayload = json_encode([
|
||||||
|
'relay_uid' => $relayUid,
|
||||||
|
'filename' => $fn,
|
||||||
|
'data' => hyprcrdRelayEncrypt(base64_encode($bytes), $relayKey),
|
||||||
|
]);
|
||||||
|
$ach = curl_init(rtrim($relayUrl, '/') . '/api/read-attachments');
|
||||||
|
curl_setopt_array($ach, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $attPayload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
|
curl_exec($ach);
|
||||||
|
curl_close($ach);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCardFromRelayRead(string $relayUid, PDO $db): void {
|
||||||
|
if ($relayUid === '') return;
|
||||||
|
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
|
||||||
|
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
|
||||||
|
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
|
||||||
|
if (!$relayUrl || !$relayKey || !$relayUser) return;
|
||||||
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read/' . urlencode($relayUid));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||||
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin route handlers (registered by plugin backend.php files).
|
// Plugin route handlers (registered by plugin backend.php files).
|
||||||
@@ -260,7 +393,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||||
$pos = $input['position'] ?? 0;
|
$pos = $input['position'] ?? 0;
|
||||||
$body = $input['body'] ?? '';
|
$body = $input['body'] ?? '';
|
||||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([
|
$readPush = !empty($input['read_push']) ? 1 : 0;
|
||||||
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad, read_push) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad, :read_push)')->execute([
|
||||||
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
|
'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null),
|
||||||
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
||||||
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
||||||
@@ -268,8 +402,24 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
'tags' => extractTags($body),
|
'tags' => extractTags($body),
|
||||||
'due_date' => $input['due_date'] ?? null,
|
'due_date' => $input['due_date'] ?? null,
|
||||||
'scratchpad' => $input['scratchpad'] ?? null,
|
'scratchpad' => $input['scratchpad'] ?? null,
|
||||||
|
'read_push' => $readPush,
|
||||||
]);
|
]);
|
||||||
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
|
$saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch();
|
||||||
|
// When the card opts into read-later, generate the relay_uid up
|
||||||
|
// front so the response the client receives already carries it.
|
||||||
|
// Actually pushing to the relay happens AFTER the response is
|
||||||
|
// flushed — the relay hop is best-effort and shouldn't stretch
|
||||||
|
// the user-facing save latency.
|
||||||
|
if ($readPush && $saved && empty($saved['relay_uid'])) {
|
||||||
|
$uid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $saved['id']]);
|
||||||
|
$saved['relay_uid'] = $uid;
|
||||||
|
}
|
||||||
|
echo json_encode($saved);
|
||||||
|
if ($readPush && $saved) {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
pushCardToRelayRead($saved, $db);
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,20 +439,28 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/cards/meta' && $method === 'GET') {
|
if ($route === '/api/cards/meta' && $method === 'GET') {
|
||||||
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
|
echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, c.read_push, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll());
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
// CalDAV tombstone for synced cards
|
// CalDAV tombstone for synced cards, plus a read-list wipe so the
|
||||||
$card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id');
|
// mobile reader stops showing cards that no longer exist locally.
|
||||||
|
$card = $db->prepare('SELECT caldav_uid, relay_uid, read_push FROM cards WHERE id = :id');
|
||||||
$card->execute(['id' => $m[1]]);
|
$card->execute(['id' => $m[1]]);
|
||||||
$uid = $card->fetchColumn();
|
$cardRow = $card->fetch();
|
||||||
if ($uid) {
|
if ($cardRow && !empty($cardRow['caldav_uid'])) {
|
||||||
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]);
|
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]);
|
||||||
}
|
}
|
||||||
|
$wipeFromRelay = ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid']))
|
||||||
|
? (string)$cardRow['relay_uid'] : null;
|
||||||
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]);
|
||||||
|
if ($wipeFromRelay) {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
deleteCardFromRelayRead($wipeFromRelay, $db);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
||||||
@@ -333,10 +491,54 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) {
|
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) {
|
||||||
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
|
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('read_push', $input)) {
|
||||||
|
$fields[] = 'read_push = :read_push';
|
||||||
|
$params['read_push'] = !empty($input['read_push']) ? 1 : 0;
|
||||||
|
}
|
||||||
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
|
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
|
||||||
|
// Snapshot the previous read_push / relay_uid before the write so we
|
||||||
|
// can detect a toggle-off and remove the remote copy afterwards.
|
||||||
|
// The history-tracking block above only loads $old when a tracked
|
||||||
|
// field changed; we need it unconditionally when read_push is in
|
||||||
|
// play, so backfill here if missing.
|
||||||
|
$prevRead = null; $prevRelayUid = null;
|
||||||
|
if (array_key_exists('read_push', $input) && !isset($old)) {
|
||||||
|
$snap = $db->prepare('SELECT read_push, relay_uid FROM cards WHERE id = :id');
|
||||||
|
$snap->execute(['id' => $m[1]]);
|
||||||
|
$old = $snap->fetch() ?: null;
|
||||||
|
}
|
||||||
|
if (isset($old) && $old) { $prevRead = (int)($old['read_push'] ?? 0); $prevRelayUid = $old['relay_uid'] ?? null; }
|
||||||
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
|
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
|
||||||
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
|
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
|
||||||
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
|
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
|
||||||
|
// Decide the relay-sync action BEFORE echoing the response, then
|
||||||
|
// flush + run the actual cURL hop afterwards. The user doesn't
|
||||||
|
// wait for the relay round-trip; a dead/slow relay no longer
|
||||||
|
// stretches the card save into "eine Ewigkeit".
|
||||||
|
$pushAction = null; // 'push' | 'delete' | null
|
||||||
|
if ($savedCard) {
|
||||||
|
$nowRead = (int)($savedCard['read_push'] ?? 0);
|
||||||
|
if ($nowRead === 1) {
|
||||||
|
// Mint relay_uid up front so the response carries it.
|
||||||
|
if (empty($savedCard['relay_uid'])) {
|
||||||
|
$uid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $savedCard['id']]);
|
||||||
|
$savedCard['relay_uid'] = $uid;
|
||||||
|
}
|
||||||
|
$pushAction = 'push';
|
||||||
|
} elseif ($prevRead === 1 && $prevRelayUid) {
|
||||||
|
$pushAction = 'delete';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode($savedCard);
|
||||||
|
if ($pushAction === 'push') {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
pushCardToRelayRead($savedCard, $db);
|
||||||
|
} elseif ($pushAction === 'delete') {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
deleteCardFromRelayRead((string)$prevRelayUid, $db);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
@@ -752,21 +954,37 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract uploads
|
// Extract uploads. Guard against zip bombs and malicious payloads:
|
||||||
|
// per-file 25MB cap, 500MB total, whitelist allowed extensions (same
|
||||||
|
// set as the live /api/upload endpoint), never overwrite existing
|
||||||
|
// files, never leak paths via traversal.
|
||||||
$uploadDir = UPLOAD_DIR;
|
$uploadDir = UPLOAD_DIR;
|
||||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||||
|
$allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'ogg', 'wav', 'm4a', 'pdf'];
|
||||||
|
$perFileMax = 25 * 1024 * 1024; // 25 MB
|
||||||
|
$totalMax = 500 * 1024 * 1024; // 500 MB
|
||||||
|
$totalWritten = 0;
|
||||||
|
$skipped = [];
|
||||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
$name = $zip->getNameIndex($i);
|
$name = $zip->getNameIndex($i);
|
||||||
if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') {
|
if (!str_starts_with($name, 'uploads/') || $name === 'uploads/') continue;
|
||||||
$content = $zip->getFromIndex($i);
|
$base = basename($name);
|
||||||
if ($content !== false) {
|
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||||
file_put_contents($uploadDir . '/' . basename($name), $content);
|
if (!in_array($ext, $allowedExts, true)) { $skipped[] = $base; continue; }
|
||||||
}
|
$stat = $zip->statIndex($i);
|
||||||
}
|
if ($stat && isset($stat['size']) && $stat['size'] > $perFileMax) { $skipped[] = $base; continue; }
|
||||||
|
if ($totalWritten + (int)($stat['size'] ?? 0) > $totalMax) { $skipped[] = $base; continue; }
|
||||||
|
$dest = $uploadDir . '/' . $base;
|
||||||
|
if (file_exists($dest)) { $skipped[] = $base; continue; }
|
||||||
|
$content = $zip->getFromIndex($i);
|
||||||
|
if ($content === false) continue;
|
||||||
|
if (strlen($content) > $perFileMax) { $skipped[] = $base; continue; }
|
||||||
|
file_put_contents($dest, $content);
|
||||||
|
$totalWritten += strlen($content);
|
||||||
}
|
}
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]);
|
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards']), 'skipped_files' => count($skipped)]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,24 +1005,82 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
if ($route === '/api/caldav/sync' && $method === 'POST') {
|
||||||
echo json_encode($caldav->sync()); exit;
|
echo json_encode($caldav->sync()); exit;
|
||||||
}
|
}
|
||||||
|
// ── Oumta dashboard proxy ──
|
||||||
|
// Browser talks to /api/dashboard/* same-origin; we forward everything
|
||||||
|
// after that prefix to OUMTA_DASHBOARD_URL. Keeps CORS out of the
|
||||||
|
// browser, lets the dashboard live on a private network, and keeps any
|
||||||
|
// bearer token server-side. No SSE / streaming here: the response is
|
||||||
|
// buffered whole, so the plugin uses its polling fallback.
|
||||||
|
if (str_starts_with($route, '/api/dashboard/')) {
|
||||||
|
$upstreamPath = substr($route, strlen('/api/dashboard'));
|
||||||
|
$upstream = rtrim(OUMTA_DASHBOARD_URL, '/') . $upstreamPath;
|
||||||
|
$query = $_SERVER['QUERY_STRING'] ?? '';
|
||||||
|
if ($query !== '') $upstream .= '?' . $query;
|
||||||
|
|
||||||
|
$headers = ['Accept: application/json'];
|
||||||
|
if (OUMTA_DASHBOARD_TOKEN !== '') {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . OUMTA_DASHBOARD_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($upstream);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
]);
|
||||||
|
if ($method !== 'GET' && $method !== 'HEAD') {
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
if ($body !== '' && $body !== false) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
$headers[] = 'Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? 'application/json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$ct = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($status === 0) {
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'dashboard upstream unreachable', 'detail' => APP_DEBUG ? $err : '']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code($status);
|
||||||
|
if ($ct) header('Content-Type: ' . $ct);
|
||||||
|
echo $resp;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Relay ──
|
// ── Relay ──
|
||||||
|
|
||||||
if ($route === '/api/relay/settings' && $method === 'GET') {
|
if ($route === '/api/relay/settings' && $method === 'GET') {
|
||||||
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
|
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
|
||||||
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
|
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
|
||||||
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
|
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
|
||||||
// Single-user local app — echo the stored secret back so the user can
|
// Never echo the plaintext secret to the client — if it leaks via XSS
|
||||||
// see and edit it directly without guessing if it's still saved.
|
// or a compromised session, the attacker can decrypt all relay traffic.
|
||||||
echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
|
// The UI just needs to know whether a secret is set.
|
||||||
|
echo json_encode(['url' => $url, 'username' => $username, 'has_key' => !empty($key)]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/relay/settings' && $method === 'POST') {
|
if ($route === '/api/relay/settings' && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
|
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
|
||||||
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
|
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
|
||||||
// Always overwrite key when present (even empty) — single-user local
|
// The UI no longer receives the stored secret, so empty strings are
|
||||||
// app, the input is now pre-populated so an empty submit is intentional
|
// just "field untouched" and we only write when the user types a new
|
||||||
if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
|
// value. An explicit null clears the secret for users who need it.
|
||||||
|
if (array_key_exists('key', $input)) {
|
||||||
|
$newKey = $input['key'];
|
||||||
|
if ($newKey === null) {
|
||||||
|
$db->prepare("DELETE FROM settings WHERE key = 'relay_key'")->execute();
|
||||||
|
} elseif (is_string($newKey) && $newKey !== '') {
|
||||||
|
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $newKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +1107,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
@@ -851,13 +1128,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
|
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
|
||||||
|
|
||||||
$pulled = 0;
|
$pulled = 0;
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
$decryptField = function($text) use ($relayKey) {
|
||||||
$decryptField = function($text) use ($encKey) {
|
$decoded = hyprcrdRelayDecrypt($text, $relayKey);
|
||||||
if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text;
|
// Dual-format decrypt returns '' only when a magic-prefixed
|
||||||
$raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? ''));
|
// envelope failed to decrypt — surface the original string in
|
||||||
if (strlen($raw) < 17) return $text;
|
// that case so the UI still shows something useful.
|
||||||
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
if ($decoded === '' && (str_starts_with($text, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($text, HYPRCRD_RELAY_CBC_MAGIC))) {
|
||||||
return $plain !== false ? $plain : $text;
|
return $text;
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load IDs we pushed ourselves
|
// Load IDs we pushed ourselves
|
||||||
@@ -920,6 +1199,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
],
|
],
|
||||||
CURLOPT_TIMEOUT => 5,
|
CURLOPT_TIMEOUT => 5,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($bch);
|
||||||
$bRes = curl_exec($bch);
|
$bRes = curl_exec($bch);
|
||||||
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
|
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($bch);
|
curl_close($bch);
|
||||||
@@ -936,6 +1216,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
$attResponse = curl_exec($ach);
|
$attResponse = curl_exec($ach);
|
||||||
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
|
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ach);
|
curl_close($ach);
|
||||||
@@ -949,23 +1230,28 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
|
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
|
||||||
$dest = $uploadDir . '/' . $fn;
|
$dest = $uploadDir . '/' . $fn;
|
||||||
if (file_exists($dest)) continue;
|
if (file_exists($dest)) continue;
|
||||||
// New format: HYPRCRD-RELAY-V1 envelope (decrypt-reencrypted by relay)
|
// Dual-format: GCM-V2 envelope (v1.10+) and legacy
|
||||||
// Old format: raw base64(iv || cipher)
|
// CBC-V1 envelope; plaintext inside is base64(file
|
||||||
|
// bytes). Very-old pre-envelope format was raw
|
||||||
|
// base64(iv || cipher) — unlikely at this point but
|
||||||
|
// cheap to keep as a last-resort fallback.
|
||||||
$payload = $att['data'] ?? '';
|
$payload = $att['data'] ?? '';
|
||||||
if (str_starts_with($payload, 'HYPRCRD-RELAY-V1')) {
|
$decrypted = false;
|
||||||
$b64 = trim(substr($payload, strlen('HYPRCRD-RELAY-V1') + 1));
|
if (str_starts_with($payload, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($payload, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||||
$raw = base64_decode($b64);
|
$plain = hyprcrdRelayDecrypt($payload, $relayKey);
|
||||||
|
if ($plain !== '') $decrypted = $plain;
|
||||||
} else {
|
} else {
|
||||||
|
$legacyKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||||
$raw = base64_decode($payload);
|
$raw = base64_decode($payload);
|
||||||
}
|
if ($raw !== false && strlen($raw) >= 17) {
|
||||||
if (strlen($raw) >= 17) {
|
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $legacyKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||||
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
|
||||||
if ($decrypted !== false) {
|
|
||||||
$fileBytes = base64_decode($decrypted);
|
|
||||||
if ($fileBytes !== false) $decrypted = $fileBytes;
|
|
||||||
file_put_contents($dest, $decrypted);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($decrypted !== false) {
|
||||||
|
$fileBytes = base64_decode($decrypted);
|
||||||
|
if ($fileBytes !== false) $decrypted = $fileBytes;
|
||||||
|
file_put_contents($dest, $decrypted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -982,6 +1268,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 5,
|
CURLOPT_TIMEOUT => 5,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($sch);
|
||||||
$sResp = curl_exec($sch);
|
$sResp = curl_exec($sch);
|
||||||
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
|
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($sch);
|
curl_close($sch);
|
||||||
@@ -1015,6 +1302,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
@@ -1054,12 +1342,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
$encryptField = fn($text) => hyprcrdRelayEncrypt((string)$text, $relayKey);
|
||||||
$encryptField = function($text) use ($encKey) {
|
|
||||||
$iv = random_bytes(16);
|
|
||||||
$cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
|
||||||
};
|
|
||||||
|
|
||||||
$payloadData = [
|
$payloadData = [
|
||||||
'title' => $encryptField($card['title'] ?? ''),
|
'title' => $encryptField($card['title'] ?? ''),
|
||||||
@@ -1072,21 +1355,27 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$payload = json_encode($payloadData);
|
$payload = json_encode($payloadData);
|
||||||
|
|
||||||
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
|
||||||
$debugStream = fopen('php://temp', 'w+');
|
$debugStream = APP_DEBUG ? fopen('php://temp', 'w+') : null;
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_TIMEOUT => 10,
|
||||||
CURLOPT_VERBOSE => true,
|
|
||||||
CURLOPT_STDERR => $debugStream,
|
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
|
if ($debugStream) {
|
||||||
|
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||||
|
curl_setopt($ch, CURLOPT_STDERR, $debugStream);
|
||||||
|
}
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
rewind($debugStream);
|
$curlDebug = '';
|
||||||
$curlDebug = stream_get_contents($debugStream);
|
if ($debugStream) {
|
||||||
fclose($debugStream);
|
rewind($debugStream);
|
||||||
|
$curlDebug = stream_get_contents($debugStream);
|
||||||
|
fclose($debugStream);
|
||||||
|
}
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
if ($status === 200 || $status === 201) {
|
if ($status === 200 || $status === 201) {
|
||||||
@@ -1121,6 +1410,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
curl_exec($ach);
|
curl_exec($ach);
|
||||||
curl_close($ach);
|
curl_close($ach);
|
||||||
}
|
}
|
||||||
@@ -1135,16 +1425,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
} elseif (is_string($response) && $response !== '') {
|
} elseif (is_string($response) && $response !== '') {
|
||||||
$relayMsg = ' — ' . substr($response, 0, 200);
|
$relayMsg = ' — ' . substr($response, 0, 200);
|
||||||
}
|
}
|
||||||
// Strip the X-Relay-Key value from the debug log so we don't leak the secret
|
// In production, don't leak upstream URL / raw bodies / curl traces —
|
||||||
$safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
|
// APP_DEBUG=1 re-enables them for local troubleshooting.
|
||||||
echo json_encode([
|
$out = ['ok' => false, 'error' => "relay returned HTTP $status$relayMsg", 'sent_username' => $relayUser];
|
||||||
'ok' => false,
|
if (APP_DEBUG) {
|
||||||
'error' => "relay returned HTTP $status$relayMsg",
|
$out['relay_url'] = rtrim($relayUrl, '/') . '/api/messages';
|
||||||
'sent_username' => $relayUser,
|
$out['response_body'] = is_string($response) ? substr($response, 0, 500) : null;
|
||||||
'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
|
$out['curl_debug'] = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
|
||||||
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
|
}
|
||||||
'curl_debug' => $safeDebug,
|
echo json_encode($out);
|
||||||
]);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,6 +1448,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>hyprcrd</title>
|
<title>hyprcrd</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="stylesheet" href="css/fonts.css">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
|
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
|
||||||
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
|
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
|
||||||
@@ -1177,6 +1467,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
paper: { label:'Bright Mind', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0.05)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
|
paper: { label:'Bright Mind', vars:{ '--t-bg':'#f4f4f5','--t-surface':'#ffffff','--t-raised':'#e8e8eb','--t-border':'#d4d4d8','--t-dim':'#a1a1aa','--t-muted':'#71717a','--t-text':'#3f3f46','--t-bright':'#18181b','--t-accent':'#2563eb','--t-accent2':'#7c3aed','--t-danger':'#dc2626','--t-scanline':'rgba(0,0,0,0.05)','--t-overlay':'rgba(244,244,245,0.8)','--t-font-pixel':'"JetBrains Mono", monospace','--t-cover-gradient':'linear-gradient(180deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.75) 40%, rgba(255,255,255,0.3) 100%)' }},
|
||||||
sand: { label:'Sand', vars:{ '--t-bg':'#d6c5a3','--t-surface':'#e2d5b8','--t-raised':'#ede2c5','--t-border':'#a89572','--t-dim':'#8a7654','--t-muted':'#5d4f36','--t-text':'#3a3120','--t-bright':'#1a1408','--t-accent':'#b91c1c','--t-accent2':'#15803d','--t-danger':'#7c2d12','--t-scanline':'rgba(60,40,20,0.06)','--t-overlay':'rgba(60,50,30,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(214,196,160,0.22) 0%, rgba(214,196,160,0.7) 62%, rgba(214,196,160,0.86) 100%)' }},
|
sand: { label:'Sand', vars:{ '--t-bg':'#d6c5a3','--t-surface':'#e2d5b8','--t-raised':'#ede2c5','--t-border':'#a89572','--t-dim':'#8a7654','--t-muted':'#5d4f36','--t-text':'#3a3120','--t-bright':'#1a1408','--t-accent':'#b91c1c','--t-accent2':'#15803d','--t-danger':'#7c2d12','--t-scanline':'rgba(60,40,20,0.06)','--t-overlay':'rgba(60,50,30,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(214,196,160,0.22) 0%, rgba(214,196,160,0.7) 62%, rgba(214,196,160,0.86) 100%)' }},
|
||||||
stone: { label:'Stone', vars:{ '--t-bg':'#94a3b8','--t-surface':'#a8b6c8','--t-raised':'#bcc7d5','--t-border':'#64748b','--t-dim':'#475569','--t-muted':'#334155','--t-text':'#1e293b','--t-bright':'#0f172a','--t-accent':'#1d4ed8','--t-accent2':'#7e22ce','--t-danger':'#991b1b','--t-scanline':'rgba(15,23,42,0.06)','--t-overlay':'rgba(50,60,80,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(148,163,184,0.22) 0%, rgba(148,163,184,0.7) 62%, rgba(148,163,184,0.86) 100%)' }},
|
stone: { label:'Stone', vars:{ '--t-bg':'#94a3b8','--t-surface':'#a8b6c8','--t-raised':'#bcc7d5','--t-border':'#64748b','--t-dim':'#475569','--t-muted':'#334155','--t-text':'#1e293b','--t-bright':'#0f172a','--t-accent':'#1d4ed8','--t-accent2':'#7e22ce','--t-danger':'#991b1b','--t-scanline':'rgba(15,23,42,0.06)','--t-overlay':'rgba(50,60,80,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(148,163,184,0.22) 0%, rgba(148,163,184,0.7) 62%, rgba(148,163,184,0.86) 100%)' }},
|
||||||
|
apple: { label:'Apple Dark', vars:{ '--t-bg':'#000000','--t-surface':'#1c1c1e','--t-raised':'#2c2c2e','--t-border':'#3a3a3c','--t-dim':'#636366','--t-muted':'#8e8e93','--t-text':'#ebebf5','--t-bright':'#ffffff','--t-accent':'#0a84ff','--t-accent2':'#bf5af2','--t-danger':'#ff453a','--t-scanline':'rgba(255,255,255,0.02)','--t-overlay':'rgba(0,0,0,0.7)','--t-font-pixel':'-apple-system, "SF Pro Display", system-ui, sans-serif','--t-cover-gradient':'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.72) 62%, rgba(0,0,0,0.88) 100%)' }},
|
||||||
};
|
};
|
||||||
window.applyTheme = function(name) {
|
window.applyTheme = function(name) {
|
||||||
const t = themes[name] || themes.slate;
|
const t = themes[name] || themes.slate;
|
||||||
@@ -1240,6 +1531,13 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
padCard: 10, gapBoard: 17,
|
padCard: 10, gapBoard: 17,
|
||||||
shadowCard: 'none', shadowModal: 'none', shadowUi: 'none',
|
shadowCard: 'none', shadowModal: 'none', shadowUi: 'none',
|
||||||
liftCard: 2, blurOverlay: 5, animSpeed: 'normal' },
|
liftCard: 2, blurOverlay: 5, animSpeed: 'normal' },
|
||||||
|
'Apple Dark': { theme: 'apple',
|
||||||
|
fontHeadline: 'sans', fontContent: 'sans', fontUi: 'sans',
|
||||||
|
radiusCard: 12, radiusButton: 8, radiusInput: 8, radiusModal: 14, radiusTag: 6, radiusImage: 10,
|
||||||
|
borderCard: 1, borderStyle: 'solid', barHeight: 2,
|
||||||
|
padCard: 14, gapBoard: 18,
|
||||||
|
shadowCard: 'soft', shadowModal: 'medium', shadowUi: 'soft',
|
||||||
|
liftCard: 2, blurOverlay: 12, animSpeed: 'normal' },
|
||||||
};
|
};
|
||||||
window.applyDesign = function(d) {
|
window.applyDesign = function(d) {
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
@@ -1275,6 +1573,11 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
|
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
|
||||||
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
|
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
|
||||||
if (d.animSpeed === 'slow') document.body.classList.add('anim-slow');
|
if (d.animSpeed === 'slow') document.body.classList.add('anim-slow');
|
||||||
|
// Rounded-card badge variant: the default "done" corner triangle
|
||||||
|
// gets clipped by a rounded border, so when the user opts into
|
||||||
|
// rounded cards we swap it for a circle pill in the BR corner
|
||||||
|
// (see .rounded-cards .card-done-badge in style.css).
|
||||||
|
document.body.classList.toggle('rounded-cards', (Number(d.radiusCard) || 0) > 0);
|
||||||
};
|
};
|
||||||
window.loadDesign = function() {
|
window.loadDesign = function() {
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -1926,6 +2229,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
|
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
|
||||||
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
|
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
|
||||||
|
<div class="toggle-row" data-toggle="setting-open-switches-bucket"><span class="toggle-label">Switch to card's bucket on open (search / link)</span><div class="toggle-switch" id="setting-open-switches-bucket"></div></div>
|
||||||
|
<div class="toggle-row" data-toggle="setting-tasks-show-shopping"><span class="toggle-label">Show shopping list items in task collector</span><div class="toggle-switch" id="setting-tasks-show-shopping"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2045,7 +2350,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
<div class="overlay fixed inset-0 z-[70]" style="background:var(--t-overlay)" id="search">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex flex-col border-b border-t-border">
|
<div class="flex flex-col border-b border-t-border">
|
||||||
<div class="flex items-center gap-3 px-4 py-3">
|
<div class="flex items-center gap-3 px-4 py-3">
|
||||||
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
<i class="ph ph-magnifying-glass text-t-accent text-base"></i>
|
||||||
@@ -2098,7 +2403,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Tasks -->
|
<!-- Tasks -->
|
||||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="tasks">
|
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="tasks">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:54px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border">
|
||||||
<i class="ph ph-check-square-offset text-t-accent2 text-base"></i>
|
<i class="ph ph-check-square-offset text-t-accent2 text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent2">global_tasks</h2>
|
<h2 class="font-pixel text-xs text-t-accent2">global_tasks</h2>
|
||||||
@@ -2123,7 +2428,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Detail -->
|
<!-- Detail -->
|
||||||
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="detail">
|
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="detail">
|
||||||
<div class="bg-t-surface border border-t-border flex absolute" style="inset:40px;">
|
<div class="bg-t-surface flex absolute" style="inset:0;">
|
||||||
<!-- Group sidebar (hidden for single cards) -->
|
<!-- Group sidebar (hidden for single cards) -->
|
||||||
<div id="detail-sidebar" class="hidden w-56 shrink-0 border-r border-t-border flex flex-col">
|
<div id="detail-sidebar" class="hidden w-56 shrink-0 border-r border-t-border flex flex-col">
|
||||||
<div class="px-3 py-3 border-b border-t-border flex items-center gap-2">
|
<div class="px-3 py-3 border-b border-t-border flex items-center gap-2">
|
||||||
@@ -2149,6 +2454,9 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
|
<button id="detail-zen" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Zen mode\nFull-screen distraction-free reading.\nDrag edges to resize.">
|
||||||
<i class="ph ph-corners-out text-sm"></i>
|
<i class="ph ph-corners-out text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="detail-read-toggle" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Read later\nPush this card to your relay account so you can read it in the distraction-free mobile reader.">
|
||||||
|
<i id="detail-read-icon" class="ph ph-book-open text-sm"></i><span id="detail-read-label" class="font-pixel text-[10px]">Read</span>
|
||||||
|
</button>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
|
<button id="split-right-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-help="Split view\nShows cards sharing tags, links,\nor content with this card.">
|
||||||
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
|
<i class="ph ph-split-horizontal text-sm"></i><span class="font-pixel text-[10px]">Split</span>
|
||||||
@@ -2188,7 +2496,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Calendar -->
|
<!-- Calendar -->
|
||||||
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
|
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
|
||||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
|
<div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
|
||||||
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
|
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
|
||||||
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
||||||
@@ -2211,7 +2519,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Assets (was Gallery) -->
|
<!-- Assets (was Gallery) -->
|
||||||
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
<div class="overlay fixed inset-0 z-[72]" style="background:var(--t-overlay)" id="gallery">
|
||||||
<div class="bg-t-surface border border-t-border rounded-sm shadow-2xl flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-t-border shrink-0">
|
||||||
<i class="ph ph-folder-open text-t-accent text-base"></i>
|
<i class="ph ph-folder-open text-t-accent text-base"></i>
|
||||||
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
|
<h2 class="font-pixel text-xs text-t-accent">assets</h2>
|
||||||
@@ -2284,7 +2592,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
|
|
||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
|
<div class="overlay fixed inset-0 z-[60]" style="background:var(--t-overlay)" id="modal">
|
||||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
<div class="card-bar w-full shrink-0" id="editor-color"></div>
|
<div class="card-bar w-full shrink-0" id="editor-color"></div>
|
||||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-t-border shrink-0">
|
||||||
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
|
<div class="flex items-center gap-3"><i class="ph ph-file-plus text-t-accent text-sm"></i><h2 id="modal-title" class="font-pixel text-xs text-t-accent">new_card</h2></div>
|
||||||
|
|||||||
+39
-18
@@ -108,11 +108,14 @@ async function loadCards() {
|
|||||||
updateSortUI();
|
updateSortUI();
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
// Phase 2: Load full cards in batches → replace spinners with content
|
// Phase 2: Load full card bodies. One fetch up to the ID cap — with
|
||||||
const batchSize = 25;
|
// the /api/cards(?ids=) endpoint now indexed on cards.position +
|
||||||
for (let i = 0; i < meta.length; i += batchSize) {
|
// created_at a single big batch is faster than many small ones, and
|
||||||
const batch = meta.slice(i, i + batchSize);
|
// shorter perceived spinner time matters more than memory. Fall back
|
||||||
const ids = batch.map(c => c.id).join(',');
|
// to smaller batches only if the URL would blow past ~2000 chars.
|
||||||
|
const IDS_MAX_PER_FETCH = 500;
|
||||||
|
for (let i = 0; i < meta.length; i += IDS_MAX_PER_FETCH) {
|
||||||
|
const ids = meta.slice(i, i + IDS_MAX_PER_FETCH).map(c => c.id).join(',');
|
||||||
const full = await api('api/cards?ids=' + ids);
|
const full = await api('api/cards?ids=' + ids);
|
||||||
if (!full) continue;
|
if (!full) continue;
|
||||||
for (const card of full) {
|
for (const card of full) {
|
||||||
@@ -180,6 +183,32 @@ document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
|||||||
location.reload();
|
location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-usable login prompt loop. Shared between the boot-time auth check
|
||||||
|
// and the 401-driven re-auth path (session expiry while the UI is open).
|
||||||
|
// Concurrent callers collapse onto a single dialog via reauthPromise so a
|
||||||
|
// wave of background polls doesn't stack up N prompts.
|
||||||
|
let reauthPromise = null;
|
||||||
|
async function promptLogin(message = '') {
|
||||||
|
if (reauthPromise) return reauthPromise;
|
||||||
|
reauthPromise = (async () => {
|
||||||
|
while (true) {
|
||||||
|
const pw = await showDialog({ title: 'login', message, input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
|
||||||
|
if (!pw) continue;
|
||||||
|
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// wrong password — loop and re-prompt
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
try { await reauthPromise; } finally { reauthPromise = null; }
|
||||||
|
}
|
||||||
|
// Expose for the api() helpers so a 401 from any fetch triggers the same
|
||||||
|
// dialog + CSRF refresh flow.
|
||||||
|
window._reauth = () => promptLogin('Session expired — sign in to continue.');
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const res = await fetch('api/auth/status');
|
const res = await fetch('api/auth/status');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -194,24 +223,16 @@ async function checkAuth() {
|
|||||||
|
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
let pw = null;
|
let pw = null;
|
||||||
while (!pw || pw.length < 4) {
|
while (!pw || pw.length < 10) {
|
||||||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
|
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 10 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||||||
if (pw && pw.length < 4) pw = null;
|
if (pw && pw.length < 10) pw = null;
|
||||||
}
|
}
|
||||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': data.csrf || '' }, body: JSON.stringify({ password: pw }) });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.error) { location.reload(); return; }
|
if (d.error) { location.reload(); return; }
|
||||||
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
} else if (!data.logged_in) {
|
} else if (!data.logged_in) {
|
||||||
let ok = false;
|
await promptLogin();
|
||||||
while (!ok) {
|
|
||||||
const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' });
|
|
||||||
if (!pw) continue;
|
|
||||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } }
|
|
||||||
else { /* failed login: caller will retry */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBuckets();
|
loadBuckets();
|
||||||
|
|||||||
+19
-8
@@ -127,15 +127,26 @@
|
|||||||
csrf() { return csrfToken; },
|
csrf() { return csrfToken; },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Generic JSON API call with automatic CSRF header. */
|
/** Generic JSON API call with automatic CSRF header.
|
||||||
|
* On 401 (session expired while the UI is open) we call the app's
|
||||||
|
* re-auth prompt once, pick up the refreshed CSRF token, and retry
|
||||||
|
* the original request so the caller's save / poll succeeds instead
|
||||||
|
* of silently returning an unauthorized error payload. */
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const doFetch = () => {
|
||||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
const res = await fetch(path, {
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
method: opts.method || 'GET',
|
return fetch(path, {
|
||||||
headers,
|
method: opts.method || 'GET',
|
||||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
headers,
|
||||||
});
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch();
|
||||||
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
||||||
|
await window._reauth();
|
||||||
|
res = await doFetch();
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ export function animateCardRemoval(ids) {
|
|||||||
return new Promise(r => setTimeout(r, 260));
|
return new Promise(r => setTimeout(r, 260));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ghost-and-spinner overlay for cards that are being moved between
|
||||||
|
* buckets. The PATCH + reloadAll round-trip used to leave the original
|
||||||
|
* tile sitting stale in the current bucket for a couple hundred ms;
|
||||||
|
* marking them gives immediate feedback. The class comes off naturally
|
||||||
|
* on the next renderBoard() (the DOM is rebuilt from fresh state). */
|
||||||
|
export function markCardsMoving(ids) {
|
||||||
|
if (!ids || !ids.length) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
const el = board.querySelector(`[data-id="${id}"]`);
|
||||||
|
if (el) el.classList.add('card-moving');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._markCardsMoving = markCardsMoving;
|
||||||
|
|
||||||
/** Add a pulse class to matching board cards and strip it after the
|
/** Add a pulse class to matching board cards and strip it after the
|
||||||
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
||||||
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
||||||
@@ -486,14 +500,37 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||||
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
|
||||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||||
|
${Number(c.read_push) ? '<span class="flex items-center gap-1 text-t-accent" title="On your relay\'s Read list"><i class="ph ph-book-open text-xs"></i></span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
//
|
||||||
|
// Public renderBoard() is coalesced via requestAnimationFrame: multiple
|
||||||
|
// callers in the same tick (cache paint → meta paint → body paint →
|
||||||
|
// plugin-hook re-render → ...) collapse into a single actual render on
|
||||||
|
// the next frame. Dropped the "three innerHTML swaps per reload" cost
|
||||||
|
// that showed up as the "Gekringel" during boot. Callers that need the
|
||||||
|
// DOM in a specific state before doing something else can call
|
||||||
|
// renderBoardSync() explicitly — but in practice every existing caller
|
||||||
|
// just wants the board to reflect the latest state.
|
||||||
|
|
||||||
|
let rafScheduled = 0;
|
||||||
export function renderBoard() {
|
export function renderBoard() {
|
||||||
|
if (rafScheduled) return;
|
||||||
|
rafScheduled = requestAnimationFrame(() => {
|
||||||
|
rafScheduled = 0;
|
||||||
|
renderBoardSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function renderBoardSync() {
|
||||||
|
if (rafScheduled) { cancelAnimationFrame(rafScheduled); rafScheduled = 0; }
|
||||||
|
renderBoardImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoardImpl() {
|
||||||
// Diff state.cards against the last seen ids so we can flag arrivals
|
// Diff state.cards against the last seen ids so we can flag arrivals
|
||||||
// as "unseen". The very first renderBoard runs with state.cards = []
|
// as "unseen". The very first renderBoard runs with state.cards = []
|
||||||
// (initial empty state, before the API has returned), so we wait
|
// (initial empty state, before the API has returned), so we wait
|
||||||
|
|||||||
@@ -627,6 +627,7 @@ function renderBar() {
|
|||||||
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
|
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
|
||||||
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
|
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
|
||||||
if (bucketId === undefined) return;
|
if (bucketId === undefined) return;
|
||||||
|
window._markCardsMoving?.(ids);
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
|
||||||
window._clearSelection?.();
|
window._clearSelection?.();
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
@@ -672,6 +673,7 @@ function renderBar() {
|
|||||||
const selected = window._getSelectedIds?.() || [];
|
const selected = window._getSelectedIds?.() || [];
|
||||||
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
|
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
|
||||||
const card = state.cards.find(c => String(c.id) === String(cardId));
|
const card = state.cards.find(c => String(c.id) === String(cardId));
|
||||||
|
window._markCardsMoving?.(ids);
|
||||||
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
||||||
if (!created?.id) return;
|
if (!created?.id) return;
|
||||||
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
|
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
|
||||||
|
|||||||
@@ -416,7 +416,13 @@ export function showContextMenu(x, y, card) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
|
||||||
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); }));
|
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation(); hideContextMenu();
|
||||||
|
pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id });
|
||||||
|
window._markCardsMoving?.([card.id]);
|
||||||
|
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}));
|
||||||
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
||||||
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, m
|
|||||||
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
import { renderMd, stripMd, firstImage, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
|
||||||
import { showDialog } from './dialog.js';
|
import { showDialog } from './dialog.js';
|
||||||
|
import { activateBucket } from './buckets.js';
|
||||||
|
|
||||||
const detail = $('detail');
|
const detail = $('detail');
|
||||||
const scroll = $('detail-scroll');
|
const scroll = $('detail-scroll');
|
||||||
@@ -57,6 +58,18 @@ export function openDetail(id, options = {}) {
|
|||||||
if (!card) return;
|
if (!card) return;
|
||||||
markRecent(id);
|
markRecent(id);
|
||||||
|
|
||||||
|
// Optional: switch the board to the card's bucket so the user
|
||||||
|
// sees context. Only when opened via search, wikilink, or
|
||||||
|
// programmatic call — not when the user clicked a board card
|
||||||
|
// (they're already in the right bucket). Gated by a setting
|
||||||
|
// under Behavior → Interactions.
|
||||||
|
if (card.bucket_id
|
||||||
|
&& localStorage.getItem('hyprcrd-open-switches-bucket') === '1'
|
||||||
|
&& state.activeBucket !== 'all'
|
||||||
|
&& String(state.activeBucket) !== String(card.bucket_id)) {
|
||||||
|
activateBucket(card.bucket_id);
|
||||||
|
}
|
||||||
|
|
||||||
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
const el = $('board')?.querySelector(`[data-id="${id}"]`);
|
||||||
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
if (el) el.style.borderBottom = `3px solid ${card.color}`;
|
||||||
|
|
||||||
@@ -557,10 +570,50 @@ function updateDoneButton(card) {
|
|||||||
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
if (label) label.textContent = isDone ? 'Undone' : 'Done';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateReadButton(card) {
|
||||||
|
const isOn = !!Number(card?.read_push || 0);
|
||||||
|
const btn = $('detail-read-toggle');
|
||||||
|
const icon = $('detail-read-icon');
|
||||||
|
const label = $('detail-read-label');
|
||||||
|
if (!btn) return;
|
||||||
|
// Off state reuses the neutral control-bar style; on state inverts
|
||||||
|
// (accent-filled tile with dark text) so "this card sits on the relay
|
||||||
|
// right now" is visually unmistakable, matching typical toggle UIs.
|
||||||
|
btn.classList.toggle('bg-t-accent', isOn);
|
||||||
|
btn.classList.toggle('border-t-accent', isOn);
|
||||||
|
btn.classList.toggle('text-t-bg', isOn);
|
||||||
|
btn.classList.toggle('text-t-muted', !isOn);
|
||||||
|
btn.classList.toggle('hover:border-t-accent', !isOn);
|
||||||
|
btn.classList.toggle('hover:text-t-accent', !isOn);
|
||||||
|
if (icon) icon.className = (isOn ? 'ph-fill ph-book-open' : 'ph ph-book-open') + ' text-sm';
|
||||||
|
if (label) label.textContent = isOn ? 'On relay' : 'Read';
|
||||||
|
btn.title = isOn
|
||||||
|
? 'On relay — click to remove from your Read list'
|
||||||
|
: 'Push this card to your relay\'s Read list for distraction-free mobile reading';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('detail-read-toggle')?.addEventListener('click', async () => {
|
||||||
|
const card = state.viewingCard;
|
||||||
|
if (!card) return;
|
||||||
|
const next = Number(card.read_push || 0) ? 0 : 1;
|
||||||
|
try {
|
||||||
|
const updated = await api(`api/cards/${card.id}`, { method: 'PATCH', body: { read_push: next } });
|
||||||
|
if (updated && !updated.error) {
|
||||||
|
card.read_push = updated.read_push;
|
||||||
|
card.relay_uid = updated.relay_uid;
|
||||||
|
const c = state.cards.find(x => x.id == card.id);
|
||||||
|
if (c) { c.read_push = updated.read_push; c.relay_uid = updated.relay_uid; }
|
||||||
|
updateReadButton(card);
|
||||||
|
window._renderBoard?.();
|
||||||
|
}
|
||||||
|
} catch (_) { /* network error: silently ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
function showContent(card) {
|
function showContent(card) {
|
||||||
$('detail-color').style.background = card.color;
|
$('detail-color').style.background = card.color;
|
||||||
titleEl.textContent = card.title;
|
titleEl.textContent = card.title;
|
||||||
updateDoneButton(card);
|
updateDoneButton(card);
|
||||||
|
updateReadButton(card);
|
||||||
const doneCorner = $('detail-done-corner');
|
const doneCorner = $('detail-done-corner');
|
||||||
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
if (doneCorner) doneCorner.classList.toggle('hidden', !(card.tags || '').split(',').includes('done'));
|
||||||
bodyEl.innerHTML = renderMd(card.body);
|
bodyEl.innerHTML = renderMd(card.body);
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export function openModal(card = null) {
|
|||||||
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
|
// `card` can be `null` (add-button new) or a shell `{id: null, ...}`
|
||||||
// from the calendar day-popup — both count as "new", so key on
|
// from the calendar day-popup — both count as "new", so key on
|
||||||
// card?.id not the card ref itself.
|
// card?.id not the card ref itself.
|
||||||
const isNewAppointment = !card?.id && /#(termin|geburtstag)\b/.test(body.value);
|
const isNewAppointment = !card?.id && /#(termin|appointment|geburtstag|birthday)\b/.test(body.value);
|
||||||
inpBucket.value = card?.bucket_id ? card.bucket_id
|
inpBucket.value = card?.bucket_id ? card.bucket_id
|
||||||
: (isNewAppointment ? ''
|
: (isNewAppointment ? ''
|
||||||
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
|
: (!virtualBuckets.includes(state.activeBucket) ? state.activeBucket : ''));
|
||||||
@@ -512,7 +512,7 @@ async function doSave(close, { animate = true } = {}) {
|
|||||||
}
|
}
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
// Auto-sync if card has #termin or #geburtstag
|
// Auto-sync if card has #termin or #geburtstag
|
||||||
if (data.body && /#(termin|geburtstag)\b/.test(data.body)) {
|
if (data.body && /#(termin|appointment|geburtstag|birthday)\b/.test(data.body)) {
|
||||||
window._triggerSync?.();
|
window._triggerSync?.();
|
||||||
}
|
}
|
||||||
if (close) {
|
if (close) {
|
||||||
|
|||||||
+26
-11
@@ -59,14 +59,16 @@ export async function triggerSync() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const s = res.stats || {};
|
const s = res.stats || {};
|
||||||
const rp = relayRes?.pulled || 0;
|
const rp = relayRes?.pulled || 0;
|
||||||
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
const caldavStats = { pushed: s.pushed || 0, pulled: s.pulled || 0, deleted: s.deleted || 0 };
|
||||||
|
const relayStats = { pulled: rp };
|
||||||
const allLog = res.log || [];
|
const allLog = res.log || [];
|
||||||
const hasErrors = allLog.some(l => l.status === 'error');
|
const hasErrors = allLog.some(l => l.status === 'error');
|
||||||
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
|
const msg = `pushed:${caldavStats.pushed} pulled:${caldavStats.pulled + rp} del:${caldavStats.deleted}${rp ? ' relay:' + rp : ''}`;
|
||||||
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
||||||
await window._reloadAll?.();
|
await window._reloadAll?.();
|
||||||
showLog(allLog);
|
showLog(allLog);
|
||||||
showSyncNotification(total);
|
showSyncNotification('CalDAV', 'ph-cloud-arrow-down', caldavStats);
|
||||||
|
showSyncNotification('Relay', 'ph-broadcast', relayStats);
|
||||||
} else {
|
} else {
|
||||||
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
||||||
}
|
}
|
||||||
@@ -99,19 +101,29 @@ export function autoSync() {
|
|||||||
|
|
||||||
// ── Sync notifications ──────────────────────────────────────────
|
// ── Sync notifications ──────────────────────────────────────────
|
||||||
|
|
||||||
function showSyncNotification(stats) {
|
/** Per-source sync toast. Caller picks the label ("CalDAV" vs "Relay")
|
||||||
|
* and icon so the user knows which pipeline just finished, instead of
|
||||||
|
* the old always-CalDAV banner that lumped relay traffic in silently. */
|
||||||
|
let syncNoteOffset = 0;
|
||||||
|
function showSyncNotification(label, icon, stats) {
|
||||||
const s = stats || {};
|
const s = stats || {};
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (s.pulled) parts.push(`${s.pulled} received`);
|
if (s.pulled) parts.push(`${s.pulled} received`);
|
||||||
if (s.pushed) parts.push(`${s.pushed} sent`);
|
if (s.pushed) parts.push(`${s.pushed} sent`);
|
||||||
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
||||||
if (!parts.length) return;
|
if (!parts.length) return;
|
||||||
|
|
||||||
|
const bottom = 60 + syncNoteOffset * 48;
|
||||||
|
syncNoteOffset++;
|
||||||
const note = document.createElement('div');
|
const note = document.createElement('div');
|
||||||
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;`;
|
||||||
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ${icon}"></i>${label}: ${parts.join(', ')}</div>`;
|
||||||
document.body.appendChild(note);
|
document.body.appendChild(note);
|
||||||
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
setTimeout(() => {
|
||||||
|
note.style.transition = 'opacity 0.3s';
|
||||||
|
note.style.opacity = '0';
|
||||||
|
setTimeout(() => { note.remove(); syncNoteOffset = Math.max(0, syncNoteOffset - 1); }, 350);
|
||||||
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One toast per incoming sync event: "alice synced: Card Title".
|
// One toast per incoming sync event: "alice synced: Card Title".
|
||||||
@@ -321,8 +333,11 @@ export async function loadRelaySettings() {
|
|||||||
if (urlInput) urlInput.value = s.url || '';
|
if (urlInput) urlInput.value = s.url || '';
|
||||||
if (userInput) userInput.value = s.username || '';
|
if (userInput) userInput.value = s.username || '';
|
||||||
if (keyInput) {
|
if (keyInput) {
|
||||||
keyInput.value = s.key || '';
|
// The server no longer echoes the stored secret. Leave the field
|
||||||
keyInput.placeholder = 'shared secret';
|
// blank; placeholder tells the user whether one is set. An empty
|
||||||
|
// save is treated as "field untouched" server-side.
|
||||||
|
keyInput.value = '';
|
||||||
|
keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret';
|
||||||
}
|
}
|
||||||
// Sync-interval slider lives in the Relay panel but drives every
|
// Sync-interval slider lives in the Relay panel but drives every
|
||||||
// poller. Snap points span 5s..1h; live label updates on drag,
|
// poller. Snap points span 5s..1h; live label updates on drag,
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ const summary = $('tasks-summary');
|
|||||||
const countBadge = $('tasks-open-count');
|
const countBadge = $('tasks-open-count');
|
||||||
|
|
||||||
function collectTasks() {
|
function collectTasks() {
|
||||||
|
const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
|
||||||
const all = [];
|
const all = [];
|
||||||
for (const card of state.cards) {
|
for (const card of state.cards) {
|
||||||
|
if (!showShopping) {
|
||||||
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
||||||
|
if (tags.includes('einkauf') || tags.includes('shopping')) continue;
|
||||||
|
}
|
||||||
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
||||||
let m, idx = 0;
|
let m, idx = 0;
|
||||||
while ((m = re.exec(card.body || '')) !== null) {
|
while ((m = re.exec(card.body || '')) !== null) {
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ $('settings-pw-save')?.addEventListener('click', async () => {
|
|||||||
|
|
||||||
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
if (newPw.length < 10) { if (status) { status.textContent = 'min 10 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
||||||
|
|
||||||
const res = await fetch('api/auth/password', {
|
const res = await fetch('api/auth/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -450,6 +450,8 @@ function initBehaviorSettings() {
|
|||||||
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
||||||
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
||||||
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
||||||
|
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
|
||||||
|
'setting-tasks-show-shopping': { key: 'hyprcrd-tasks-show-shopping', def: '0' },
|
||||||
};
|
};
|
||||||
for (const [id, cfg] of Object.entries(selects)) {
|
for (const [id, cfg] of Object.entries(selects)) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
|
|||||||
@@ -15,13 +15,23 @@ export function setCsrf(token) { csrfToken = token; }
|
|||||||
export function getCsrf() { return csrfToken; }
|
export function getCsrf() { return csrfToken; }
|
||||||
|
|
||||||
export async function api(path, opts = {}) {
|
export async function api(path, opts = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const doFetch = () => {
|
||||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
const res = await fetch(path, {
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
headers,
|
return fetch(path, {
|
||||||
...opts,
|
headers,
|
||||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
...opts,
|
||||||
});
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch();
|
||||||
|
// Session expired mid-use: prompt for re-login, then retry once. Avoid
|
||||||
|
// recursion on auth endpoints themselves — promptLogin handles its own
|
||||||
|
// fetches directly.
|
||||||
|
if (res.status === 401 && !path.startsWith('api/auth/') && typeof window._reauth === 'function') {
|
||||||
|
await window._reauth();
|
||||||
|
res = await doFetch();
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
echo json_encode(['error' => 'valid url required']);
|
echo json_encode(['error' => 'valid url required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
|
||||||
|
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'only http/https URLs allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!hyprcrdUrlHostIsPublic($url)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'URL host is not publicly reachable']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
@@ -30,6 +41,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
$html = curl_exec($ch);
|
$html = curl_exec($ch);
|
||||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
|
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
|
||||||
@@ -63,18 +75,21 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$faviconData = '';
|
$faviconData = '';
|
||||||
$ch2 = curl_init($faviconUrl);
|
$faviconScheme = strtolower((string)parse_url($faviconUrl, PHP_URL_SCHEME));
|
||||||
curl_setopt_array($ch2, [
|
$favCh = ($faviconScheme === 'http' || $faviconScheme === 'https') && hyprcrdUrlHostIsPublic($faviconUrl);
|
||||||
|
$ch2 = $favCh ? curl_init($faviconUrl) : null;
|
||||||
|
if ($ch2) curl_setopt_array($ch2, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
CURLOPT_MAXREDIRS => 3,
|
CURLOPT_MAXREDIRS => 3,
|
||||||
CURLOPT_TIMEOUT => 5,
|
CURLOPT_TIMEOUT => 5,
|
||||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||||
]);
|
]);
|
||||||
$favBytes = curl_exec($ch2);
|
if ($ch2) hyprcrdApplySafeCurlOpts($ch2);
|
||||||
$favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
$favBytes = $ch2 ? curl_exec($ch2) : false;
|
||||||
$favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon';
|
$favStatus = $ch2 ? curl_getinfo($ch2, CURLINFO_HTTP_CODE) : 0;
|
||||||
curl_close($ch2);
|
$favMime = $ch2 ? (curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon') : 'image/x-icon';
|
||||||
|
if ($ch2) curl_close($ch2);
|
||||||
|
|
||||||
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
|
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
|
||||||
$mime = preg_replace('/;.*$/', '', $favMime);
|
$mime = preg_replace('/;.*$/', '', $favMime);
|
||||||
|
|||||||
@@ -53,11 +53,8 @@
|
|||||||
/* Overlay shell */
|
/* Overlay shell */
|
||||||
.bookmarks-shell {
|
.bookmarks-shell {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 40px;
|
inset: 0;
|
||||||
background: var(--t-surface);
|
background: var(--t-surface);
|
||||||
border: 1px solid var(--t-border);
|
|
||||||
border-radius: var(--ui-radius-modal, 4px);
|
|
||||||
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ async function save() {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { body },
|
body: { body },
|
||||||
});
|
});
|
||||||
|
// Keep the local state in sync so the next openOverlay (which
|
||||||
|
// reads from state.cards, not the server) sees the latest body.
|
||||||
|
const local = (window.state?.cards || []).find(c => c.id == activeCardId);
|
||||||
|
if (local) local.body = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Board card decoration ────────────────────────────────────────
|
// ── Board card decoration ────────────────────────────────────────
|
||||||
|
|||||||
@@ -108,24 +108,20 @@ function chatRelaySettings(): array {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */
|
/** Dual-format envelope shared with the relay sync path. Writes V2
|
||||||
|
* (AES-GCM + HKDF), reads V2 + legacy V1 (CBC). Delegates to the central
|
||||||
|
* helpers so the format stays in lock-step with src/bootstrap.php and
|
||||||
|
* the relay's crypto.php. */
|
||||||
function chatEncrypt(string $plain, string $relayKey): string {
|
function chatEncrypt(string $plain, string $relayKey): string {
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
return hyprcrdRelayEncrypt($plain, $relayKey);
|
||||||
$iv = random_bytes(16);
|
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatDecrypt(string $envelope, string $relayKey): string {
|
function chatDecrypt(string $envelope, string $relayKey): string {
|
||||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return '';
|
// Old behaviour returned '' for non-envelope strings; preserve that.
|
||||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||||
$raw = base64_decode($b64);
|
return '';
|
||||||
if (!$raw || strlen($raw) < 17) return '';
|
}
|
||||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
return hyprcrdRelayDecrypt($envelope, $relayKey);
|
||||||
$iv = substr($raw, 0, 16);
|
|
||||||
$cipher = substr($raw, 16);
|
|
||||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
return $result !== false ? $result : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatGetState(string $key, $default = null) {
|
function chatGetState(string $key, $default = null) {
|
||||||
|
|||||||
@@ -110,32 +110,24 @@
|
|||||||
");
|
");
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** Encrypt a JSON payload with the local relay_key using the same envelope
|
/** Encrypt a JSON payload with the local relay_key using the shared
|
||||||
* format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
|
* dual-format envelope (GCM-V2 writes, V1 read-only fallback lives in
|
||||||
|
* hyprcrdRelayDecrypt). */
|
||||||
function collabEncryptPayload(array $fields): ?string {
|
function collabEncryptPayload(array $fields): ?string {
|
||||||
$key = collabRelaySettings()['key'] ?? '';
|
$key = collabRelaySettings()['key'] ?? '';
|
||||||
if ($key === '') return null;
|
if ($key === '') return null;
|
||||||
$encKey = substr(hash('sha256', $key), 0, 32);
|
|
||||||
$plain = json_encode($fields);
|
$plain = json_encode($fields);
|
||||||
$iv = random_bytes(16);
|
if ($plain === false) return null;
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
return hyprcrdRelayEncrypt($plain, $key);
|
||||||
if ($cipher === false) return null;
|
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypt the inverse. Returns an associative array, or null on failure. */
|
/** Decrypt the inverse. Returns an associative array, or null on failure. */
|
||||||
function collabDecryptPayload(string $envelope): ?array {
|
function collabDecryptPayload(string $envelope): ?array {
|
||||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
|
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) return null;
|
||||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
|
||||||
$raw = base64_decode($b64);
|
|
||||||
if (!$raw || strlen($raw) < 17) return null;
|
|
||||||
$key = collabRelaySettings()['key'] ?? '';
|
$key = collabRelaySettings()['key'] ?? '';
|
||||||
if ($key === '') return null;
|
if ($key === '') return null;
|
||||||
$encKey = substr(hash('sha256', $key), 0, 32);
|
$plain = hyprcrdRelayDecrypt($envelope, $key);
|
||||||
$iv = substr($raw, 0, 16);
|
if ($plain === '') return null;
|
||||||
$cipher = substr($raw, 16);
|
|
||||||
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
|
||||||
if ($plain === false) return null;
|
|
||||||
$data = json_decode($plain, true);
|
$data = json_decode($plain, true);
|
||||||
return is_array($data) ? $data : null;
|
return is_array($data) ? $data : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Email Plugin: full-screen mail viewer overlay -->
|
<!-- Email Plugin: full-screen mail viewer overlay -->
|
||||||
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
||||||
<div class="email-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
<div class="email-modal bg-t-bg flex flex-col" style="position:absolute;inset:0;">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
<!-- Gitea Plugin: full-screen issue browser overlay -->
|
||||||
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
|
||||||
<div class="gitea-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
<div class="gitea-modal bg-t-bg flex flex-col" style="position:absolute;inset:0;">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ Close with **ESC** or the × button.
|
|||||||
|
|
||||||
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
|
Tab row at the top. Currently two mocks (`oumta-dev`, `oumta-beta`). Real list comes from the backend once `/api/namespaces` exists.
|
||||||
|
|
||||||
## Settings
|
## Configuration
|
||||||
|
|
||||||
Open the hyprcrd settings overlay and choose the **oumta** tab:
|
Hardwired at the top of `oumta-dashboard.js` in the `CONFIG` block:
|
||||||
|
|
||||||
- **API Base URL** — e.g. `http://localhost:8080` for local dev, or the cluster-internal URL
|
- **baseUrl** — where the Oumta Go backend is reachable (default `http://localhost:8085`).
|
||||||
- **Auth token** — optional, sent as `Authorization: Bearer …`. Will eventually be filled automatically once Keycloak is wired up.
|
- **authToken** — optional, sent as `Authorization: Bearer …` when non-empty.
|
||||||
- **Poll interval** — polling fallback cadence when SSE is unavailable. Default 15 s.
|
- **pollIntervalSec** — polling fallback cadence when SSE is unavailable. Default 15 s.
|
||||||
|
|
||||||
|
No UI settings panel: the dashboard only runs against one specific deployment, so exposing these as editable fields would be noise.
|
||||||
|
|
||||||
## State today
|
## State today
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
border-radius: var(--ui-radius-card);
|
border-radius: var(--ui-radius-card);
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
.oumta-card-wide { grid-column: span 4; }
|
.oumta-card-wide { grid-column: span 4; }
|
||||||
.oumta-card-label {
|
.oumta-card-label {
|
||||||
@@ -57,18 +60,36 @@
|
|||||||
}
|
}
|
||||||
.oumta-card-value {
|
.oumta-card-value {
|
||||||
font-family: var(--font-content, monospace);
|
font-family: var(--font-content, monospace);
|
||||||
font-size: 28px;
|
font-size: 56px;
|
||||||
color: var(--t-bright);
|
color: var(--t-bright);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
.oumta-card-value-sm { font-size: 14px; font-weight: normal; }
|
.oumta-card-value-sm { font-size: 18px; font-weight: normal; line-height: 1.3; }
|
||||||
.oumta-card-sub { font-size: 10px; color: var(--t-dim); }
|
.oumta-card-sub { font-size: 10px; color: var(--t-dim); }
|
||||||
.oumta-danger { color: var(--t-danger); }
|
.oumta-danger { color: var(--t-danger); }
|
||||||
|
|
||||||
|
/* Fail state: whole card goes red; label, value, sub all flip to white
|
||||||
|
* so the box reads as a single alarm tile rather than a coloured number
|
||||||
|
* on dark chrome. Triggered from JS via the oumta-fail class. Uses the
|
||||||
|
* theme's --t-danger to stay consistent with the "unavailable" state
|
||||||
|
* labels and workload state colours elsewhere in the dashboard. */
|
||||||
|
.oumta-card.oumta-fail {
|
||||||
|
background: var(--t-danger);
|
||||||
|
border-color: var(--t-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.oumta-card.oumta-fail .oumta-card-label,
|
||||||
|
.oumta-card.oumta-fail .oumta-card-value,
|
||||||
|
.oumta-card.oumta-fail .oumta-card-sub {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; }
|
.oumta-cluster-status[data-state="healthy"] .oumta-card-value { color: #4ade80; }
|
||||||
.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; }
|
.oumta-cluster-status[data-state="degraded"] .oumta-card-value { color: #fb923c; }
|
||||||
.oumta-cluster-status[data-state="down"] .oumta-card-value { color: var(--t-danger); }
|
|
||||||
.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); }
|
.oumta-cluster-status[data-state="unknown"] .oumta-card-value { color: var(--t-dim); }
|
||||||
|
/* data-state="down" is handled by .oumta-fail (toggled from JS) so the
|
||||||
|
* cluster tile goes fully red + white like the other alarms. */
|
||||||
|
|
||||||
/* ── Attention list ── */
|
/* ── Attention list ── */
|
||||||
.oumta-section { margin-top: 8px; }
|
.oumta-section { margin-top: 8px; }
|
||||||
@@ -176,30 +197,204 @@
|
|||||||
}
|
}
|
||||||
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
|
|
||||||
.oumta-timeline-body {
|
/* Workload filter pills: inline quick-pick. Horizontal-scrolls if the row
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
* runs out of space; an overflow dropdown is injected from JS when the
|
||||||
border-left: 2px solid var(--t-border);
|
* pill count would blow out past the container. */
|
||||||
padding-left: 16px;
|
.oumta-tl-filters {
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.oumta-timeline-entry {
|
.oumta-tl-pill {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 140px 80px 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid var(--t-border);
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
.oumta-timeline-time { font-family: var(--font-content, monospace); font-size: 11px; color: var(--t-dim); }
|
|
||||||
.oumta-timeline-severity {
|
|
||||||
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.oumta-timeline-severity[data-sev="info"] { color: var(--t-accent); }
|
.oumta-tl-pill:hover { border-color: var(--t-accent); color: var(--t-accent); }
|
||||||
.oumta-timeline-severity[data-sev="warning"] { color: #fb923c; }
|
.oumta-tl-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
|
||||||
.oumta-timeline-severity[data-sev="error"] { color: var(--t-danger); }
|
|
||||||
.oumta-timeline-message { font-size: 12px; color: var(--t-bright); }
|
.oumta-tl-more {
|
||||||
.oumta-timeline-meta { font-size: 10px; color: var(--t-dim); margin-top: 2px; }
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px); left: 0;
|
||||||
|
background: var(--t-surface);
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 200px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu button {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--t-text);
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-input);
|
||||||
|
}
|
||||||
|
.oumta-tl-more-menu button:hover { background: var(--t-bg); color: var(--t-bright); }
|
||||||
|
.oumta-tl-more-menu button.active { color: var(--t-accent); }
|
||||||
|
|
||||||
|
/* Timeline body: two-column layout (left = date badge + time + dot, right
|
||||||
|
* = event list). Clusters of events that fired within the same tolerance
|
||||||
|
* window get a single row with all sub-events listed in the right column.
|
||||||
|
*
|
||||||
|
* The decorative connector line is drawn ONCE on the body, not per entry —
|
||||||
|
* that's the only way to guarantee it's truly continuous (per-entry
|
||||||
|
* pseudo-elements collapse into gaps the moment an entry has a shorter
|
||||||
|
* left column than right). */
|
||||||
|
.oumta-timeline-body {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-timeline-body::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 79px; /* right edge of the 80px left column */
|
||||||
|
top: 24px; /* begin a bit below the first dot */
|
||||||
|
bottom: 24px; /* end a bit above the last dot */
|
||||||
|
width: 2px;
|
||||||
|
/* Soft fade at both ends so the line feels like it eases into the
|
||||||
|
* timeline rather than being clipped. The middle stays fully opaque. */
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
var(--t-border) 28px,
|
||||||
|
var(--t-border) calc(100% - 28px),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
grid-template-rows: auto auto; /* optional date row on top, dot-row + events below */
|
||||||
|
gap: 0 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.oumta-timeline-entry > * { position: relative; z-index: 1; }
|
||||||
|
/* Time + dot share a horizontal row so clusters without a date badge
|
||||||
|
* show the time label LEFT of the dot, not stacked underneath. The row
|
||||||
|
* lives in grid row 2 so events in the right column (also row 2) stay
|
||||||
|
* vertically aligned with it — regardless of whether a date badge
|
||||||
|
* occupies row 1 above. */
|
||||||
|
.oumta-tl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-self: end; /* hug the connector line on the right */
|
||||||
|
align-self: start; /* don't stretch to the events column's full height — stay at the top */
|
||||||
|
}
|
||||||
|
.oumta-tl-date {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
border-radius: var(--ui-radius-card);
|
||||||
|
padding: 4px 0 5px;
|
||||||
|
width: 44px;
|
||||||
|
background: var(--t-bg); /* solid, so the connector line behind doesn't strike through the text */
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: start; /* badge hugs the left edge of the column */
|
||||||
|
margin-bottom: 8px; /* spacing before the dot-row below */
|
||||||
|
}
|
||||||
|
.oumta-tl-date .day {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 18px; font-weight: 700;
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.oumta-tl-date .mon {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.oumta-tl-time {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.oumta-tl-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* Dot sits at the right end of the time+dot row, which align-items:
|
||||||
|
* flex-end of the left column pushes to the right edge (x=80).
|
||||||
|
* Translate by half the dot width so its centre lines up exactly with
|
||||||
|
* the connector line (also at x=80). */
|
||||||
|
transform: translateX(5px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent);
|
||||||
|
background: var(--oumta-dot-color);
|
||||||
|
}
|
||||||
|
.oumta-tl-dot[data-sev="info"] { --oumta-dot-color: var(--t-accent); }
|
||||||
|
.oumta-tl-dot[data-sev="warning"] { --oumta-dot-color: #fb923c; }
|
||||||
|
.oumta-tl-dot[data-sev="error"] { --oumta-dot-color: var(--t-danger); }
|
||||||
|
.oumta-tl-dot[data-sev="recovered"] { --oumta-dot-color: #4ade80; }
|
||||||
|
.oumta-tl-dot[data-sev="image"] { --oumta-dot-color: #60a5fa; }
|
||||||
|
|
||||||
|
.oumta-tl-right {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 23px;
|
||||||
|
min-width: 0;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2; /* pinned to the same row as .oumta-tl-row so events line up with the dot */
|
||||||
|
}
|
||||||
|
.oumta-tl-event {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.oumta-tl-event-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.oumta-tl-severity {
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.oumta-tl-severity[data-sev="info"] { color: var(--t-accent); }
|
||||||
|
.oumta-tl-severity[data-sev="warning"] { color: #fb923c; }
|
||||||
|
.oumta-tl-severity[data-sev="error"] { color: var(--t-danger); }
|
||||||
|
.oumta-tl-severity[data-sev="recovered"] { color: #4ade80; }
|
||||||
|
.oumta-tl-severity[data-sev="image"] { color: #60a5fa; }
|
||||||
|
.oumta-tl-workload {
|
||||||
|
font-family: var(--font-content, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
.oumta-tl-workload:hover { color: var(--t-accent); }
|
||||||
|
.oumta-tl-reason { font-size: 10px; color: var(--t-dim); }
|
||||||
|
.oumta-tl-message { font-size: 12px; color: var(--t-bright); line-height: 1.4; word-break: break-word; }
|
||||||
|
|
||||||
/* ── Live indicator ── */
|
/* ── Live indicator ── */
|
||||||
.oumta-dot {
|
.oumta-dot {
|
||||||
@@ -209,26 +404,5 @@
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
#oumta-live-indicator[data-state="live"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #fb923c; }
|
#oumta-live-indicator[data-state="polling"] .oumta-dot { background: #4ade80; box-shadow: 0 0 6px #4ade80; }
|
||||||
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
|
#oumta-live-indicator[data-state="error"] .oumta-dot { background: var(--t-danger); }
|
||||||
|
|
||||||
/* ── Settings tab ── */
|
|
||||||
.oumta-settings-field {
|
|
||||||
display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.oumta-settings-field label {
|
|
||||||
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
|
||||||
font-size: 9px; color: var(--t-dim); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.oumta-settings-field input {
|
|
||||||
background: var(--t-bg);
|
|
||||||
border: 1px solid var(--t-border);
|
|
||||||
color: var(--t-bright);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--ui-radius-input);
|
|
||||||
font-family: var(--font-content, monospace);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.oumta-settings-field input:focus { border-color: var(--t-accent); }
|
|
||||||
.oumta-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; }
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="oumta-overlay">
|
<div class="overlay fixed inset-0 z-[75]" style="background:var(--t-overlay)" id="oumta-overlay">
|
||||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface flex flex-col absolute" style="inset:0;">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="oumta-card">
|
<div class="oumta-card">
|
||||||
<div class="oumta-card-label">Workloads down</div>
|
<div class="oumta-card-label">Workloads down</div>
|
||||||
<div class="oumta-card-value oumta-danger" id="oumta-workloads-down">—</div>
|
<div class="oumta-card-value" id="oumta-workloads-down">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="oumta-card">
|
<div class="oumta-card">
|
||||||
<div class="oumta-card-label">Open incidents</div>
|
<div class="oumta-card-label">Open incidents</div>
|
||||||
@@ -107,6 +107,9 @@
|
|||||||
<button id="oumta-timeline-back" class="hidden oumta-btn-ghost"><i class="ph ph-caret-left text-sm"></i> back to all</button>
|
<button id="oumta-timeline-back" class="hidden oumta-btn-ghost"><i class="ph ph-caret-left text-sm"></i> back to all</button>
|
||||||
<span id="oumta-timeline-scope" class="font-pixel text-xs text-t-accent">all events</span>
|
<span id="oumta-timeline-scope" class="font-pixel text-xs text-t-accent">all events</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Workload filter: quick-pick pills by default, a dropdown
|
||||||
|
trigger appears only when the pills don't fit. -->
|
||||||
|
<div id="oumta-timeline-filters" class="oumta-tl-filters"></div>
|
||||||
<div id="oumta-timeline-body" class="oumta-timeline-body">
|
<div id="oumta-timeline-body" class="oumta-timeline-body">
|
||||||
<div class="oumta-empty">No events.</div>
|
<div class="oumta-empty">No events.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
// ── Oumta Dashboard plugin ──
|
// ── Oumta Dashboard plugin ──
|
||||||
//
|
//
|
||||||
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
// Fullscreen view that consumes the Oumta Go backend (`oumta-dashboard`).
|
||||||
// Currently ALL data is mocked so we can iterate on layout without waiting
|
// The browser talks to /api/dashboard/* same-origin; the hyprcrd PHP
|
||||||
// for the backend. See TODO.md for every call that must later become a real
|
// backend forwards to the real upstream (see OUMTA_DASHBOARD_URL in
|
||||||
// fetch against the backend.
|
// config.php). Keeps CORS out of the browser and lets the dashboard
|
||||||
|
// live on a private network reachable only by the hyprcrd container.
|
||||||
|
|
||||||
const h = window.hyprcrd;
|
const h = window.hyprcrd;
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
// ── Settings / persisted state ──
|
const CONFIG = {
|
||||||
//
|
proxyPrefix: '/api/dashboard',
|
||||||
// We use localStorage for now. Later we might persist via a hyprcrd setting
|
pollIntervalSec: 15,
|
||||||
// or a plugin-level table — see TODO.md.
|
};
|
||||||
|
|
||||||
const LS_KEY = 'oumta-dashboard-settings';
|
|
||||||
const state = {
|
const state = {
|
||||||
settings: {
|
|
||||||
baseUrl: '', // e.g. https://dashboard.oumta.internal
|
|
||||||
authToken: '', // Keycloak bearer; empty = no auth yet
|
|
||||||
pollIntervalSec: 15,
|
|
||||||
},
|
|
||||||
namespaces: [], // [{ id, label, namespace }]
|
namespaces: [], // [{ id, label, namespace }]
|
||||||
activeNamespace: null,
|
activeNamespace: null,
|
||||||
activeView: 'overview',
|
activeView: 'overview',
|
||||||
@@ -27,130 +22,26 @@ const state = {
|
|||||||
pollHandle: null,
|
pollHandle: null,
|
||||||
sseHandle: null,
|
sseHandle: null,
|
||||||
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
|
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
|
||||||
|
timelineFilter: new Set(), // empty = all events; else any-of workloadIds (multi-select pills)
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadSettings() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LS_KEY);
|
|
||||||
if (raw) Object.assign(state.settings, JSON.parse(raw));
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
function saveSettings() {
|
|
||||||
localStorage.setItem(LS_KEY, JSON.stringify(state.settings));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Central API surface ─────────────────────────────────────────
|
// ── Central API surface ─────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Every backend call routes through `api()`. Right now each case returns
|
// Every backend call routes through `api()` against the same-origin
|
||||||
// mock data. When the backend endpoints are ready, swap the `return MOCK…`
|
// proxy prefix; the active namespace (if any) is attached as a
|
||||||
// lines for real `fetch(…)` calls. The UI code above this layer should
|
// `namespace` query parameter. Auth / bearer tokens live server-side
|
||||||
// not need to change.
|
// in config.php, so nothing sensitive ships to the browser. Response
|
||||||
//
|
// shapes are defined by the Go backend in
|
||||||
// Field naming legend for the mocks below:
|
// `internal/httpapi/contracts.go`.
|
||||||
// [REAL] = already defined in Go code (internal/httpapi/router.go).
|
|
||||||
// Must match byte-for-byte when flipping to real fetch.
|
|
||||||
// [PROPOSED] = our guess based on docs/README.md prose. Backend author
|
|
||||||
// is free to rename — when they do, adjust the renderer
|
|
||||||
// (there's only one place per field) or add a small adapter
|
|
||||||
// inside api() that normalises the response.
|
|
||||||
|
|
||||||
const MOCK_NAMESPACES = [
|
|
||||||
{ id: 'oumta-dev', label: 'oumta-dev', namespace: 'oumta-dev' },
|
|
||||||
{ id: 'oumta-beta', label: 'oumta-beta', namespace: 'oumta-beta' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Overview mock — shape matches the existing Go struct `clusterOverviewResponse`
|
|
||||||
// byte-for-byte (see cmd/api/main.go → internal/httpapi/router.go).
|
|
||||||
// [REAL] clusterState, workloadsUp, workloadsDown, updatedAtUtc
|
|
||||||
// Anything else the overview page shows (incidents count, attention list) is
|
|
||||||
// DERIVED client-side from the workloads response — see renderOverview().
|
|
||||||
function mockOverview(ns) {
|
|
||||||
const wl = mockWorkloads(ns);
|
|
||||||
const up = wl.filter(w => w.state === 'healthy').length;
|
|
||||||
const down = wl.filter(w => w.state === 'unavailable').length;
|
|
||||||
const degraded = wl.filter(w => w.state === 'degraded').length;
|
|
||||||
const clusterState = down > 0 ? 'down' : (degraded > 0 ? 'degraded' : 'healthy');
|
|
||||||
return {
|
|
||||||
clusterState,
|
|
||||||
workloadsUp: up,
|
|
||||||
workloadsDown: down,
|
|
||||||
updatedAtUtc: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workloads mock — [PROPOSED] shape. Fields chosen from docs/README.md §2:
|
|
||||||
// Name, Namespace, Zustand, Replikas (ready/desired), CPU, Speicher,
|
|
||||||
// Image, Letzter Grund für Zustandswechsel.
|
|
||||||
// `workloadId` is our synthetic stable handle (kind/namespace/name) so the
|
|
||||||
// timeline can be linked back. If the backend emits a different handle
|
|
||||||
// format we'll adjust the link-builders.
|
|
||||||
function mockWorkloads(ns) {
|
|
||||||
const id = name => `deployment/${ns}/${name}`;
|
|
||||||
return [
|
|
||||||
{ workloadId: id('api'), name: 'api', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '420m', memory: '512Mi', image: 'oumta/api:1.4.2', lastReason: 'CrashLoopBackOff', lastTransitionAt: minutesAgo(12) },
|
|
||||||
{ workloadId: id('api-canary'), name: 'api-canary', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '140m', memory: '310Mi', image: 'oumta/api:1.4.3-rc2', lastReason: '—', lastTransitionAt: minutesAgo(180) },
|
|
||||||
{ workloadId: id('web'), name: 'web', namespace: ns, state: 'healthy', replicasReady: 2, replicasDesired: 2, cpu: '95m', memory: '256Mi', image: 'oumta/web:2.1.0', lastReason: '—', lastTransitionAt: minutesAgo(720) },
|
|
||||||
{ workloadId: id('worker'), name: 'worker', namespace: ns, state: 'healthy', replicasReady: 4, replicasDesired: 4, cpu: '820m', memory: '1Gi', image: 'oumta/worker:1.4.2', lastReason: '—', lastTransitionAt: minutesAgo(121) },
|
|
||||||
{ workloadId: id('ingest'), name: 'ingest', namespace: ns, state: 'unavailable', replicasReady: 0, replicasDesired: 1, cpu: '—', memory: '—', image: 'oumta/ingest:3.0.0', lastReason: 'ImagePullBackOff', lastTransitionAt: minutesAgo(3) },
|
|
||||||
{ workloadId: id('scheduler'), name: 'scheduler', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '55m', memory: '120Mi', image: 'oumta/scheduler:0.9.1', lastReason: '—', lastTransitionAt: minutesAgo(2880)},
|
|
||||||
{ workloadId: id('postgres'), name: 'postgres', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '180m', memory: '768Mi', image: 'postgres:16.2', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
|
|
||||||
{ workloadId: id('redis'), name: 'redis', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '25m', memory: '80Mi', image: 'redis:7.2-alpine', lastReason: '—', lastTransitionAt: minutesAgo(4320)},
|
|
||||||
{ workloadId: id('nats'), name: 'nats', namespace: ns, state: 'degraded', replicasReady: 2, replicasDesired: 3, cpu: '60m', memory: '200Mi', image: 'nats:2.10.14', lastReason: 'Readiness probe failed', lastTransitionAt: minutesAgo(45) },
|
|
||||||
{ workloadId: id('reports'), name: 'reports', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '75m', memory: '180Mi', image: 'oumta/reports:1.2.0', lastReason: '—', lastTransitionAt: minutesAgo(600) },
|
|
||||||
{ workloadId: id('notifier'), name: 'notifier', namespace: ns, state: 'healthy', replicasReady: 1, replicasDesired: 1, cpu: '30m', memory: '96Mi', image: 'oumta/notifier:0.5.3', lastReason: '—', lastTransitionAt: minutesAgo(1500)},
|
|
||||||
{ workloadId: id('migrations'), name: 'migrations', namespace: ns, state: 'healthy', replicasReady: 0, replicasDesired: 0, cpu: '—', memory: '—', image: 'oumta/migrations:1.4.2', lastReason: 'Completed', lastTransitionAt: minutesAgo(300) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeline mock — [PROPOSED] shape.
|
|
||||||
// Fields inferred from docs §3 (Detail page) & §4 (filters): timestamp,
|
|
||||||
// severity, workloadId, message, reason. `kind` is a derived hint for
|
|
||||||
// rendering (deployment vs. incident vs. scale etc.) and can be dropped
|
|
||||||
// if the backend doesn't provide it.
|
|
||||||
function mockTimeline(ns, workloadId = null) {
|
|
||||||
const id = name => `deployment/${ns}/${name}`;
|
|
||||||
const all = [
|
|
||||||
{ timestamp: minutesAgo(3), severity: 'error', workloadId: id('ingest'), message: 'Pod ingest-6d8bf4 entered ImagePullBackOff', reason: 'ImagePullBackOff', kind: 'incident' },
|
|
||||||
{ timestamp: minutesAgo(8), severity: 'warning', workloadId: id('nats'), message: 'Replica 3/3 readiness probe failed', reason: 'ProbeFailed', kind: 'incident' },
|
|
||||||
{ timestamp: minutesAgo(12), severity: 'warning', workloadId: id('api'), message: 'Pod api-7c9ff6 restarted (3rd time in 10m)', reason: 'CrashLoopBackOff', kind: 'incident' },
|
|
||||||
{ timestamp: minutesAgo(26), severity: 'info', workloadId: id('api'), message: 'HorizontalPodAutoscaler targeted 3 replicas', reason: 'HPA', kind: 'scale' },
|
|
||||||
{ timestamp: minutesAgo(48), severity: 'info', workloadId: id('web'), message: 'Rollout completed to revision 17', reason: 'DeploymentRolled', kind: 'deployment' },
|
|
||||||
{ timestamp: minutesAgo(58), severity: 'info', workloadId: id('web'), message: 'Rollout started: oumta/web:2.1.0', reason: 'DeploymentStarted', kind: 'deployment' },
|
|
||||||
{ timestamp: minutesAgo(121), severity: 'info', workloadId: id('worker'), message: 'Scaled up from 2 to 4 replicas', reason: 'HPA', kind: 'scale' },
|
|
||||||
{ timestamp: minutesAgo(180), severity: 'info', workloadId: id('api-canary'), message: 'Canary became ready', reason: 'CanaryReady', kind: 'deployment' },
|
|
||||||
{ timestamp: minutesAgo(240), severity: 'info', workloadId: id('api'), message: 'Rollout started: oumta/api:1.4.2', reason: 'DeploymentStarted', kind: 'deployment' },
|
|
||||||
{ timestamp: minutesAgo(300), severity: 'info', workloadId: id('migrations'), message: 'Job completed successfully in 1m42s', reason: 'JobSucceeded', kind: 'deployment' },
|
|
||||||
{ timestamp: minutesAgo(720), severity: 'info', workloadId: id('reports'), message: 'Rolled out revision 9', reason: 'DeploymentRolled', kind: 'deployment' },
|
|
||||||
{ timestamp: minutesAgo(1500), severity: 'warning', workloadId: id('ingest'), message: 'OOMKilled: container exceeded 256Mi limit', reason: 'OOMKilled', kind: 'incident' },
|
|
||||||
{ timestamp: minutesAgo(2880), severity: 'info', workloadId: id('scheduler'), message: 'Initial rollout: oumta/scheduler:0.9.1', reason: 'DeploymentStarted', kind: 'deployment' },
|
|
||||||
];
|
|
||||||
return workloadId ? all.filter(e => e.workloadId === workloadId) : all;
|
|
||||||
}
|
|
||||||
|
|
||||||
function minutesAgo(m) { return new Date(Date.now() - m * 60_000).toISOString(); }
|
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
// TODO(api): when backend is ready, build a real fetch here.
|
const url = new URL(CONFIG.proxyPrefix + path, window.location.origin);
|
||||||
// const url = new URL(path, state.settings.baseUrl);
|
if (!url.searchParams.has('namespace') && state.activeNamespace) {
|
||||||
// const headers = state.settings.authToken
|
url.searchParams.set('namespace', state.activeNamespace.namespace);
|
||||||
// ? { Authorization: 'Bearer ' + state.settings.authToken } : {};
|
|
||||||
// const res = await fetch(url, { ...opts, headers });
|
|
||||||
// if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
|
||||||
// return res.json();
|
|
||||||
|
|
||||||
// Mock dispatch — keep in sync with backend `docs/README.md`.
|
|
||||||
const ns = state.activeNamespace?.namespace || '';
|
|
||||||
if (path === '/api/namespaces') return { items: MOCK_NAMESPACES }; // TODO(api)
|
|
||||||
if (path === '/api/cluster/overview') return mockOverview(ns); // TODO(api): GET /api/cluster/overview?namespace=
|
|
||||||
if (path === '/api/workloads') return { items: mockWorkloads(ns) }; // TODO(api): GET /api/workloads?namespace=&state=&since=
|
|
||||||
if (path.startsWith('/api/workloads/') && path.endsWith('/timeline')) {
|
|
||||||
// /api/workloads/{namespace}/{name}/timeline
|
|
||||||
const parts = path.replace('/api/workloads/', '').replace('/timeline', '').split('/');
|
|
||||||
const workloadId = `deployment/${parts[0]}/${parts[1]}`;
|
|
||||||
return { items: mockTimeline(ns, workloadId) }; // TODO(api)
|
|
||||||
}
|
}
|
||||||
if (path === '/api/timeline') return { items: mockTimeline(ns) }; // TODO(api): GET /api/timeline?namespace=
|
const res = await fetch(url, opts);
|
||||||
throw new Error('unknown api path: ' + path);
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fullscreen overlay show/hide ─────────────────────────────────
|
// ── Fullscreen overlay show/hide ─────────────────────────────────
|
||||||
@@ -163,11 +54,12 @@ function isOpen() {
|
|||||||
function show() {
|
function show() {
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
if (!state.namespaces.length) bootstrap();
|
// Always kick off polling — refresh() self-heals: it loads namespaces
|
||||||
else {
|
// if they aren't there yet, and the interval keeps retrying even if
|
||||||
refresh();
|
// the initial poll fails. Previously an initial bootstrap failure
|
||||||
startLive();
|
// stopped the loop entirely and required a page reload to recover.
|
||||||
}
|
refresh();
|
||||||
|
startLive();
|
||||||
}
|
}
|
||||||
function hide() {
|
function hide() {
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
@@ -179,21 +71,6 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
window._oumtaToggle = toggle;
|
window._oumtaToggle = toggle;
|
||||||
|
|
||||||
// ── Bootstrap: load namespaces, then first refresh ─────────────
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
try {
|
|
||||||
const res = await api('/api/namespaces');
|
|
||||||
state.namespaces = res.items || [];
|
|
||||||
state.activeNamespace = state.namespaces[0] || null;
|
|
||||||
renderNamespaceTabs();
|
|
||||||
await refresh();
|
|
||||||
startLive();
|
|
||||||
} catch (e) {
|
|
||||||
setLive('error', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNamespaceTabs() {
|
function renderNamespaceTabs() {
|
||||||
const host = $('oumta-ns-tabs');
|
const host = $('oumta-ns-tabs');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
@@ -234,10 +111,27 @@ function switchView(view) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Refresh all data for the active namespace ──────────────────
|
// ── Refresh all data for the active namespace ──────────────────
|
||||||
|
//
|
||||||
|
// Self-healing: if namespaces aren't loaded yet (e.g. initial poll
|
||||||
|
// failed), fetch them first; if upstream is down, leave state as
|
||||||
|
// 'error' and let the next interval tick try again. On any successful
|
||||||
|
// poll we flip back to 'polling' so the indicator recovers without a
|
||||||
|
// page reload.
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!state.activeNamespace) return;
|
|
||||||
try {
|
try {
|
||||||
|
if (!state.activeNamespace) {
|
||||||
|
const res = await api('/api/namespaces');
|
||||||
|
state.namespaces = res.items || [];
|
||||||
|
state.activeNamespace = state.namespaces[0] || null;
|
||||||
|
renderNamespaceTabs();
|
||||||
|
}
|
||||||
|
if (!state.activeNamespace) {
|
||||||
|
// Namespaces endpoint returned empty — nothing to poll yet.
|
||||||
|
// Stay in 'polling' so we keep retrying on the interval.
|
||||||
|
setLive('polling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [ov, wl, tl] = await Promise.all([
|
const [ov, wl, tl] = await Promise.all([
|
||||||
api('/api/cluster/overview'),
|
api('/api/cluster/overview'),
|
||||||
api('/api/workloads'),
|
api('/api/workloads'),
|
||||||
@@ -247,6 +141,7 @@ async function refresh() {
|
|||||||
state.data.workloads = wl.items || [];
|
state.data.workloads = wl.items || [];
|
||||||
state.data.timeline = tl.items || [];
|
state.data.timeline = tl.items || [];
|
||||||
renderAll();
|
renderAll();
|
||||||
|
setLive('polling');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLive('error', e.message);
|
setLive('error', e.message);
|
||||||
}
|
}
|
||||||
@@ -269,19 +164,28 @@ function renderOverview() {
|
|||||||
if (!o) return;
|
if (!o) return;
|
||||||
|
|
||||||
// Values that come directly from the Go overview endpoint [REAL].
|
// Values that come directly from the Go overview endpoint [REAL].
|
||||||
$('oumta-cluster-card').dataset.state = o.clusterState || 'unknown';
|
const clusterCard = $('oumta-cluster-card');
|
||||||
$('oumta-cluster-state').textContent = o.clusterState || 'unknown';
|
const clusterState = o.clusterState || 'unknown';
|
||||||
|
clusterCard.dataset.state = clusterState;
|
||||||
|
clusterCard.classList.toggle('oumta-fail', clusterState === 'down');
|
||||||
|
$('oumta-cluster-state').textContent = clusterState;
|
||||||
$('oumta-cluster-updated').textContent = o.updatedAtUtc ? 'as of ' + formatTime(o.updatedAtUtc) : 'no data';
|
$('oumta-cluster-updated').textContent = o.updatedAtUtc ? 'as of ' + formatTime(o.updatedAtUtc) : 'no data';
|
||||||
$('oumta-workloads-up').textContent = o.workloadsUp ?? '—';
|
$('oumta-workloads-up').textContent = o.workloadsUp ?? '—';
|
||||||
$('oumta-workloads-down').textContent = o.workloadsDown ?? '—';
|
$('oumta-workloads-down').textContent = o.workloadsDown ?? '—';
|
||||||
$('oumta-last-import').textContent = o.updatedAtUtc ? formatTime(o.updatedAtUtc) : '—';
|
$('oumta-last-import').textContent = o.updatedAtUtc ? formatTime(o.updatedAtUtc) : '—';
|
||||||
|
|
||||||
|
// Any workload down → the "workloads down" tile becomes an alarm tile.
|
||||||
|
const downCard = $('oumta-workloads-down').closest('.oumta-card');
|
||||||
|
if (downCard) downCard.classList.toggle('oumta-fail', (o.workloadsDown ?? 0) > 0);
|
||||||
|
|
||||||
// Values derived client-side from the workloads list — the overview
|
// Values derived client-side from the workloads list — the overview
|
||||||
// endpoint doesn't carry these today. If the backend later adds
|
// endpoint doesn't carry these today. If the backend later adds
|
||||||
// `incidents` / `attention` fields, prefer those over the derivation.
|
// `incidents` / `attention` fields, prefer those over the derivation.
|
||||||
const attention = wl.filter(w => w.state !== 'healthy')
|
const attention = wl.filter(w => w.state !== 'healthy')
|
||||||
.sort((a, b) => rankState(a.state) - rankState(b.state));
|
.sort((a, b) => rankState(a.state) - rankState(b.state));
|
||||||
$('oumta-incidents').textContent = attention.length || '—';
|
$('oumta-incidents').textContent = attention.length || '—';
|
||||||
|
const incCard = $('oumta-incidents').closest('.oumta-card');
|
||||||
|
if (incCard) incCard.classList.toggle('oumta-fail', attention.length > 0);
|
||||||
|
|
||||||
const list = $('oumta-attention-list');
|
const list = $('oumta-attention-list');
|
||||||
if (!attention.length) {
|
if (!attention.length) {
|
||||||
@@ -336,6 +240,95 @@ function renderWorkloads() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Timeline helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const TL_CLUSTER_WINDOW_MS = 60_000; // events within 60s collapse into one entry
|
||||||
|
const TL_SEV_RANK = { error: 5, warning: 4, recovered: 3, image: 2, info: 1 };
|
||||||
|
const TL_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
||||||
|
|
||||||
|
/** Normalise an event's severity. Two post-hoc classifications matter:
|
||||||
|
* "-> healthy" transitions are recoveries, and ImageRolled reasons are
|
||||||
|
* real image pushes (vs. plain revision bumps which are just info). */
|
||||||
|
function eventSev(e) {
|
||||||
|
const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy');
|
||||||
|
const isImageRoll = e.reason === 'ImageRolled';
|
||||||
|
if (isRecovery) return 'recovered';
|
||||||
|
if (isImageRoll) return 'image';
|
||||||
|
return e.severity || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Workload short-name for the filter pill (strip namespace prefix). */
|
||||||
|
function workloadShort(id) {
|
||||||
|
if (!id) return '?';
|
||||||
|
const parts = id.split('/');
|
||||||
|
return parts[parts.length - 1] || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy clustering: walk events (assumed newest-first) and start a new
|
||||||
|
* cluster whenever the gap exceeds the tolerance window. */
|
||||||
|
function clusterEvents(events) {
|
||||||
|
const clusters = [];
|
||||||
|
let current = null;
|
||||||
|
for (const e of events) {
|
||||||
|
const ts = Date.parse(e.timestamp);
|
||||||
|
if (!current || isNaN(ts) || Math.abs(current.anchorTs - ts) > TL_CLUSTER_WINDOW_MS) {
|
||||||
|
current = { anchorTs: ts, ts: e.timestamp, events: [e] };
|
||||||
|
clusters.push(current);
|
||||||
|
} else {
|
||||||
|
current.events.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dominantSev(events) {
|
||||||
|
let best = null;
|
||||||
|
for (const e of events) {
|
||||||
|
const sev = eventSev(e);
|
||||||
|
if (!best || TL_SEV_RANK[sev] > TL_SEV_RANK[best]) best = sev;
|
||||||
|
}
|
||||||
|
return best || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan the cluster (events are newest-first) for the most recent state
|
||||||
|
* transition and map it to a dot severity. This reflects how the
|
||||||
|
* workload ended up after the cluster, not the worst thing that
|
||||||
|
* happened inside it — so a cluster that ends in "-> healthy" shows a
|
||||||
|
* green dot even if it contains degraded/warning events along the way.
|
||||||
|
* A cluster without any state transition (rollouts or image changes
|
||||||
|
* that didn't flip state) counts as "uneventful" → also green, since
|
||||||
|
* the absence of a transition means the workload stayed in whatever
|
||||||
|
* state it was in. */
|
||||||
|
function clusterOutcomeSev(events) {
|
||||||
|
for (const e of events) {
|
||||||
|
const m = /->\s*(\w+)\s*$/.exec(e.message || '');
|
||||||
|
if (!m) continue;
|
||||||
|
switch (m[1]) {
|
||||||
|
case 'healthy': return 'recovered';
|
||||||
|
case 'degraded': return 'warning';
|
||||||
|
case 'unavailable': return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'recovered';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(a, b) {
|
||||||
|
const da = new Date(a), db = new Date(b);
|
||||||
|
return da.getFullYear() === db.getFullYear()
|
||||||
|
&& da.getMonth() === db.getMonth()
|
||||||
|
&& da.getDate() === db.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeShort(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const now = new Date();
|
||||||
|
if (Math.abs(now - d) < 60_000) return 'just now';
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTimeline() {
|
function renderTimeline() {
|
||||||
const scope = state.data.scopeWorkload;
|
const scope = state.data.scopeWorkload;
|
||||||
const scopeEl = $('oumta-timeline-scope');
|
const scopeEl = $('oumta-timeline-scope');
|
||||||
@@ -344,27 +337,143 @@ function renderTimeline() {
|
|||||||
scopeEl.textContent = 'events for ' + scope;
|
scopeEl.textContent = 'events for ' + scope;
|
||||||
backEl.classList.remove('hidden');
|
backEl.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
scopeEl.textContent = 'all events';
|
const n = state.timelineFilter.size;
|
||||||
|
scopeEl.textContent = n === 0 ? 'all events' : `filtered · ${n}`;
|
||||||
backEl.classList.add('hidden');
|
backEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
const events = scope
|
const filter = state.timelineFilter;
|
||||||
? (state.data.timeline.filter(e => e.workloadId === scope))
|
const events = state.data.timeline.filter(e => {
|
||||||
: state.data.timeline;
|
if (scope) return e.workloadId === scope;
|
||||||
|
if (filter.size === 0) return true;
|
||||||
|
return filter.has(e.workloadId);
|
||||||
|
});
|
||||||
|
renderTimelineFilters();
|
||||||
|
|
||||||
const body = $('oumta-timeline-body');
|
const body = $('oumta-timeline-body');
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
body.innerHTML = '<div class="oumta-empty">No events.</div>';
|
body.innerHTML = '<div class="oumta-empty">No events.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = events.map(e => `
|
const clusters = clusterEvents(events);
|
||||||
<div class="oumta-timeline-entry">
|
|
||||||
<div class="oumta-timeline-time">${formatTime(e.timestamp)}</div>
|
let lastDate = null;
|
||||||
<div class="oumta-timeline-severity" data-sev="${e.severity}">${e.severity}</div>
|
body.innerHTML = clusters.map(cluster => {
|
||||||
<div>
|
const sev = clusterOutcomeSev(cluster.events) || dominantSev(cluster.events);
|
||||||
<div class="oumta-timeline-message">${escapeHtml(e.message)}</div>
|
const showDate = !lastDate || !sameDay(lastDate, cluster.ts);
|
||||||
<div class="oumta-timeline-meta">${escapeHtml(e.workloadId)} · ${escapeHtml(e.reason || '')}</div>
|
lastDate = cluster.ts;
|
||||||
</div>
|
const d = new Date(cluster.ts);
|
||||||
</div>
|
const badge = showDate
|
||||||
`).join('');
|
? `<div class="oumta-tl-date" title="${escapeAttr(d.toDateString())}">
|
||||||
|
<span class="day">${d.getDate()}</span>
|
||||||
|
<span class="mon">${TL_MONTHS[d.getMonth()]}</span>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
const left = `
|
||||||
|
${badge}
|
||||||
|
<div class="oumta-tl-row">
|
||||||
|
<span class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</span>
|
||||||
|
<span class="oumta-tl-dot" data-sev="${sev}"></span>
|
||||||
|
</div>`;
|
||||||
|
const right = `
|
||||||
|
<div class="oumta-tl-right">
|
||||||
|
${cluster.events.map(e => {
|
||||||
|
const esev = eventSev(e);
|
||||||
|
const wid = e.workloadId || '';
|
||||||
|
return `
|
||||||
|
<div class="oumta-tl-event">
|
||||||
|
<div class="oumta-tl-event-head">
|
||||||
|
<span class="oumta-tl-severity" data-sev="${esev}">${esev}</span>
|
||||||
|
<span class="oumta-tl-workload" data-wlid="${escapeAttr(wid)}" title="filter by this workload">${escapeHtml(workloadShort(wid))}</span>
|
||||||
|
<span class="oumta-tl-reason">${escapeHtml(e.reason || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="oumta-tl-message">${escapeHtml(e.message || '')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Tapping a workload name inside an event toggles it in the filter set.
|
||||||
|
body.querySelectorAll('.oumta-tl-workload').forEach(el => {
|
||||||
|
el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle a workload in the multi-select filter set. Passing an empty
|
||||||
|
* value clears the set (the "all" pill). */
|
||||||
|
function toggleTimelineFilter(workloadId) {
|
||||||
|
const set = state.timelineFilter;
|
||||||
|
if (!workloadId) { set.clear(); }
|
||||||
|
else if (set.has(workloadId)) { set.delete(workloadId); }
|
||||||
|
else { set.add(workloadId); }
|
||||||
|
renderTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the workload quick-pick bar. "all" + distinct workloads seen in
|
||||||
|
* the current timeline, capped at N inline pills — overflow collapses
|
||||||
|
* into a dropdown. Deduplicated by workloadId, ranked by most recent
|
||||||
|
* event so the pills you care about stay visible. */
|
||||||
|
function renderTimelineFilters() {
|
||||||
|
const host = $('oumta-timeline-filters');
|
||||||
|
if (!host) return;
|
||||||
|
// In workload-detail scope (opened via a card click) the filter bar is
|
||||||
|
// redundant — we're already showing one workload's events.
|
||||||
|
if (state.data.scopeWorkload) { host.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
const seen = new Map(); // workloadId -> earliest-seen index (= recency)
|
||||||
|
state.data.timeline.forEach((e, i) => {
|
||||||
|
if (e.workloadId && !seen.has(e.workloadId)) seen.set(e.workloadId, i);
|
||||||
|
});
|
||||||
|
const workloads = [...seen.keys()];
|
||||||
|
if (!workloads.length) { host.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
const INLINE_LIMIT = 6;
|
||||||
|
const inline = workloads.slice(0, INLINE_LIMIT);
|
||||||
|
const overflow = workloads.slice(INLINE_LIMIT);
|
||||||
|
const active = state.timelineFilter; // Set<workloadId>
|
||||||
|
|
||||||
|
const pill = (id, label, isActive) =>
|
||||||
|
`<button class="oumta-tl-pill${isActive ? ' active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
|
||||||
|
|
||||||
|
// "all" is active whenever the filter set is empty; picking it clears
|
||||||
|
// the set. The individual pills toggle their workload in the set so
|
||||||
|
// multiple filters can stack (any-of match).
|
||||||
|
const overflowActiveCount = overflow.reduce((n, id) => n + (active.has(id) ? 1 : 0), 0);
|
||||||
|
host.innerHTML = [
|
||||||
|
pill('', 'all', active.size === 0),
|
||||||
|
...inline.map(id => pill(id, workloadShort(id), active.has(id))),
|
||||||
|
overflow.length ? `
|
||||||
|
<div class="oumta-tl-more">
|
||||||
|
<button class="oumta-tl-pill${overflowActiveCount ? ' active' : ''}" data-tl-more="1">+${overflow.length} more${overflowActiveCount ? ` (${overflowActiveCount})` : ''}<i class="ph ph-caret-down" style="font-size:10px;margin-left:4px;"></i></button>
|
||||||
|
<div class="oumta-tl-more-menu hidden">
|
||||||
|
${overflow.map(id => `<button class="${active.has(id) ? 'active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(workloadShort(id))}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>` : '',
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
|
host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => toggleTimelineFilter(btn.dataset.wlid || ''));
|
||||||
|
});
|
||||||
|
const moreBtn = host.querySelector('[data-tl-more]');
|
||||||
|
const moreMenu = host.querySelector('.oumta-tl-more-menu');
|
||||||
|
if (moreBtn && moreMenu) {
|
||||||
|
moreBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
moreMenu.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
// Overflow picks don't close the menu — multi-select UX relies on
|
||||||
|
// being able to tick several without the menu collapsing every time.
|
||||||
|
moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleTimelineFilter(btn.dataset.wlid || '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!moreMenu.contains(e.target) && e.target !== moreBtn) moreMenu.classList.add('hidden');
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Workload → detail-as-timeline navigation ──────────────────
|
// ── Workload → detail-as-timeline navigation ──────────────────
|
||||||
@@ -389,19 +498,16 @@ $('oumta-timeline-back')?.addEventListener('click', () => {
|
|||||||
}).catch(e => setLive('error', e.message));
|
}).catch(e => setLive('error', e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Realtime (SSE) with polling fallback ──────────────────────
|
// ── Polling loop ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Plan: open an EventSource at <baseUrl>/api/events?namespace=… — backend
|
// SSE intentionally unsupported: the PHP proxy buffers whole responses,
|
||||||
// not implemented yet, so we go straight to polling. When SSE is available
|
// so streaming through it would pin an Apache worker per client. If we
|
||||||
// we can try to connect and fall back on `error`.
|
// ever want live events, bypass the proxy and talk to the Go backend's
|
||||||
|
// WebSocket / SSE endpoint directly — browsers don't enforce CORS on
|
||||||
|
// WS, and SSE would need a short-lived signed URL from the proxy.
|
||||||
|
|
||||||
function startLive() {
|
function startLive() {
|
||||||
stopLive();
|
stopLive();
|
||||||
// TODO(api): attempt SSE first:
|
|
||||||
// state.sseHandle = new EventSource(url);
|
|
||||||
// state.sseHandle.onmessage = ev => applyLiveEvent(JSON.parse(ev.data));
|
|
||||||
// state.sseHandle.onerror = () => { stopLive(); startPolling(); setLive('polling'); };
|
|
||||||
// state.sseHandle.onopen = () => setLive('live');
|
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
function stopLive() {
|
function stopLive() {
|
||||||
@@ -410,7 +516,7 @@ function stopLive() {
|
|||||||
setLive('off');
|
setLive('off');
|
||||||
}
|
}
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
const ms = (state.settings.pollIntervalSec || 15) * 1000;
|
const ms = (CONFIG.pollIntervalSec || 15) * 1000;
|
||||||
state.pollHandle = setInterval(refresh, ms);
|
state.pollHandle = setInterval(refresh, ms);
|
||||||
setLive('polling');
|
setLive('polling');
|
||||||
}
|
}
|
||||||
@@ -467,46 +573,6 @@ if (document.readyState === 'loading') {
|
|||||||
injectHeaderButton();
|
injectHeaderButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings tab in hyprcrd settings ─────────────────────────
|
|
||||||
|
|
||||||
function renderSettingsPanel(panelEl) {
|
|
||||||
panelEl.innerHTML = `
|
|
||||||
<div class="oumta-settings-field">
|
|
||||||
<label for="oumta-set-baseurl">API Base URL</label>
|
|
||||||
<input type="text" id="oumta-set-baseurl" placeholder="http://localhost:8080">
|
|
||||||
<div class="oumta-settings-help">Where the Oumta Go backend is reachable from this browser. No trailing slash.</div>
|
|
||||||
</div>
|
|
||||||
<div class="oumta-settings-field">
|
|
||||||
<label for="oumta-set-token">Auth token (optional)</label>
|
|
||||||
<input type="text" id="oumta-set-token" placeholder="Bearer token from Keycloak">
|
|
||||||
<div class="oumta-settings-help">Will be sent as <code>Authorization: Bearer <token></code>. Leave empty if the API is unauthenticated.</div>
|
|
||||||
</div>
|
|
||||||
<div class="oumta-settings-field">
|
|
||||||
<label for="oumta-set-poll">Poll interval (seconds)</label>
|
|
||||||
<input type="number" id="oumta-set-poll" min="5" max="300">
|
|
||||||
<div class="oumta-settings-help">Used as a fallback when SSE is unavailable.</div>
|
|
||||||
</div>
|
|
||||||
<button id="oumta-set-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors">
|
|
||||||
<i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span>
|
|
||||||
</button>
|
|
||||||
<span id="oumta-set-status" class="font-pixel text-[10px] text-t-dim ml-3"></span>
|
|
||||||
`;
|
|
||||||
$('oumta-set-baseurl').value = state.settings.baseUrl || '';
|
|
||||||
$('oumta-set-token').value = state.settings.authToken || '';
|
|
||||||
$('oumta-set-poll').value = state.settings.pollIntervalSec || 15;
|
|
||||||
$('oumta-set-save').addEventListener('click', () => {
|
|
||||||
state.settings.baseUrl = $('oumta-set-baseurl').value.trim();
|
|
||||||
state.settings.authToken = $('oumta-set-token').value.trim();
|
|
||||||
state.settings.pollIntervalSec = Math.max(5, parseInt($('oumta-set-poll').value) || 15);
|
|
||||||
saveSettings();
|
|
||||||
const s = $('oumta-set-status');
|
|
||||||
s.textContent = 'saved';
|
|
||||||
setTimeout(() => { s.textContent = ''; }, 1500);
|
|
||||||
// If the overlay is open, restart polling with new interval.
|
|
||||||
if (isOpen()) { stopLive(); startLive(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -522,11 +588,4 @@ function formatTime(iso) {
|
|||||||
|
|
||||||
// ── Boot ────────────────────────────────────────────────────
|
// ── Boot ────────────────────────────────────────────────────
|
||||||
|
|
||||||
loadSettings();
|
|
||||||
h?.settings?.registerTab?.({
|
|
||||||
id: 'oumta-dashboard',
|
|
||||||
label: 'oumta',
|
|
||||||
icon: 'ph-gauge',
|
|
||||||
onShow: renderSettingsPanel,
|
|
||||||
});
|
|
||||||
h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });
|
h?.registerPlugin?.({ name: 'oumta-dashboard', version: '0.1.0' });
|
||||||
@@ -241,7 +241,7 @@ function openDialog() {
|
|||||||
const prev = getActiveIds();
|
const prev = getActiveIds();
|
||||||
const next = [...selected];
|
const next = [...selected];
|
||||||
setActiveIds(next);
|
setActiveIds(next);
|
||||||
status.textContent = 'saving…';
|
status.innerHTML = '<i class="ph ph-spinner card-spinner" style="font-size:14px;vertical-align:middle;margin-right:4px;"></i> saving — please wait!';
|
||||||
try {
|
try {
|
||||||
// Remove deactivated lists from relay.
|
// Remove deactivated lists from relay.
|
||||||
for (const id of prev) {
|
for (const id of prev) {
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<?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') {
|
||||||
|
// Mirrors /api/relay/poll: an unconfigured support relay is a normal
|
||||||
|
// resting state, not an error. Returning 502 here floods the network
|
||||||
|
// tab on every background poll for users who don't use the plugin.
|
||||||
|
if (supportRelayUrl() === '' || supportRelayKey() === '') {
|
||||||
|
supportJson(['ok' => true, 'pulled' => 0]);
|
||||||
|
}
|
||||||
|
$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,289 @@
|
|||||||
|
/* 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 {
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
/* Hero empty-state ("pick a ticket on the left") matches the board's
|
||||||
|
* "no cards found" placeholder: centred in the available column, pixel
|
||||||
|
* font with blink cursor, 4xl dim icon above. flex:1 consumes the
|
||||||
|
* detail column so the axis centring actually has a box to work in. */
|
||||||
|
.support-empty-hero {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.support-empty-hero i {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.25rem; /* matches text-4xl */
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-bottom: 1rem; /* matches mb-4 */
|
||||||
|
}
|
||||||
|
.support-empty-hero p {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive));
|
||||||
|
font-size: 0.875rem; /* text-sm */
|
||||||
|
color: var(--t-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 class="cursor-blink">select a ticket</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 class="cursor-blink">select a ticket</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" id="support-status-closed" title="Close without replying"><i class="ph ph-archive"></i> 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' });
|
||||||
@@ -78,14 +78,17 @@ describe('Search: Find/Replace', () => {
|
|||||||
const okBtn = document.getElementById('dialog-ok');
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
if (okBtn) click(okBtn);
|
if (okBtn) click(okBtn);
|
||||||
|
|
||||||
// Poll until the replace has actually persisted
|
// Poll until both replaces have persisted. Budget is generous
|
||||||
|
// (~10s) because a cold PHP run plus two sequential PATCH calls
|
||||||
|
// can easily blow past the original 4-second window — the retry
|
||||||
|
// always passes because opcache + SQLite WAL are warm by then.
|
||||||
let updated1, updated2;
|
let updated1, updated2;
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
updated1 = await getCard(c1.id);
|
updated1 = await getCard(c1.id);
|
||||||
if (updated1?.body === 'goodbye world goodbye') break;
|
updated2 = await getCard(c2.id);
|
||||||
|
if (updated1?.body === 'goodbye world goodbye' && updated2?.body === 'goodbye there') break;
|
||||||
}
|
}
|
||||||
updated2 = await getCard(c2.id);
|
|
||||||
expect(updated1.body).toBe('goodbye world goodbye');
|
expect(updated1.body).toBe('goodbye world goodbye');
|
||||||
expect(updated2.body).toBe('goodbye there');
|
expect(updated2.body).toBe('goodbye there');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,6 +130,55 @@ describe('Bookmarks: card links', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Bookmarks: column persistence', () => {
|
||||||
|
it('new columns survive close + reopen', async () => {
|
||||||
|
let card;
|
||||||
|
try {
|
||||||
|
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_colsave__', body: emptyBody() }) }).then(r => r.json());
|
||||||
|
await window._reloadAll();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// Open overlay, add a column via the data model + save
|
||||||
|
// (simulates what addColumn does without the dialog).
|
||||||
|
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||||
|
click(el);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
// Grab the plugin's internal data via a fresh body parse,
|
||||||
|
// add a column, and save through the overlay's save path.
|
||||||
|
const full = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||||
|
const body = (full[0]?.body || '').trim();
|
||||||
|
const jsonEnd = body.lastIndexOf('}');
|
||||||
|
const parsed = JSON.parse(body.substring(0, jsonEnd + 1));
|
||||||
|
parsed.columns.push({ name: 'Added', items: [] });
|
||||||
|
const newBody = JSON.stringify(parsed) + '\n\n#bookmarks';
|
||||||
|
await fetch(`api/cards/${card.id}`, {
|
||||||
|
method: 'PATCH', headers: hJson(),
|
||||||
|
body: JSON.stringify({ body: newBody }),
|
||||||
|
});
|
||||||
|
// Update local state like save() does.
|
||||||
|
const local = (window.state?.cards || []).find(c => c.id == card.id);
|
||||||
|
if (local) local.body = newBody;
|
||||||
|
|
||||||
|
click($('bookmarks-close'));
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Reopen — the new column must still be there.
|
||||||
|
const el2 = document.querySelector(`.board-card[data-id="${card.id}"]`);
|
||||||
|
click(el2);
|
||||||
|
await sleep(150);
|
||||||
|
const names = [...document.querySelectorAll('.bm-col-name')].map(n => n.textContent);
|
||||||
|
expect(names).toContain('General');
|
||||||
|
expect(names).toContain('Added');
|
||||||
|
click($('bookmarks-close'));
|
||||||
|
await sleep(80);
|
||||||
|
} finally {
|
||||||
|
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Bookmarks: column width persistence', () => {
|
describe('Bookmarks: column width persistence', () => {
|
||||||
it('saves and restores columnWidth from card body', async () => {
|
it('saves and restores columnWidth from card body', async () => {
|
||||||
const cols = [{ name: 'W', items: [] }];
|
const cols = [{ name: 'W', items: [] }];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll } from './runner.js?v=4';
|
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
describe('Detail View', () => {
|
describe('Detail View', () => {
|
||||||
afterEach(() => closeAll());
|
afterEach(() => closeAll());
|
||||||
@@ -71,3 +71,90 @@ describe('Detail View', () => {
|
|||||||
expect($('#detail')).toBeHidden();
|
expect($('#detail')).toBeHidden();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Detail: open-switches-bucket', () => {
|
||||||
|
const SETTING_KEY = 'hyprcrd-open-switches-bucket';
|
||||||
|
const BUCKET_KEY = 'hyprcrd-active-bucket';
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await closeAll();
|
||||||
|
// Restore runner baseline.
|
||||||
|
window.state.activeBucket = 'all';
|
||||||
|
localStorage.setItem(BUCKET_KEY, 'all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to the card bucket when the setting is on', async () => {
|
||||||
|
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||||
|
if (!Array.isArray(buckets) || !buckets.length) return;
|
||||||
|
// The bucket bar renders top-level non-super buckets with
|
||||||
|
// `data-bucket=<id>`, super-bucket parents with `data-bucket-super`,
|
||||||
|
// and hides child buckets behind the parent's dropdown. To keep
|
||||||
|
// this test robust across different user bucket layouts, pick two
|
||||||
|
// plain top-level buckets (no children, no parent). Skip when the
|
||||||
|
// fixture doesn't give us two.
|
||||||
|
const childParentIds = new Set(buckets.filter(x => x.parent_id).map(x => x.parent_id));
|
||||||
|
const plainTop = buckets.filter(x => !x.parent_id && !childParentIds.has(x.id));
|
||||||
|
if (plainTop.length < 2) return;
|
||||||
|
const b = plainTop[0];
|
||||||
|
const other = plainTop[1];
|
||||||
|
const token = csrf();
|
||||||
|
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
||||||
|
let card;
|
||||||
|
const prev = localStorage.getItem(SETTING_KEY);
|
||||||
|
try {
|
||||||
|
card = await fetch('api/cards', { method: 'POST', headers: h,
|
||||||
|
body: JSON.stringify({ title: '__test_switch_bucket__', body: '', bucket_id: b.id })
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll();
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Start on a DIFFERENT bucket so the switch is visible.
|
||||||
|
window.state.activeBucket = String(other.id);
|
||||||
|
|
||||||
|
// Enable the setting.
|
||||||
|
localStorage.setItem(SETTING_KEY, '1');
|
||||||
|
|
||||||
|
window._openDetail(card.id);
|
||||||
|
await sleep(150);
|
||||||
|
|
||||||
|
expect(String(window.state.activeBucket)).toBe(String(b.id));
|
||||||
|
// Bucket bar must highlight the switched-to bucket.
|
||||||
|
const activeTab = document.querySelector('#bucket-bar .bucket-tab.active[data-bucket]');
|
||||||
|
expect(activeTab).toExist();
|
||||||
|
expect(activeTab.dataset.bucket).toBe(String(b.id));
|
||||||
|
} finally {
|
||||||
|
localStorage.setItem(SETTING_KEY, prev ?? '0');
|
||||||
|
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT switch when the setting is off', async () => {
|
||||||
|
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||||
|
if (!Array.isArray(buckets) || buckets.length < 2) return;
|
||||||
|
const token = csrf();
|
||||||
|
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
||||||
|
let card;
|
||||||
|
const prev = localStorage.getItem(SETTING_KEY);
|
||||||
|
try {
|
||||||
|
card = await fetch('api/cards', { method: 'POST', headers: h,
|
||||||
|
body: JSON.stringify({ title: '__test_no_switch__', body: '', bucket_id: buckets[0].id })
|
||||||
|
}).then(r => r.json());
|
||||||
|
await window._reloadAll();
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
window.state.activeBucket = String(buckets[1].id);
|
||||||
|
localStorage.setItem(SETTING_KEY, '0');
|
||||||
|
|
||||||
|
window._openDetail(card.id);
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Should stay on the other bucket.
|
||||||
|
expect(String(window.state.activeBucket)).toBe(String(buckets[1].id));
|
||||||
|
} finally {
|
||||||
|
localStorage.setItem(SETTING_KEY, prev ?? '0');
|
||||||
|
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token } });
|
||||||
|
await window._reloadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,8 +3,10 @@ import { describe, it, expect, csrf } from './runner.js?v=4';
|
|||||||
describe('Relay: Push API', () => {
|
describe('Relay: Push API', () => {
|
||||||
it('push without config returns error', async () => {
|
it('push without config returns error', async () => {
|
||||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
// The GET endpoint echoes the secret directly as `key` (single-user local app).
|
// GET only reports `has_key` (boolean) — the secret itself is never
|
||||||
if (settings.url && settings.key) return; // skip if configured
|
// echoed. If a relay is configured the real push may succeed or fail
|
||||||
|
// for other reasons, so skip in that case.
|
||||||
|
if (settings.url && settings.has_key) return; // skip if configured
|
||||||
|
|
||||||
const res = await fetch('api/relay/push', {
|
const res = await fetch('api/relay/push', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
@@ -16,7 +18,7 @@ describe('Relay: Push API', () => {
|
|||||||
|
|
||||||
it('push without card_id returns error', async () => {
|
it('push without card_id returns error', async () => {
|
||||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
if (!settings.url || !settings.key) return; // card_id check is after config check
|
if (!settings.url || !settings.has_key) return; // card_id check is after config check
|
||||||
const res = await fetch('api/relay/push', {
|
const res = await fetch('api/relay/push', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
@@ -28,7 +30,7 @@ describe('Relay: Push API', () => {
|
|||||||
describe('Relay: Poll API', () => {
|
describe('Relay: Poll API', () => {
|
||||||
it('poll without config returns 0 pulled', async () => {
|
it('poll without config returns 0 pulled', async () => {
|
||||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||||
if (settings.url && settings.key) return;
|
if (settings.url && settings.has_key) return;
|
||||||
|
|
||||||
const res = await fetch('api/relay/poll', {
|
const res = await fetch('api/relay/poll', {
|
||||||
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ describe('Tim Plugin: Open & Close', () => {
|
|||||||
describe('Tim Plugin: Tabs', () => {
|
describe('Tim Plugin: Tabs', () => {
|
||||||
afterEach(async () => { closeTim(); await closeAll(); });
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
it('duration tab is active on load', () => {
|
it('duration tab is active on load', async () => {
|
||||||
|
openTim();
|
||||||
|
await sleep(30);
|
||||||
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
|
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
|
||||||
expect(tab.classList.contains('active')).toBe(true);
|
expect(tab.classList.contains('active')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,11 +109,17 @@ describe('Undo: Editor Save', () => {
|
|||||||
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
click('#modal-save-close');
|
click('#modal-save-close');
|
||||||
|
|
||||||
|
// Wait for both the server patch AND the modal close before firing
|
||||||
|
// Ctrl+Z — the global undo shortcut is guarded by
|
||||||
|
// `!guards.isOpen('modal')`, so if doSave's closeModal() hasn't run
|
||||||
|
// yet (it only fires after the post-save _reloadAll), the undo key
|
||||||
|
// is swallowed silently and the test races the client's save flow.
|
||||||
let card;
|
let card;
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||||
if ((card?.body || '').includes('after edit')) break;
|
const modalClosed = !document.getElementById('modal')?.classList.contains('open');
|
||||||
|
if ((card?.body || '').includes('after edit') && modalClosed) break;
|
||||||
}
|
}
|
||||||
expect(card.body).toContain('after edit');
|
expect(card.body).toContain('after edit');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+9
-1
@@ -24,7 +24,9 @@ function getSessionTenant(PDO $db): ?array {
|
|||||||
if (!$token) return null;
|
if (!$token) return null;
|
||||||
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
||||||
$stmt->execute(['tok' => $token]);
|
$stmt->execute(['tok' => $token]);
|
||||||
return $stmt->fetch() ?: null;
|
$tenant = $stmt->fetch() ?: null;
|
||||||
|
if ($tenant) touchLastSeen($db, (int)$tenant['id']);
|
||||||
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroySession(PDO $db): void {
|
function destroySession(PDO $db): void {
|
||||||
@@ -62,5 +64,11 @@ function authClient(PDO $db): ?array {
|
|||||||
$tenant = getTenantByUsername($db, $username);
|
$tenant = getTenantByUsername($db, $username);
|
||||||
if (!$tenant) return null;
|
if (!$tenant) return null;
|
||||||
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
||||||
|
touchLastSeen($db, (int)$tenant['id']);
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function touchLastSeen(PDO $db, int $tenantId): void {
|
||||||
|
$db->prepare("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = :id")
|
||||||
|
->execute(['id' => $tenantId]);
|
||||||
|
}
|
||||||
@@ -133,6 +133,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS tenants (
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)');
|
)');
|
||||||
|
|
||||||
|
// last_seen_at: updated on every authenticated request so the admin
|
||||||
|
// can see when a user was last active.
|
||||||
|
$tenantCols = $db->query("PRAGMA table_info(tenants)")->fetchAll();
|
||||||
|
$tenantColNames = array_column($tenantCols, 'name');
|
||||||
|
if (!in_array('last_seen_at', $tenantColNames)) {
|
||||||
|
$db->exec("ALTER TABLE tenants ADD COLUMN last_seen_at TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
$db->exec('CREATE TABLE IF NOT EXISTS settings (
|
$db->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
value TEXT
|
||||||
@@ -373,6 +381,39 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks (
|
|||||||
)');
|
)');
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)');
|
||||||
|
|
||||||
|
// Read-later items: cards the tenant opted into sending to the relay for
|
||||||
|
// distraction-free reading in the mobile zen view. Stored at-rest under the
|
||||||
|
// inter-tenant key (same pattern as messages). Hard-deleted on removal —
|
||||||
|
// no offline-sync need, the hyprcrd client is the source of truth.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS read_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT "",
|
||||||
|
body TEXT NOT NULL DEFAULT "",
|
||||||
|
hero_filename TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (tenant_id, relay_uid),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_read_items_tenant_updated ON read_items(tenant_id, updated_at DESC)');
|
||||||
|
|
||||||
|
// Read attachments (inline images referenced by read items). Encrypted blobs
|
||||||
|
// scoped to a tenant + read_item. The reader UI serves these through a
|
||||||
|
// session endpoint so the browser sees decrypted bytes.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS read_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
relay_uid TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (tenant_id, relay_uid, filename),
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_read_att_lookup ON read_attachments(tenant_id, relay_uid)');
|
||||||
|
|
||||||
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tenant_id INTEGER NOT NULL,
|
tenant_id INTEGER NOT NULL,
|
||||||
@@ -387,6 +428,34 @@ $db->exec('CREATE TABLE IF NOT EXISTS rate_limits (
|
|||||||
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)');
|
)');
|
||||||
|
|
||||||
|
// ── v3.x → v1.10 crypto migration ────────────────────────────────
|
||||||
|
// Re-encrypt master-wrapped secrets (tenant client secrets + inter-tenant
|
||||||
|
// key) from CBC-V1 to GCM-V2. This only touches data we already hold the
|
||||||
|
// master key for, so no user action is needed and no secrets change —
|
||||||
|
// only the envelope around them. Idempotent: rows already in V2 skip.
|
||||||
|
require_once __DIR__ . '/crypto.php';
|
||||||
|
if (tableExists($db, 'tenants')) {
|
||||||
|
$legacyTenantRows = $db->query("SELECT id, secret_encrypted FROM tenants WHERE secret_encrypted IS NOT NULL AND secret_encrypted != '' AND secret_encrypted NOT LIKE '" . HYPRCRD_GCM_MAGIC_MASTER . "%'")->fetchAll();
|
||||||
|
if ($legacyTenantRows) {
|
||||||
|
$upd = $db->prepare('UPDATE tenants SET secret_encrypted = :s WHERE id = :i');
|
||||||
|
foreach ($legacyTenantRows as $r) {
|
||||||
|
$plain = masterDecrypt($r['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if ($plain !== '') {
|
||||||
|
$upd->execute(['s' => masterEncrypt($plain, $MASTER_KEY), 'i' => $r['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@error_log('[hyprcrd-relay] migrated ' . count($legacyTenantRows) . ' tenant secret_encrypted rows to GCM-V2');
|
||||||
|
}
|
||||||
|
$legacyItk = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
||||||
|
if ($legacyItk && !str_starts_with($legacyItk, HYPRCRD_GCM_MAGIC_MASTER)) {
|
||||||
|
$plainItk = masterDecrypt($legacyItk, $MASTER_KEY);
|
||||||
|
if ($plainItk !== '') {
|
||||||
|
setInterTenantKey($db, $MASTER_KEY, $plainItk);
|
||||||
|
@error_log('[hyprcrd-relay] migrated inter-tenant key to GCM-V2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup: 24h ephemeral storage, expired sessions, old rate limits
|
// Cleanup: 24h ephemeral storage, expired sessions, old rate limits
|
||||||
$db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')");
|
$db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')");
|
||||||
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
|
||||||
|
|||||||
+77
-27
@@ -1,24 +1,54 @@
|
|||||||
<?php
|
<?php
|
||||||
// Crypto helpers: at-rest encryption with master key, plus the
|
// Crypto helpers.
|
||||||
// HYPRCRD-RELAY-V1 envelope format used by both the JS client and
|
//
|
||||||
// the relay's internal re-encryption hops.
|
// Two envelope formats coexist during the v1.10 rollout:
|
||||||
|
//
|
||||||
|
// HYPRCRD-GCM-V2 — new: AES-256-GCM, per-ciphertext random salt + HKDF
|
||||||
|
// (authenticated, standard). Used for every new write.
|
||||||
|
// HYPRCRD-RELAY-V1 — legacy: AES-256-CBC, substr(hex(sha256(secret))) as a
|
||||||
|
// 32-byte ASCII key, no MAC. Dual-read only.
|
||||||
|
//
|
||||||
|
// For at-rest master-key encryption the legacy format carries no magic
|
||||||
|
// prefix (raw base64(iv || cipher)). The new format uses HYPRCRD-MGCM-V2.
|
||||||
|
//
|
||||||
|
// Same user secret works across both formats — only the KDF and cipher
|
||||||
|
// differ. Old data stays readable forever via the V1 fallback; new data
|
||||||
|
// is authenticated and cannot be silently mutated.
|
||||||
|
|
||||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
const HYPRCRD_GCM_MAGIC_CLIENT = 'HYPRCRD-GCM-V2';
|
||||||
|
const HYPRCRD_CBC_MAGIC_CLIENT = 'HYPRCRD-RELAY-V1';
|
||||||
|
const HYPRCRD_GCM_MAGIC_MASTER = 'HYPRCRD-MGCM-V2';
|
||||||
|
const HYPRCRD_HKDF_INFO_CLIENT = 'hyprcrd-relay-client-v2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a value at-rest with the master key. Used for tenant secrets
|
* Encrypt a value at-rest with the master key. The master key is already
|
||||||
* and the inter-tenant key in the DB.
|
* cryptographically random (32 raw bytes), so no KDF stretching is needed —
|
||||||
|
* a fresh 12-byte nonce per ciphertext is enough for GCM.
|
||||||
*/
|
*/
|
||||||
function masterEncrypt(string $plain, string $masterKeyHex): string {
|
function masterEncrypt(string $plain, string $masterKeyHex): string {
|
||||||
$key = hex2bin($masterKeyHex);
|
$key = hex2bin($masterKeyHex);
|
||||||
$iv = random_bytes(16);
|
$nonce = random_bytes(12);
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
$tag = '';
|
||||||
return base64_encode($iv . $cipher);
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
return HYPRCRD_GCM_MAGIC_MASTER . "\n" . base64_encode($nonce . $cipher . $tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function masterDecrypt(string $encoded, string $masterKeyHex): string {
|
function masterDecrypt(string $encoded, string $masterKeyHex): string {
|
||||||
$key = hex2bin($masterKeyHex);
|
$key = hex2bin($masterKeyHex);
|
||||||
$raw = base64_decode($encoded);
|
if (str_starts_with($encoded, HYPRCRD_GCM_MAGIC_MASTER)) {
|
||||||
|
$b64 = trim(substr($encoded, strlen(HYPRCRD_GCM_MAGIC_MASTER) + 1));
|
||||||
|
$raw = base64_decode($b64, true);
|
||||||
|
if (!$raw || strlen($raw) < 12 + 16 + 1) return '';
|
||||||
|
$nonce = substr($raw, 0, 12);
|
||||||
|
$tag = substr($raw, -16);
|
||||||
|
$cipher = substr($raw, 12, -16);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||||
|
return $plain !== false ? $plain : '';
|
||||||
|
}
|
||||||
|
// Legacy: base64(iv(16) || ciphertext) under AES-256-CBC, no prefix.
|
||||||
|
$raw = base64_decode($encoded, true);
|
||||||
if (!$raw || strlen($raw) < 17) return '';
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
$iv = substr($raw, 0, 16);
|
$iv = substr($raw, 0, 16);
|
||||||
$cipher = substr($raw, 16);
|
$cipher = substr($raw, 16);
|
||||||
@@ -26,27 +56,48 @@ function masterDecrypt(string $encoded, string $masterKeyHex): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match the JS client's encryption format exactly:
|
* Encrypt with a user-chosen secret (client API "shared secret" and the
|
||||||
* key = first 32 chars of hex(SHA-256(secret)) used as 32-byte ASCII key
|
* relay-wide inter-tenant key). V2 format:
|
||||||
* envelope = "HYPRCRD-RELAY-V1\n" + base64(iv || ciphertext)
|
* magic "\n" base64( salt(16) || nonce(12) || ciphertext || tag(16) )
|
||||||
|
* Per-ciphertext salt feeds HKDF(SHA-256) to derive a fresh 32-byte subkey
|
||||||
|
* — defeats the weak legacy substr-of-hex KDF and removes cross-tenant
|
||||||
|
* key collisions for identical secrets.
|
||||||
*/
|
*/
|
||||||
function clientEncrypt(string $plain, string $secret): string {
|
function clientEncrypt(string $plain, string $secret): string {
|
||||||
$key = substr(hash('sha256', $secret), 0, 32);
|
$salt = random_bytes(16);
|
||||||
$iv = random_bytes(16);
|
$nonce = random_bytes(12);
|
||||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
$key = hash_hkdf('sha256', $secret, 32, HYPRCRD_HKDF_INFO_CLIENT, $salt);
|
||||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
$tag = '';
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
return HYPRCRD_GCM_MAGIC_CLIENT . "\n" . base64_encode($salt . $nonce . $cipher . $tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientDecrypt(string $envelope, string $secret): string {
|
function clientDecrypt(string $envelope, string $secret): string {
|
||||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return $envelope;
|
if (str_starts_with($envelope, HYPRCRD_GCM_MAGIC_CLIENT)) {
|
||||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
$b64 = trim(substr($envelope, strlen(HYPRCRD_GCM_MAGIC_CLIENT) + 1));
|
||||||
$raw = base64_decode($b64);
|
$raw = base64_decode($b64, true);
|
||||||
if (!$raw || strlen($raw) < 17) return '';
|
if (!$raw || strlen($raw) < 16 + 12 + 16 + 1) return '';
|
||||||
$key = substr(hash('sha256', $secret), 0, 32);
|
$salt = substr($raw, 0, 16);
|
||||||
$iv = substr($raw, 0, 16);
|
$nonce = substr($raw, 16, 12);
|
||||||
$cipher = substr($raw, 16);
|
$tag = substr($raw, -16);
|
||||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
$cipher = substr($raw, 28, -16);
|
||||||
return $result !== false ? $result : '';
|
$key = hash_hkdf('sha256', $secret, 32, HYPRCRD_HKDF_INFO_CLIENT, $salt);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||||
|
return $plain !== false ? $plain : '';
|
||||||
|
}
|
||||||
|
if (str_starts_with($envelope, HYPRCRD_CBC_MAGIC_CLIENT)) {
|
||||||
|
$b64 = trim(substr($envelope, strlen(HYPRCRD_CBC_MAGIC_CLIENT) + 1));
|
||||||
|
$raw = base64_decode($b64, true);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
$key = substr(hash('sha256', $secret), 0, 32);
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
$result = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return $result !== false ? $result : '';
|
||||||
|
}
|
||||||
|
// Neither envelope — passthrough. Preserves the old behaviour where
|
||||||
|
// already-plaintext fields flowed through unchanged.
|
||||||
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +105,7 @@ function clientDecrypt(string $envelope, string $secret): string {
|
|||||||
* during initial setup (it's the third field in the setup form). Used to
|
* during initial setup (it's the third field in the setup form). Used to
|
||||||
* re-encrypt messages at rest so they can be routed between tenants.
|
* re-encrypt messages at rest so they can be routed between tenants.
|
||||||
*
|
*
|
||||||
* Returns null if not set yet (e.g. legacy v2 installs that haven't been
|
* Returns null if not set yet.
|
||||||
* upgraded through the new setup wizard).
|
|
||||||
*/
|
*/
|
||||||
function getInterTenantKey(PDO $db, string $masterKey): ?string {
|
function getInterTenantKey(PDO $db, string $masterKey): ?string {
|
||||||
$stored = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
$stored = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ if ($route === '/api/account/secret' && $method === 'DELETE') {
|
|||||||
|
|
||||||
if ($route === '/api/account/password' && $method === 'POST') {
|
if ($route === '/api/account/password' && $method === 'POST') {
|
||||||
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
|
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
|
||||||
|
// Rate-limit password changes — a stolen session cookie could otherwise
|
||||||
|
// brute-force the `current` field server-side with no friction.
|
||||||
|
if (isRateLimited($db, 'account-password', 10)) jsonResponse(['error' => 'too many attempts'], 429);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$current = (string)($input['current'] ?? '');
|
$current = (string)($input['current'] ?? '');
|
||||||
$new = (string)($input['new'] ?? '');
|
$new = (string)($input['new'] ?? '');
|
||||||
|
|
||||||
if (strlen($new) < 4) jsonResponse(['error' => 'new password must be at least 4 chars'], 400);
|
if (strlen($new) < 10) jsonResponse(['error' => 'new password must be at least 10 chars'], 400);
|
||||||
if (!password_verify($current, $sessionTenant['password_hash'])) {
|
if (!password_verify($current, $sessionTenant['password_hash'])) {
|
||||||
jsonResponse(['error' => 'current password is wrong'], 401);
|
jsonResponse(['error' => 'current password is wrong'], 401);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
if ($route === '/api/users' && $method === 'GET') {
|
if ($route === '/api/users' && $method === 'GET') {
|
||||||
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
|
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
|
||||||
$rows = $db->query('SELECT id, username, is_admin, created_at FROM tenants ORDER BY id ASC')->fetchAll();
|
$rows = $db->query('SELECT id, username, is_admin, created_at, last_seen_at FROM tenants ORDER BY id ASC')->fetchAll();
|
||||||
jsonResponse(['users' => $rows]);
|
jsonResponse(['users' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ if ($route === '/api/users' && $method === 'POST') {
|
|||||||
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
||||||
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
||||||
}
|
}
|
||||||
if (strlen($password) < 4) jsonResponse(['error' => 'password must be at least 4 chars'], 400);
|
if (strlen($password) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400);
|
||||||
if (getTenantByUsername($db, $username)) jsonResponse(['error' => 'username already exists'], 409);
|
if (getTenantByUsername($db, $username)) jsonResponse(['error' => 'username already exists'], 409);
|
||||||
|
|
||||||
// No client secret — the new user sets that themselves after first login
|
// No client secret — the new user sets that themselves after first login
|
||||||
@@ -53,7 +53,7 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$fields = []; $params = ['id' => $id];
|
$fields = []; $params = ['id' => $id];
|
||||||
if (!empty($input['password'])) {
|
if (!empty($input['password'])) {
|
||||||
if (strlen($input['password']) < 4) jsonResponse(['error' => 'password too short'], 400);
|
if (strlen($input['password']) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400);
|
||||||
$fields[] = 'password_hash = :pw';
|
$fields[] = 'password_hash = :pw';
|
||||||
$params['pw'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
$params['pw'] = password_hash($input['password'], PASSWORD_BCRYPT);
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-17
@@ -23,14 +23,16 @@ if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
|
|||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$myId = $sessionTenant['id'];
|
$myId = $sessionTenant['id'];
|
||||||
|
// 1:1 threads: my 1:1 messages grouped by peer username. Group sends
|
||||||
|
// (room_uid IS NOT NULL) are excluded so they don't surface here.
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
|
SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count
|
||||||
FROM chat_messages WHERE recipient_id = :r
|
FROM chat_messages WHERE recipient_id = :r AND room_uid IS NULL
|
||||||
GROUP BY sender_username
|
GROUP BY sender_username
|
||||||
UNION
|
UNION
|
||||||
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
|
SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count
|
||||||
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
|
FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id
|
||||||
WHERE cm.sender_id = :s AND cm.recipient_id != :s2
|
WHERE cm.sender_id = :s AND cm.recipient_id != :s2 AND cm.room_uid IS NULL
|
||||||
GROUP BY t.username
|
GROUP BY t.username
|
||||||
');
|
');
|
||||||
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
|
$stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]);
|
||||||
@@ -42,29 +44,85 @@ if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) {
|
|||||||
$threads[$peer]['count'] += (int)$row['count'];
|
$threads[$peer]['count'] += (int)$row['count'];
|
||||||
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
|
if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at'];
|
||||||
}
|
}
|
||||||
usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at']));
|
|
||||||
// Add unread count per peer
|
|
||||||
foreach ($threads as &$t) {
|
foreach ($threads as &$t) {
|
||||||
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0');
|
$stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0 AND room_uid IS NULL');
|
||||||
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
|
$stmt2->execute(['r' => $myId, 'p' => $t['peer']]);
|
||||||
$t['unread'] = (int)$stmt2->fetchColumn();
|
$t['unread'] = (int)$stmt2->fetchColumn();
|
||||||
}
|
}
|
||||||
jsonResponse(array_values($threads));
|
unset($t);
|
||||||
|
// Group rooms the caller is a member of. The UI renders these as
|
||||||
|
// first-class threads alongside 1:1 conversations; room_uid marks them
|
||||||
|
// so the client can key off the right identifier.
|
||||||
|
$roomStmt = $db->prepare('
|
||||||
|
SELECT r.uid, r.name, r.owner_username,
|
||||||
|
(SELECT MAX(created_at) FROM chat_messages cm WHERE cm.room_uid = r.uid) AS last_at,
|
||||||
|
(SELECT COUNT(*) FROM chat_messages cm WHERE cm.room_uid = r.uid) AS count
|
||||||
|
FROM chat_rooms r
|
||||||
|
INNER JOIN chat_room_members m ON m.room_uid = r.uid
|
||||||
|
WHERE m.tenant_id = :tid
|
||||||
|
ORDER BY last_at DESC
|
||||||
|
');
|
||||||
|
$roomStmt->execute(['tid' => $myId]);
|
||||||
|
$roomThreads = [];
|
||||||
|
foreach ($roomStmt->fetchAll() as $r) {
|
||||||
|
$roomThreads[] = [
|
||||||
|
'room_uid' => $r['uid'],
|
||||||
|
'name' => $r['name'],
|
||||||
|
'owner' => $r['owner_username'],
|
||||||
|
'last_at' => $r['last_at'] ?? '',
|
||||||
|
'count' => (int)$r['count'],
|
||||||
|
'unread' => 0, // rooms have no per-recipient seen state yet
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$combined = array_merge(array_values($threads), $roomThreads);
|
||||||
|
usort($combined, fn($a, $b) => strcmp($b['last_at'] ?? '', $a['last_at'] ?? ''));
|
||||||
|
jsonResponse($combined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
|
||||||
$peer = trim($_GET['peer'] ?? '');
|
$peer = trim($_GET['peer'] ?? '');
|
||||||
if (!$peer) jsonResponse(['error' => 'peer required'], 400);
|
$roomUid = trim($_GET['room_uid'] ?? '');
|
||||||
|
if ($peer === '' && $roomUid === '') jsonResponse(['error' => 'peer or room_uid required'], 400);
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$myId = $sessionTenant['id'];
|
$myId = $sessionTenant['id'];
|
||||||
|
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
// Group room: caller must be a current member.
|
||||||
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
|
$check->execute(['r' => $roomUid, 't' => $myId]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, sender_id, sender_username, body, created_at
|
||||||
|
FROM chat_messages
|
||||||
|
WHERE room_uid = :r
|
||||||
|
ORDER BY created_at ASC LIMIT 500
|
||||||
|
');
|
||||||
|
$stmt->execute(['r' => $roomUid]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'from' => $row['sender_username'],
|
||||||
|
'mine' => (int)$row['sender_id'] === $myId,
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'seen' => 1, // group rooms don't track per-recipient seen
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1:1 fallback
|
||||||
$peerTenant = getTenantByUsername($db, $peer);
|
$peerTenant = getTenantByUsername($db, $peer);
|
||||||
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
|
if (!$peerTenant) jsonResponse(['error' => 'peer not found'], 404);
|
||||||
$peerId = $peerTenant['id'];
|
$peerId = $peerTenant['id'];
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
SELECT id, sender_id, sender_username, body, created_at, seen
|
SELECT id, sender_id, sender_username, body, created_at, seen
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
|
WHERE room_uid IS NULL
|
||||||
|
AND ((recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s))
|
||||||
ORDER BY created_at ASC LIMIT 500
|
ORDER BY created_at ASC LIMIT 500
|
||||||
');
|
');
|
||||||
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
|
$stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]);
|
||||||
@@ -90,13 +148,30 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
$body = trim((string)($input['body'] ?? ''));
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
$recipientName = trim((string)($input['recipient'] ?? ''));
|
$recipientName = trim((string)($input['recipient'] ?? ''));
|
||||||
|
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||||
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400);
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
$stored = clientEncrypt($body, $interKey);
|
$stored = clientEncrypt($body, $interKey);
|
||||||
|
|
||||||
|
if ($roomUid !== '') {
|
||||||
|
// Group post: verify membership. `recipient_id` NOT NULL because of
|
||||||
|
// the FK, so we park the sender's own id there — GET filters on
|
||||||
|
// room_uid, never recipient_id, for group rows.
|
||||||
|
$check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t');
|
||||||
|
$check->execute(['r' => $roomUid, 't' => $sessionTenant['id']]);
|
||||||
|
if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403);
|
||||||
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (:me, :s, :sn, :ru, :b, 1)')
|
||||||
|
->execute([
|
||||||
|
'me' => $sessionTenant['id'], 's' => $sessionTenant['id'],
|
||||||
|
'sn' => $sessionTenant['username'], 'ru' => $roomUid, 'b' => $stored,
|
||||||
|
]);
|
||||||
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'room_uid' => $roomUid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)')
|
||||||
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
|
->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]);
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
@@ -122,7 +197,7 @@ if ($route === '/api/sync-events' && $method === 'POST') {
|
|||||||
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
|
||||||
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications
|
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
$db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)')
|
$db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)')
|
||||||
->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]);
|
->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]);
|
||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
@@ -193,7 +268,8 @@ if ($route === '/api/chat/rooms' && $method === 'POST') {
|
|||||||
$db->commit();
|
$db->commit();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500);
|
@error_log('[hyprcrd-relay] chat room create failed: ' . $e->getMessage());
|
||||||
|
jsonResponse(['error' => 'create failed'], 500);
|
||||||
}
|
}
|
||||||
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
||||||
}
|
}
|
||||||
@@ -306,7 +382,7 @@ if ($route === '/api/chat' && $method === 'POST') {
|
|||||||
->execute(['me' => $clientTenant['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]);
|
->execute(['me' => $clientTenant['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]);
|
||||||
} else {
|
} else {
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, persistent) VALUES (:r, :s, :sn, :b, :p)')
|
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, persistent) VALUES (:r, :s, :sn, :b, :p)')
|
||||||
->execute([
|
->execute([
|
||||||
'r' => $recipient['id'],
|
'r' => $recipient['id'],
|
||||||
@@ -417,7 +493,7 @@ if ($route === '/api/chat/backfill' && $method === 'GET') {
|
|||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
} else {
|
} else {
|
||||||
$peerTenant = getTenantByUsername($db, $peer);
|
$peerTenant = getTenantByUsername($db, $peer);
|
||||||
if (!$peerTenant) jsonResponse(['error' => "no such user: $peer"], 404);
|
if (!$peerTenant) jsonResponse(['error' => 'peer not found'], 404);
|
||||||
$sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at
|
$sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at
|
||||||
FROM chat_messages
|
FROM chat_messages
|
||||||
WHERE room_uid IS NULL
|
WHERE room_uid IS NULL
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ if ($route === '/api/collab-buckets' && $method === 'POST') {
|
|||||||
$db->commit();
|
$db->commit();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($db->inTransaction()) $db->rollBack();
|
if ($db->inTransaction()) $db->rollBack();
|
||||||
jsonResponse(['error' => 'create failed: ' . $e->getMessage()], 500);
|
@error_log('[hyprcrd-relay] collab bucket create failed: ' . $e->getMessage());
|
||||||
|
jsonResponse(['error' => 'create failed'], 500);
|
||||||
}
|
}
|
||||||
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]);
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $metho
|
|||||||
$newOwnerName = trim((string)$input['owner']);
|
$newOwnerName = trim((string)$input['owner']);
|
||||||
if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400);
|
if ($newOwnerName === '') jsonResponse(['error' => 'owner cannot be empty'], 400);
|
||||||
$newOwner = getTenantByUsername($db, $newOwnerName);
|
$newOwner = getTenantByUsername($db, $newOwnerName);
|
||||||
if (!$newOwner) jsonResponse(['error' => "no such user: $newOwnerName"], 404);
|
if (!$newOwner) jsonResponse(['error' => 'user not found'], 404);
|
||||||
$memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
$memCheck = $db->prepare('SELECT 1 FROM collab_bucket_members WHERE bucket_uid = :b AND tenant_id = :t');
|
||||||
$memCheck->execute(['b' => $uid, 't' => $newOwner['id']]);
|
$memCheck->execute(['b' => $uid, 't' => $newOwner['id']]);
|
||||||
if (!$memCheck->fetchColumn()) {
|
if (!$memCheck->fetchColumn()) {
|
||||||
|
|||||||
@@ -243,6 +243,14 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route
|
|||||||
|
|
||||||
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
|
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
|
||||||
|
|
||||||
|
// Attachment filenames always follow the hyprcrd upload pattern
|
||||||
|
// (bin2hex(random_bytes(16)).<ext>). Enforce server-side so malicious
|
||||||
|
// clients can't poison the DB with traversal-shaped names that later
|
||||||
|
// surface in UIs or get forwarded to filesystem APIs.
|
||||||
|
function collabAttachmentFilenameValid(string $fn): bool {
|
||||||
|
return (bool)preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $fn);
|
||||||
|
}
|
||||||
|
|
||||||
if ($route === '/api/collab-attachments' && $method === 'POST') {
|
if ($route === '/api/collab-attachments' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
@@ -250,6 +258,7 @@ if ($route === '/api/collab-attachments' && $method === 'POST') {
|
|||||||
$filename = (string)($input['filename'] ?? '');
|
$filename = (string)($input['filename'] ?? '');
|
||||||
$data = (string)($input['data'] ?? '');
|
$data = (string)($input['data'] ?? '');
|
||||||
if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400);
|
if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400);
|
||||||
|
if (!collabAttachmentFilenameValid($filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
}
|
}
|
||||||
@@ -277,6 +286,7 @@ if ($route === '/api/collab-attachments' && $method === 'GET') {
|
|||||||
$bucketUid = trim($_GET['bucket_uid'] ?? '');
|
$bucketUid = trim($_GET['bucket_uid'] ?? '');
|
||||||
$filename = trim($_GET['filename'] ?? '');
|
$filename = trim($_GET['filename'] ?? '');
|
||||||
if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400);
|
if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400);
|
||||||
|
if ($filename !== '' && !collabAttachmentFilenameValid($filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ function collabLocksIsMember(PDO $db, string $bucketUid, int $tenantId): bool {
|
|||||||
|
|
||||||
// ── POST: acquire/refresh ────────────────────────────────────────
|
// ── POST: acquire/refresh ────────────────────────────────────────
|
||||||
|
|
||||||
|
// Maximum time a single holder can keep refreshing the same lock. Without a
|
||||||
|
// cap a malicious member could heartbeat forever, blocking every other
|
||||||
|
// member's writes. 10 minutes is long enough for any real edit session and
|
||||||
|
// short enough to time-box grief. When the cap is hit the lock is force-
|
||||||
|
// released and the client must re-acquire — giving everyone else a window.
|
||||||
|
const COLLAB_LOCK_MAX_HOLD_SECONDS = 600;
|
||||||
|
|
||||||
if ($route === '/api/collab-locks' && $method === 'POST') {
|
if ($route === '/api/collab-locks' && $method === 'POST') {
|
||||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
|
if (isRateLimited($db, 'collab-locks-post', 240)) jsonResponse(['error' => 'rate limited'], 429);
|
||||||
@@ -35,8 +42,12 @@ if ($route === '/api/collab-locks' && $method === 'POST') {
|
|||||||
// narrow window between cleanup and this query.
|
// narrow window between cleanup and this query.
|
||||||
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND expires_at <= datetime('now')")
|
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND expires_at <= datetime('now')")
|
||||||
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
|
// Also drop locks that have been refreshed past the max hold window —
|
||||||
|
// cap enforcement for grief prevention.
|
||||||
|
$db->prepare("DELETE FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c AND acquired_at <= datetime('now', '-" . COLLAB_LOCK_MAX_HOLD_SECONDS . " seconds')")
|
||||||
|
->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
// Existing live lock? If a different tenant holds it, refuse.
|
// Existing live lock? If a different tenant holds it, refuse.
|
||||||
$cur = $db->prepare('SELECT holder_id, holder_username, expires_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c');
|
$cur = $db->prepare('SELECT holder_id, holder_username, expires_at, acquired_at FROM collab_card_locks WHERE bucket_uid = :b AND card_uid = :c');
|
||||||
$cur->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
$cur->execute(['b' => $bucketUid, 'c' => $cardUid]);
|
||||||
$existing = $cur->fetch();
|
$existing = $cur->fetch();
|
||||||
if ($existing && (int)$existing['holder_id'] !== (int)$clientTenant['id']) {
|
if ($existing && (int)$existing['holder_id'] !== (int)$clientTenant['id']) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
|||||||
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
if ($body === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
@@ -54,7 +54,7 @@ if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant
|
|||||||
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
@@ -142,7 +142,7 @@ if ($route === '/api/messages' && $method === 'POST') {
|
|||||||
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
// Decrypt with sender's secret
|
// Decrypt with sender's secret
|
||||||
if (empty($clientTenant['secret_encrypted'])) {
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
@@ -225,9 +225,12 @@ if ($route === '/api/attachments' && $method === 'POST') {
|
|||||||
$data = (string)($input['data'] ?? '');
|
$data = (string)($input['data'] ?? '');
|
||||||
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
|
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
|
||||||
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400);
|
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400);
|
||||||
|
// Enforce the hyprcrd upload naming scheme server-side so traversal-shaped
|
||||||
|
// names never enter storage or get forwarded back to other clients.
|
||||||
|
if (!preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
|
|
||||||
$recipient = getTenantByUsername($db, $recipientName);
|
$recipient = getTenantByUsername($db, $recipientName);
|
||||||
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
|
if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404);
|
||||||
|
|
||||||
// Decrypt-reencrypt hop, same shape as messages: arrives encrypted with
|
// Decrypt-reencrypt hop, same shape as messages: arrives encrypted with
|
||||||
// sender's secret, gets stored at-rest under the inter-tenant key.
|
// sender's secret, gets stored at-rest under the inter-tenant key.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ if ($route === '/api/setup' && $method === 'POST') {
|
|||||||
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
|
||||||
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400);
|
||||||
}
|
}
|
||||||
if (strlen($password) < 4) jsonResponse(['error' => 'password must be at least 4 chars'], 400);
|
if (strlen($password) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400);
|
||||||
if (strlen($superSecret) < 12) jsonResponse(['error' => 'super-shared secret must be at least 12 chars'], 400);
|
if (strlen($superSecret) < 12) jsonResponse(['error' => 'super-shared secret must be at least 12 chars'], 400);
|
||||||
|
|
||||||
// Create the admin tenant. NO client secret yet — the admin (and every
|
// Create the admin tenant. NO client secret yet — the admin (and every
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
// Read-later routes:
|
||||||
|
// POST /api/read (client API: upsert an item)
|
||||||
|
// DELETE /api/read/{uid} (client or session: remove)
|
||||||
|
// GET /api/read (session: list own items)
|
||||||
|
// GET /api/read/{uid} (session: one item, decrypted)
|
||||||
|
// POST /api/read-attachments (client API: upload image blob)
|
||||||
|
// GET /api/read-attachments/{uid}/{fn} (session: serve decrypted bytes)
|
||||||
|
//
|
||||||
|
// Encryption mirrors messages / collab-cards: client encrypts with their
|
||||||
|
// own secret, relay decrypts with senderSecret, re-encrypts under the
|
||||||
|
// inter-tenant key for at-rest storage, decrypts again for the session
|
||||||
|
// UI. Hard delete — no tombstones (no offline-sync requirement, the
|
||||||
|
// hyprcrd client is the source of truth).
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
// Session tenant is set by routes/public.php earlier in the chain;
|
||||||
|
// client-API auth is lazy so we don't hit authClient() when the session
|
||||||
|
// alone suffices.
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
|
||||||
|
// ── Client API: upsert an item ────────────────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/read' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'sender has no client secret set'], 400);
|
||||||
|
}
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$relayUid = trim((string)($input['relay_uid'] ?? ''));
|
||||||
|
$encTitle = (string)($input['title'] ?? '');
|
||||||
|
$encBody = (string)($input['body'] ?? '');
|
||||||
|
$heroFilename = trim((string)($input['hero_filename'] ?? ''));
|
||||||
|
if ($relayUid === '' || $encBody === '') {
|
||||||
|
jsonResponse(['error' => 'relay_uid and body required'], 400);
|
||||||
|
}
|
||||||
|
// Hero filename follows the hyprcrd upload naming scheme; empty is
|
||||||
|
// valid (no inline images).
|
||||||
|
if ($heroFilename !== '' && !preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $heroFilename)) {
|
||||||
|
jsonResponse(['error' => 'invalid hero_filename'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plainTitle = clientDecrypt($encTitle, $senderSecret);
|
||||||
|
$plainBody = clientDecrypt($encBody, $senderSecret);
|
||||||
|
if ($plainBody === '') jsonResponse(['error' => 'body decrypt failed'], 400);
|
||||||
|
|
||||||
|
$storedTitle = clientEncrypt($plainTitle, $interKey);
|
||||||
|
$storedBody = clientEncrypt($plainBody, $interKey);
|
||||||
|
|
||||||
|
$db->prepare("
|
||||||
|
INSERT INTO read_items (tenant_id, relay_uid, title, body, hero_filename, updated_at)
|
||||||
|
VALUES (:t, :u, :ti, :bo, :hf, datetime('now'))
|
||||||
|
ON CONFLICT(tenant_id, relay_uid) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
body = excluded.body,
|
||||||
|
hero_filename = excluded.hero_filename,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
")->execute([
|
||||||
|
't' => $clientTenant['id'],
|
||||||
|
'u' => $relayUid,
|
||||||
|
'ti' => $storedTitle,
|
||||||
|
'bo' => $storedBody,
|
||||||
|
'hf' => $heroFilename !== '' ? $heroFilename : null,
|
||||||
|
]);
|
||||||
|
jsonResponse(['ok' => true, 'relay_uid' => $relayUid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/read/{uid} ────────────────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/read/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$tenant = $clientTenant ?: $sessionTenant;
|
||||||
|
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$uid = $m[1];
|
||||||
|
$db->prepare('DELETE FROM read_attachments WHERE tenant_id = :t AND relay_uid = :u')
|
||||||
|
->execute(['t' => $tenant['id'], 'u' => $uid]);
|
||||||
|
$db->prepare('DELETE FROM read_items WHERE tenant_id = :t AND relay_uid = :u')
|
||||||
|
->execute(['t' => $tenant['id'], 'u' => $uid]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session: list own items (for the relay UI) ────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/read' && $method === 'GET' && $sessionTenant) {
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT relay_uid, title, hero_filename, created_at, updated_at, length(body) AS body_len
|
||||||
|
FROM read_items WHERE tenant_id = :t
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
');
|
||||||
|
$stmt->execute(['t' => $sessionTenant['id']]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($stmt->fetchAll() as $row) {
|
||||||
|
$out[] = [
|
||||||
|
'relay_uid' => $row['relay_uid'],
|
||||||
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||||
|
'hero_filename' => $row['hero_filename'],
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
jsonResponse(['items' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session: one item with body ───────────────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/read/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'GET' && $sessionTenant) {
|
||||||
|
$uid = $m[1];
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
$stmt = $db->prepare('SELECT relay_uid, title, body, hero_filename, created_at, updated_at FROM read_items WHERE tenant_id = :t AND relay_uid = :u');
|
||||||
|
$stmt->execute(['t' => $sessionTenant['id'], 'u' => $uid]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) jsonResponse(['error' => 'not found'], 404);
|
||||||
|
jsonResponse([
|
||||||
|
'relay_uid' => $row['relay_uid'],
|
||||||
|
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||||
|
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||||
|
'hero_filename' => $row['hero_filename'],
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client API: upload inline image blob ──────────────────────────
|
||||||
|
|
||||||
|
if ($route === '/api/read-attachments' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) {
|
||||||
|
jsonResponse(['error' => 'sender has no client secret set'], 400);
|
||||||
|
}
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$relayUid = (string)($input['relay_uid'] ?? '');
|
||||||
|
$filename = (string)($input['filename'] ?? '');
|
||||||
|
$data = (string)($input['data'] ?? '');
|
||||||
|
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, data required'], 400);
|
||||||
|
if (!preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $filename)) jsonResponse(['error' => 'invalid filename'], 400);
|
||||||
|
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plain = clientDecrypt($data, $senderSecret);
|
||||||
|
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
|
||||||
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO read_attachments (tenant_id, relay_uid, filename, data) VALUES (:t, :u, :f, :d)
|
||||||
|
ON CONFLICT(tenant_id, relay_uid, filename) DO UPDATE SET data = excluded.data')
|
||||||
|
->execute(['t' => $clientTenant['id'], 'u' => $relayUid, 'f' => $filename, 'd' => $stored]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session: serve decrypted image bytes ──────────────────────────
|
||||||
|
|
||||||
|
if (preg_match('#^/api/read-attachments/([a-zA-Z0-9_-]+)/([a-f0-9]+\.[a-z0-9]{1,8})$#', $route, $m) && $method === 'GET' && $sessionTenant) {
|
||||||
|
$uid = $m[1];
|
||||||
|
$filename = $m[2];
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) { http_response_code(500); exit; }
|
||||||
|
$stmt = $db->prepare('SELECT data FROM read_attachments WHERE tenant_id = :t AND relay_uid = :u AND filename = :f');
|
||||||
|
$stmt->execute(['t' => $sessionTenant['id'], 'u' => $uid, 'f' => $filename]);
|
||||||
|
$enc = $stmt->fetchColumn();
|
||||||
|
if (!$enc) { http_response_code(404); exit; }
|
||||||
|
$b64 = clientDecrypt($enc, $interKey);
|
||||||
|
$bytes = base64_decode($b64, true);
|
||||||
|
if ($bytes === false) { http_response_code(500); exit; }
|
||||||
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||||||
|
$mimes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'];
|
||||||
|
header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream'));
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
echo $bytes;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
+26
-3
@@ -40,10 +40,25 @@ $isSetup = tenantCount($db) === 0;
|
|||||||
|
|
||||||
if (str_starts_with($route, '/api/')) {
|
if (str_starts_with($route, '/api/')) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
// CORS: the hyprcrd client hits us server-side (no browser CORS in play)
|
||||||
|
// and the relay UI lives same-origin, so the only legitimate cross-origin
|
||||||
|
// callers are other tenant UIs on the same relay host. We allow the
|
||||||
|
// relay's own scheme+host, plus an opt-in allowlist via RELAY_CORS_ORIGINS
|
||||||
|
// (comma-separated). Credentials are intentionally NOT allowed — API auth
|
||||||
|
// is header-based (X-Relay-User/Key) and the UI is same-origin.
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
if ($origin) {
|
$ownScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' ? 'https' : 'http';
|
||||||
|
$ownOrigin = $ownScheme . '://' . ($_SERVER['HTTP_HOST'] ?? '');
|
||||||
|
$allowed = [$ownOrigin];
|
||||||
|
$extra = getenv('RELAY_CORS_ORIGINS');
|
||||||
|
if ($extra) {
|
||||||
|
foreach (explode(',', $extra) as $o) {
|
||||||
|
$o = trim($o);
|
||||||
|
if ($o !== '') $allowed[] = $o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($origin && in_array($origin, $allowed, true)) {
|
||||||
header('Access-Control-Allow-Origin: ' . $origin);
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
header('Access-Control-Allow-Credentials: true');
|
|
||||||
header('Vary: Origin');
|
header('Vary: Origin');
|
||||||
}
|
}
|
||||||
header('Access-Control-Allow-Headers: Content-Type, X-Relay-User, X-Relay-Key');
|
header('Access-Control-Allow-Headers: Content-Type, X-Relay-User, X-Relay-Key');
|
||||||
@@ -51,7 +66,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($method === 'OPTIONS') exit;
|
if ($method === 'OPTIONS') exit;
|
||||||
|
|
||||||
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||||
$sizeLimit = ($route === '/api/attachments') ? 15728640 : 262144;
|
// Attachment routes handle binary blobs; everything else is JSON metadata.
|
||||||
|
// A ~150KB image becomes a ~275KB envelope after base64 + GCM framing,
|
||||||
|
// so 256KB would silently drop any non-trivial image on collab pushes.
|
||||||
|
$isAttachmentRoute = in_array($route, ['/api/attachments', '/api/attachments/upload', '/api/collab-attachments', '/api/read-attachments'], true);
|
||||||
|
// Read items carry the full card body (markdown), which can be much
|
||||||
|
// longer than a chat message — raise the JSON-metadata cap for the
|
||||||
|
// read endpoint too.
|
||||||
|
$sizeLimit = $isAttachmentRoute ? 15728640 : ($route === '/api/read' ? 2097152 : 262144);
|
||||||
if ($contentLength > $sizeLimit) jsonResponse(['error' => 'payload too large'], 413);
|
if ($contentLength > $sizeLimit) jsonResponse(['error' => 'payload too large'], 413);
|
||||||
|
|
||||||
// Each route file contains its handlers as if-blocks that exit on
|
// Each route file contains its handlers as if-blocks that exit on
|
||||||
@@ -66,6 +88,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
require __DIR__ . '/inc/routes/collab-cards.php';
|
require __DIR__ . '/inc/routes/collab-cards.php';
|
||||||
require __DIR__ . '/inc/routes/collab-locks.php';
|
require __DIR__ . '/inc/routes/collab-locks.php';
|
||||||
require __DIR__ . '/inc/routes/shopping.php';
|
require __DIR__ . '/inc/routes/shopping.php';
|
||||||
|
require __DIR__ . '/inc/routes/read.php';
|
||||||
|
|
||||||
jsonResponse(['error' => 'not found'], 404);
|
jsonResponse(['error' => 'not found'], 404);
|
||||||
}
|
}
|
||||||
|
|||||||
+271
-52
@@ -68,16 +68,18 @@ function showView(name) {
|
|||||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||||
name = 'change-password';
|
name = 'change-password';
|
||||||
}
|
}
|
||||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
|
['setup', 'login', 'change-password', 'compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].forEach(v => {
|
||||||
$('view-' + v)?.classList.add('hidden');
|
$('view-' + v)?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
$('view-' + name)?.classList.remove('hidden');
|
$('view-' + name)?.classList.remove('hidden');
|
||||||
|
|
||||||
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].includes(name)) {
|
||||||
$('nav-buttons').classList.remove('hidden');
|
$('nav-buttons').classList.remove('hidden');
|
||||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||||
|
$('nav-inbox')?.classList.toggle('active', name === 'inbox');
|
||||||
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||||
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
||||||
|
$('nav-read')?.classList.toggle('active', name === 'read');
|
||||||
$('nav-account').classList.toggle('active', name === 'account');
|
$('nav-account').classList.toggle('active', name === 'account');
|
||||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||||
$('nav-burger')?.classList.remove('hidden');
|
$('nav-burger')?.classList.remove('hidden');
|
||||||
@@ -92,13 +94,19 @@ function showView(name) {
|
|||||||
|
|
||||||
if (name === 'compose') {
|
if (name === 'compose') {
|
||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
|
$('compose-body')?.focus();
|
||||||
|
}
|
||||||
|
if (name === 'inbox') {
|
||||||
loadInbox();
|
loadInbox();
|
||||||
loadOutbox();
|
loadOutbox();
|
||||||
loadCollabInbox();
|
loadCollabInbox();
|
||||||
startInboxPoll();
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
|
||||||
} else {
|
} else {
|
||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
|
// Keep the header badge count fresh even outside the inbox
|
||||||
|
// view — a quiet load updates the count without rendering the
|
||||||
|
// full list (the DOM elements are in the hidden inbox view).
|
||||||
|
loadInbox({ quiet: true });
|
||||||
}
|
}
|
||||||
if (name === 'chat') {
|
if (name === 'chat') {
|
||||||
loadChatThreads();
|
loadChatThreads();
|
||||||
@@ -112,9 +120,14 @@ function showView(name) {
|
|||||||
} else {
|
} else {
|
||||||
stopShoppingPoll();
|
stopShoppingPoll();
|
||||||
}
|
}
|
||||||
|
if (name === 'read') {
|
||||||
|
loadReadList();
|
||||||
|
// Close any open article when switching into the list view.
|
||||||
|
closeReader();
|
||||||
|
}
|
||||||
// Remember the active view so a page reload lands back here
|
// Remember the active view so a page reload lands back here
|
||||||
// instead of defaulting to compose every time.
|
// instead of defaulting to compose every time.
|
||||||
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].includes(name)) {
|
||||||
try { localStorage.setItem('relay-last-view', name); } catch {}
|
try { localStorage.setItem('relay-last-view', name); } catch {}
|
||||||
}
|
}
|
||||||
if (name === 'admin') {
|
if (name === 'admin') {
|
||||||
@@ -139,7 +152,7 @@ function startInboxPoll() {
|
|||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
inboxPollHandle = setInterval(() => {
|
inboxPollHandle = setInterval(() => {
|
||||||
if (inboxBusy) return;
|
if (inboxBusy) return;
|
||||||
if (!$('view-compose')?.classList.contains('hidden')) {
|
if (!$('view-inbox')?.classList.contains('hidden')) {
|
||||||
loadInbox({ quiet: true });
|
loadInbox({ quiet: true });
|
||||||
loadOutbox({ quiet: true });
|
loadOutbox({ quiet: true });
|
||||||
loadCollabInbox({ quiet: true });
|
loadCollabInbox({ quiet: true });
|
||||||
@@ -173,21 +186,7 @@ window.toggleNavMenu = toggleNavMenu;
|
|||||||
* if at all (the inbox is already on screen). */
|
* if at all (the inbox is already on screen). */
|
||||||
function jumpToInbox() {
|
function jumpToInbox() {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
if ($('view-compose')?.classList.contains('hidden')) {
|
showView('inbox');
|
||||||
showView('compose');
|
|
||||||
}
|
|
||||||
// Wait one frame so the (possibly just-shown) view has measurable
|
|
||||||
// dimensions before scrollIntoView fires
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const target = $('inbox-section');
|
|
||||||
if (!target) return;
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
target.classList.remove('flash-highlight');
|
|
||||||
// Restart the animation by toggling the class
|
|
||||||
void target.offsetWidth;
|
|
||||||
target.classList.add('flash-highlight');
|
|
||||||
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
window.jumpToInbox = jumpToInbox;
|
window.jumpToInbox = jumpToInbox;
|
||||||
// Tap outside the menu closes it
|
// Tap outside the menu closes it
|
||||||
@@ -206,7 +205,7 @@ async function doChangePassword() {
|
|||||||
const err = $('cp-err');
|
const err = $('cp-err');
|
||||||
err.textContent = '';
|
err.textContent = '';
|
||||||
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
|
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
|
||||||
if (newPw.length < 4) { err.textContent = 'new password too short'; return; }
|
if (newPw.length < 10) { err.textContent = 'new password must be at least 10 chars'; return; }
|
||||||
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
|
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
|
||||||
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
|
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
|
||||||
|
|
||||||
@@ -238,7 +237,7 @@ async function doSetup() {
|
|||||||
const err = $('setup-err');
|
const err = $('setup-err');
|
||||||
err.textContent = '';
|
err.textContent = '';
|
||||||
if (!username) { err.textContent = 'username required'; return; }
|
if (!username) { err.textContent = 'username required'; return; }
|
||||||
if (password.length < 4) { err.textContent = 'password too short'; return; }
|
if (password.length < 10) { err.textContent = 'password must be at least 10 chars'; return; }
|
||||||
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
|
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
|
||||||
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
|
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -285,7 +284,7 @@ function routePostLogin(status) {
|
|||||||
toast('set your client secret to start using the relay');
|
toast('set your client secret to start using the relay');
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem('relay-last-view') || 'compose';
|
const last = localStorage.getItem('relay-last-view') || 'compose';
|
||||||
showView(['compose', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose');
|
showView(['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].includes(last) ? last : 'compose');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +640,6 @@ async function loadCollabInbox({ quiet = false } = {}) {
|
|||||||
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
||||||
${c.preview ? `<div style="color:var(--dim);font-size:11px;margin-top:2px;">${escapeHtml(c.preview)}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -859,61 +857,88 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Chat ──
|
// ── Chat ──
|
||||||
|
//
|
||||||
|
// Two thread kinds live side by side: 1:1 (identified by peer username)
|
||||||
|
// and group rooms (identified by room_uid). The UI keeps a single
|
||||||
|
// "active thread" descriptor and switches the fetch / send shape based
|
||||||
|
// on which field is populated.
|
||||||
|
|
||||||
let chatActivePeer = null;
|
let chatActive = null; // { kind: 'peer' | 'room', peer?, roomUid?, label }
|
||||||
let chatPollHandle = null;
|
let chatPollHandle = null;
|
||||||
|
|
||||||
function startChatPoll() {
|
function startChatPoll() {
|
||||||
stopChatPoll();
|
stopChatPoll();
|
||||||
chatPollHandle = setInterval(() => {
|
chatPollHandle = setInterval(() => {
|
||||||
loadChatThreads(true);
|
loadChatThreads(true);
|
||||||
if (chatActivePeer) loadChatHistory(chatActivePeer, true);
|
if (chatActive) loadChatHistory(chatActive, true);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
function stopChatPoll() {
|
function stopChatPoll() {
|
||||||
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameActive(a, b) {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.kind === b.kind && (a.peer === b.peer) && (a.roomUid === b.roomUid);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadChatThreads(quiet = false) {
|
async function loadChatThreads(quiet = false) {
|
||||||
const list = $('chat-thread-list');
|
const list = $('chat-thread-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const threads = await api('api/chat/threads') || [];
|
const threads = await api('api/chat/threads') || [];
|
||||||
if (!Array.isArray(threads)) return;
|
if (!Array.isArray(threads)) return;
|
||||||
|
|
||||||
// If no threads and not quiet, show empty state
|
|
||||||
if (!threads.length && !quiet) {
|
if (!threads.length && !quiet) {
|
||||||
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!threads.length) return;
|
if (!threads.length) return;
|
||||||
|
|
||||||
list.innerHTML = threads.map(t => `
|
list.innerHTML = threads.map(t => {
|
||||||
<div class="chat-thread ${chatActivePeer === t.peer ? 'active' : ''}" data-peer="${escapeHtml(t.peer)}">
|
const isRoom = !!t.room_uid;
|
||||||
<i class="ph ph-user-circle" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
const desc = isRoom
|
||||||
<div style="flex:1;min-width:0;">
|
? { kind: 'room', roomUid: t.room_uid, label: t.name || t.room_uid }
|
||||||
<div class="peer-name">${escapeHtml(t.peer)}</div>
|
: { kind: 'peer', peer: t.peer, label: t.peer };
|
||||||
<div class="thread-time">${formatRelTime(t.last_at)}</div>
|
const active = isSameActive(chatActive, desc);
|
||||||
</div>
|
const icon = isRoom ? 'ph-users-three' : 'ph-user-circle';
|
||||||
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
const tag = isRoom ? 'room' : null;
|
||||||
</div>
|
const attrs = isRoom
|
||||||
`).join('');
|
? `data-kind="room" data-room-uid="${escapeHtml(t.room_uid)}" data-label="${escapeHtml(desc.label)}"`
|
||||||
|
: `data-kind="peer" data-peer="${escapeHtml(t.peer)}"`;
|
||||||
|
return `
|
||||||
|
<div class="chat-thread ${active ? 'active' : ''}" ${attrs}>
|
||||||
|
<i class="ph ${icon}" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="peer-name">${escapeHtml(desc.label)}${tag ? ` <span style="font-family:var(--pixel);font-size:8px;color:var(--dim);text-transform:uppercase;letter-spacing:0.5px;margin-left:4px;">${tag}</span>` : ''}</div>
|
||||||
|
<div class="thread-time">${escapeHtml(formatRelTime(t.last_at))}</div>
|
||||||
|
</div>
|
||||||
|
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
list.querySelectorAll('.chat-thread').forEach(el => {
|
list.querySelectorAll('.chat-thread').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
chatActivePeer = el.dataset.peer;
|
if (el.dataset.kind === 'room') {
|
||||||
|
chatActive = { kind: 'room', roomUid: el.dataset.roomUid, label: el.dataset.label };
|
||||||
|
} else {
|
||||||
|
chatActive = { kind: 'peer', peer: el.dataset.peer, label: el.dataset.peer };
|
||||||
|
}
|
||||||
loadChatThreads(true);
|
loadChatThreads(true);
|
||||||
loadChatHistory(chatActivePeer);
|
loadChatHistory(chatActive);
|
||||||
$('chat-input-bar')?.classList.remove('hidden');
|
$('chat-input-bar')?.classList.remove('hidden');
|
||||||
$('chat-peer-name').textContent = chatActivePeer;
|
$('chat-peer-name').textContent = chatActive.label;
|
||||||
$('chat-input')?.focus();
|
$('chat-input')?.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChatHistory(peer, quiet = false) {
|
async function loadChatHistory(active, quiet = false) {
|
||||||
const container = $('chat-messages');
|
const container = $('chat-messages');
|
||||||
if (!container) return;
|
if (!container || !active) return;
|
||||||
const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || [];
|
const qs = active.kind === 'room'
|
||||||
|
? 'room_uid=' + encodeURIComponent(active.roomUid)
|
||||||
|
: 'peer=' + encodeURIComponent(active.peer);
|
||||||
|
const messages = await api('api/chat/history?' + qs) || [];
|
||||||
if (!Array.isArray(messages)) return;
|
if (!Array.isArray(messages)) return;
|
||||||
|
|
||||||
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
||||||
@@ -923,8 +948,13 @@ async function loadChatHistory(peer, quiet = false) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group rooms need the sender's name visible above each incoming
|
||||||
|
// message (there isn't one single peer — it could be anyone in the
|
||||||
|
// room). 1:1 stays unlabeled for a tighter look.
|
||||||
|
const showSender = active.kind === 'room';
|
||||||
container.innerHTML = messages.map(m => `
|
container.innerHTML = messages.map(m => `
|
||||||
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||||
|
${showSender && !m.mine ? `<div class="msg-from" style="font-family:var(--pixel);font-size:9px;color:var(--accent2);margin-bottom:2px;">${escapeHtml(m.from)}</div>` : ''}
|
||||||
<div>${escapeHtml(m.body)}</div>
|
<div>${escapeHtml(m.body)}</div>
|
||||||
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -934,18 +964,20 @@ async function loadChatHistory(peer, quiet = false) {
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh thread list to clear unread badges
|
|
||||||
if (!quiet) loadChatThreads(true);
|
if (!quiet) loadChatThreads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendChatMessage() {
|
async function sendChatMessage() {
|
||||||
const input = $('chat-input');
|
const input = $('chat-input');
|
||||||
if (!input || !chatActivePeer) return;
|
if (!input || !chatActive) return;
|
||||||
const body = input.value.trim();
|
const body = input.value.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } });
|
const payload = chatActive.kind === 'room'
|
||||||
loadChatHistory(chatActivePeer);
|
? { body, room_uid: chatActive.roomUid }
|
||||||
|
: { body, recipient: chatActive.peer };
|
||||||
|
await api('api/chat/send', { method: 'POST', body: payload });
|
||||||
|
loadChatHistory(chatActive);
|
||||||
loadChatThreads(true);
|
loadChatThreads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,19 +1038,22 @@ async function loadUsers() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = users.map(u => `
|
list.innerHTML = users.map(u => {
|
||||||
|
const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected';
|
||||||
|
return `
|
||||||
<div class="user-row" data-id="${u.id}">
|
<div class="user-row" data-id="${u.id}">
|
||||||
<i class="ph ph-user-circle user-icon"></i>
|
<i class="ph ph-user-circle user-icon"></i>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||||
<div class="meta">created ${u.created_at}</div>
|
<div class="meta">created ${u.created_at} · ${seenText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
||||||
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
@@ -1044,8 +1079,8 @@ async function createUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetUserPassword(id, username) {
|
async function resetUserPassword(id, username) {
|
||||||
const newPw = prompt(`new password for ${username} (min 4 chars):`);
|
const newPw = prompt(`new password for ${username} (min 10 chars):`);
|
||||||
if (!newPw || newPw.length < 4) { toast('password too short', true); return; }
|
if (!newPw || newPw.length < 10) { toast('password too short', true); return; }
|
||||||
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
|
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
|
||||||
if (res.ok) toast('password updated');
|
if (res.ok) toast('password updated');
|
||||||
else toast(res.error || 'failed', true);
|
else toast(res.error || 'failed', true);
|
||||||
@@ -1184,3 +1219,187 @@ document.addEventListener('keydown', e => {
|
|||||||
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
|
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Read view: list + zen reader ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// The hyprcrd client pushes cards here when the user toggles "Read later"
|
||||||
|
// on a card. This view renders the list, and the reader overlay renders
|
||||||
|
// the markdown in a mobile-first, distraction-free column. Images in the
|
||||||
|
// body are rewritten to the relay's session-authed image endpoint.
|
||||||
|
|
||||||
|
let readCurrentUid = null;
|
||||||
|
|
||||||
|
async function loadReadList() {
|
||||||
|
const list = $('read-items');
|
||||||
|
const empty = $('read-empty');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await api('api/read');
|
||||||
|
const items = res?.items || [];
|
||||||
|
if (!items.length) {
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
list.innerHTML = items.map(it => {
|
||||||
|
const title = it.title?.trim() || '(untitled)';
|
||||||
|
const thumb = it.hero_filename
|
||||||
|
? `<div class="read-item-thumb" style="background-image:url('api/read-attachments/${encodeURIComponent(it.relay_uid)}/${encodeURIComponent(it.hero_filename)}');"></div>`
|
||||||
|
: `<div class="read-item-thumb read-item-thumb-placeholder"><i class="ph ph-book-open"></i></div>`;
|
||||||
|
return `
|
||||||
|
<li class="read-item" data-uid="${escapeHtml(it.relay_uid)}">
|
||||||
|
${thumb}
|
||||||
|
<div class="read-item-body">
|
||||||
|
<div class="read-item-title">${escapeHtml(title)}</div>
|
||||||
|
<div class="read-item-meta">${escapeHtml(formatRelTime(it.updated_at))}</div>
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
list.querySelectorAll('.read-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => openReader(el.dataset.uid));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<li style="color:var(--danger);font-size:12px;">Failed to load: ${escapeHtml(e.message)}</li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReader(uid) {
|
||||||
|
const reader = $('read-reader');
|
||||||
|
const article = $('read-article');
|
||||||
|
if (!reader || !article) return;
|
||||||
|
readCurrentUid = uid;
|
||||||
|
article.innerHTML = '<p style="color:var(--dim);">loading…</p>';
|
||||||
|
reader.classList.remove('hidden');
|
||||||
|
reader.scrollTop = 0;
|
||||||
|
try {
|
||||||
|
const res = await api('api/read/' + encodeURIComponent(uid));
|
||||||
|
if (res?.error) throw new Error(res.error);
|
||||||
|
article.innerHTML = renderReadArticle(res);
|
||||||
|
} catch (e) {
|
||||||
|
article.innerHTML = `<p style="color:var(--danger);">Failed to load: ${escapeHtml(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReader() {
|
||||||
|
readCurrentUid = null;
|
||||||
|
$('read-reader')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReadArticle(item) {
|
||||||
|
const title = item.title?.trim() || '(untitled)';
|
||||||
|
const rawBody = item.body || '';
|
||||||
|
// Hero: first uploads/<hex>.<ext> reference in body. Rewrite to the
|
||||||
|
// session-authed relay endpoint so the <img src> resolves server-side
|
||||||
|
// against the tenant's own read_attachments rows.
|
||||||
|
const uid = encodeURIComponent(item.relay_uid);
|
||||||
|
const bodyForRender = rawBody.replace(
|
||||||
|
/uploads\/([a-f0-9]+\.[a-z0-9]+)/gi,
|
||||||
|
(_m, fn) => `api/read-attachments/${uid}/${encodeURIComponent(fn)}`
|
||||||
|
);
|
||||||
|
const hero = item.hero_filename
|
||||||
|
? `<img class="read-hero" src="api/read-attachments/${uid}/${encodeURIComponent(item.hero_filename)}" alt="">`
|
||||||
|
: '';
|
||||||
|
const date = item.updated_at ? formatRelTime(item.updated_at) : '';
|
||||||
|
return `
|
||||||
|
${hero}
|
||||||
|
<h1 class="read-title">${escapeHtml(title)}</h1>
|
||||||
|
<div class="read-date">${escapeHtml(date)}</div>
|
||||||
|
${renderMarkdown(bodyForRender)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline markdown renderer. Covers the cases hyprcrd cards actually
|
||||||
|
// use: paragraphs, headings, bold/italic, inline code, links, bullet +
|
||||||
|
// numbered lists, blockquotes, hr, fenced code blocks, inline images.
|
||||||
|
// We do NOT pull a full library in — this matches the UI's existing
|
||||||
|
// minimal-deps stance (fonts + phosphor, nothing else).
|
||||||
|
function renderMarkdown(src) {
|
||||||
|
if (!src) return '';
|
||||||
|
const placeholders = [];
|
||||||
|
const stash = (html) => { placeholders.push(html); return `\u0000${placeholders.length - 1}\u0000`; };
|
||||||
|
|
||||||
|
// Fenced code
|
||||||
|
src = src.replace(/```(\w+)?\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
||||||
|
stash(`<pre><code>${escapeHtml(code)}</code></pre>`));
|
||||||
|
// Inline code
|
||||||
|
src = src.replace(/`([^`\n]+)`/g, (_m, c) => stash(`<code>${escapeHtml(c)}</code>`));
|
||||||
|
|
||||||
|
// Escape remaining HTML
|
||||||
|
src = escapeHtml(src);
|
||||||
|
|
||||||
|
// Restore stashed code first (before we mangle further)
|
||||||
|
src = src.replace(/\u0000(\d+)\u0000/g, (_m, i) => placeholders[Number(i)]);
|
||||||
|
|
||||||
|
// Images: 
|
||||||
|
src = src.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, url) =>
|
||||||
|
`<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}">`);
|
||||||
|
// Links: [text](url)
|
||||||
|
src = src.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) =>
|
||||||
|
`<a href="${escapeAttr(url)}" target="_blank" rel="noopener">${text}</a>`);
|
||||||
|
|
||||||
|
// Headings (## etc.), line-anchored
|
||||||
|
src = src.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
|
||||||
|
src = src.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
|
||||||
|
src = src.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// HR
|
||||||
|
src = src.replace(/^---+$/gm, '<hr>');
|
||||||
|
|
||||||
|
// Blockquotes: consecutive "> " lines collapse into one <blockquote>
|
||||||
|
src = src.replace(/(^|\n)((?:> [^\n]*\n?)+)/g, (_m, pre, block) => {
|
||||||
|
const inner = block.replace(/^> ?/gm, '').trim();
|
||||||
|
return `${pre}<blockquote>${inner.replace(/\n/g, '<br>')}</blockquote>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lists: grouped runs of "- " / "* " / "N." items
|
||||||
|
src = src.replace(/(^|\n)((?:[-*]\s+[^\n]+\n?)+)/g, (_m, pre, block) => {
|
||||||
|
const items = block.trim().split(/\n/).map(l => l.replace(/^[-*]\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
||||||
|
return `${pre}<ul>${items}</ul>\n`;
|
||||||
|
});
|
||||||
|
src = src.replace(/(^|\n)((?:\d+\.\s+[^\n]+\n?)+)/g, (_m, pre, block) => {
|
||||||
|
const items = block.trim().split(/\n/).map(l => l.replace(/^\d+\.\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
||||||
|
return `${pre}<ol>${items}</ol>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bold + italic (do bold first to avoid partial capture)
|
||||||
|
src = src.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
src = src.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Paragraphs: wrap any run of non-block-level lines. Block-level tags
|
||||||
|
// we've already emitted stay as-is.
|
||||||
|
const blocks = src.split(/\n{2,}/);
|
||||||
|
return blocks.map(b => {
|
||||||
|
const t = b.trim();
|
||||||
|
if (!t) return '';
|
||||||
|
if (/^<(h[1-6]|ul|ol|pre|blockquote|hr|img)/.test(t)) return t;
|
||||||
|
return `<p>${t.replace(/\n/g, '<br>')}</p>`;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return String(s ?? '').replace(/["&<>]/g, c => ({ '"': '"', '&': '&', '<': '<', '>': '>' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$('read-back')?.addEventListener('click', closeReader);
|
||||||
|
$('read-delete')?.addEventListener('click', async () => {
|
||||||
|
if (!readCurrentUid) return;
|
||||||
|
if (!await relayConfirm('Remove this item from your read list?', { okLabel: 'Remove', danger: true })) return;
|
||||||
|
const uid = readCurrentUid;
|
||||||
|
try {
|
||||||
|
await api('api/read/' + encodeURIComponent(uid), { method: 'DELETE' });
|
||||||
|
closeReader();
|
||||||
|
loadReadList();
|
||||||
|
toast('removed');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'failed', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browser back should close the reader first, then leave the view.
|
||||||
|
window.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && readCurrentUid) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeReader();
|
||||||
|
}
|
||||||
|
});
|
||||||
+59
-30
@@ -7,6 +7,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#1e293b">
|
<meta name="theme-color" content="#1e293b">
|
||||||
<title>hyprcrd relay</title>
|
<title>hyprcrd relay</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="stylesheet" href="fonts/phosphor.css">
|
<link rel="stylesheet" href="fonts/phosphor.css">
|
||||||
<link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
|
<link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
|
||||||
</head>
|
</head>
|
||||||
@@ -14,16 +15,20 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<i class="ph ph-broadcast brand-icon"></i>
|
<a onclick="showView('compose')" style="display:flex;align-items:center;gap:8px;text-decoration:none;cursor:pointer;">
|
||||||
<h1>hyprcrd relay</h1>
|
<i class="ph ph-broadcast brand-icon"></i>
|
||||||
|
<h1>hyprcrd relay</h1>
|
||||||
|
</a>
|
||||||
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
||||||
<div id="nav-buttons" class="hidden">
|
<div id="nav-buttons" class="hidden">
|
||||||
<span id="nav-user" class="user"></span>
|
<span id="nav-user" class="user"></span>
|
||||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||||
|
<button id="nav-inbox" class="nav-btn" onclick="showView('inbox')"><i class="ph ph-tray"></i> inbox</button>
|
||||||
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
||||||
<button id="nav-shopping" class="nav-btn" onclick="showView('shopping')"><i class="ph ph-shopping-cart"></i> shopping</button>
|
<button id="nav-shopping" class="nav-btn" onclick="showView('shopping')"><i class="ph ph-shopping-cart"></i> shopping</button>
|
||||||
|
<button id="nav-read" class="nav-btn" onclick="showView('read')"><i class="ph ph-book-open"></i> read</button>
|
||||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||||
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
||||||
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
||||||
@@ -43,7 +48,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>admin password</label>
|
<label>admin password</label>
|
||||||
<input type="password" id="setup-password" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="setup-password" placeholder="min 10 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>super-shared secret</label>
|
<label>super-shared secret</label>
|
||||||
@@ -58,15 +63,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div id="view-login" class="auth-form hidden">
|
<div id="view-login" class="auth-form hidden">
|
||||||
<i class="ph ph-key auth-icon"></i>
|
<i class="ph ph-key auth-icon"></i>
|
||||||
<h2>login</h2>
|
<h2>login</h2>
|
||||||
<div class="field">
|
<form id="login-form" autocomplete="on" onsubmit="event.preventDefault(); doLogin();">
|
||||||
<label>username</label>
|
<div class="field">
|
||||||
<input type="text" id="login-username" autocomplete="username">
|
<label for="login-username">username</label>
|
||||||
</div>
|
<input type="text" id="login-username" name="username" autocomplete="username">
|
||||||
<div class="field">
|
</div>
|
||||||
<label>password</label>
|
<div class="field">
|
||||||
<input type="password" id="login-password" autocomplete="current-password">
|
<label for="login-password">password</label>
|
||||||
</div>
|
<input type="password" id="login-password" name="password" autocomplete="current-password">
|
||||||
<button class="btn btn-primary" onclick="doLogin()"><i class="ph ph-sign-in"></i> unlock</button>
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="ph ph-sign-in"></i> unlock</button>
|
||||||
|
</form>
|
||||||
<div id="login-err" class="err"></div>
|
<div id="login-err" class="err"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,22 +113,24 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div id="compose-status" class="compose-status-bar"></div>
|
<div id="compose-status" class="compose-status-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="compose-sidebar" class="compose-sidebar">
|
</div>
|
||||||
<div id="inbox-section" class="section">
|
|
||||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
<!-- ── Inbox view (inbox + shared buckets + sent) ── -->
|
||||||
<span style="flex:1"></span>
|
<div id="view-inbox" class="hidden">
|
||||||
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
|
<div id="inbox-section" class="section">
|
||||||
</h2>
|
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||||
<div id="inbox-list"></div>
|
<span style="flex:1"></span>
|
||||||
</div>
|
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
|
||||||
<div id="shared-section" class="section">
|
</h2>
|
||||||
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
|
<div id="inbox-list"></div>
|
||||||
<div id="shared-list"></div>
|
</div>
|
||||||
</div>
|
<div id="shared-section" class="section">
|
||||||
<div id="outbox-section" class="section">
|
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
|
||||||
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
<div id="shared-list"></div>
|
||||||
<div id="outbox-list"></div>
|
</div>
|
||||||
</div>
|
<div id="outbox-section" class="section">
|
||||||
|
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
|
||||||
|
<div id="outbox-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +145,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>new password</label>
|
<label>new password</label>
|
||||||
<input type="password" id="cp-new" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="cp-new" placeholder="min 10 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>confirm new password</label>
|
<label>confirm new password</label>
|
||||||
@@ -197,7 +206,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-password">password</label>
|
<label for="new-password">password</label>
|
||||||
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="new-password" placeholder="min 10 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
||||||
@@ -226,6 +235,26 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Read view: list + zen reader ── -->
|
||||||
|
<div id="view-read" class="hidden">
|
||||||
|
<div id="read-list" class="read-wrap">
|
||||||
|
<div id="read-empty" class="read-empty hidden">
|
||||||
|
<i class="ph ph-book-open read-empty-icon"></i>
|
||||||
|
<p>No items yet. Toggle <strong>Read later</strong> on a card in hyprcrd to send it here.</p>
|
||||||
|
</div>
|
||||||
|
<ul id="read-items" class="read-items"></ul>
|
||||||
|
</div>
|
||||||
|
<!-- Zen reader overlay -->
|
||||||
|
<div id="read-reader" class="read-reader hidden">
|
||||||
|
<div class="read-reader-bar">
|
||||||
|
<button id="read-back" class="read-reader-btn" aria-label="back"><i class="ph ph-arrow-left"></i></button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="read-delete" class="read-reader-btn read-reader-btn-danger" aria-label="delete" title="delete from read list"><i class="ph ph-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<article id="read-article" class="read-article"></article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Chat view ── -->
|
<!-- ── Chat view ── -->
|
||||||
<div id="view-chat" class="hidden">
|
<div id="view-chat" class="hidden">
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
|
|||||||
+230
-42
@@ -232,13 +232,8 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
|||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: flex; flex-direction: column; gap: 16px;
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
}
|
}
|
||||||
/* Same 16 px rhythm between inbox / shared / sent on mobile as
|
#view-inbox:not(.hidden) {
|
||||||
between compose and inbox — desktop re-declares this inside the
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
grid rule further down. */
|
|
||||||
#compose-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
.compose-attachments {
|
.compose-attachments {
|
||||||
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
||||||
@@ -677,23 +672,28 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
display:flex !important;
|
display:flex !important;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
align-items:stretch;
|
align-items:stretch;
|
||||||
position:absolute;
|
position:fixed;
|
||||||
top:100%;
|
top: 48px;
|
||||||
right:12px;
|
left: 0;
|
||||||
margin-top:6px;
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0;
|
||||||
background:var(--surface);
|
background:var(--surface);
|
||||||
border:1px solid var(--border);
|
border-top:1px solid var(--border);
|
||||||
border-radius:var(--radius-lg);
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow-popover);
|
padding: 12px 16px;
|
||||||
padding:6px;
|
gap: 6px;
|
||||||
gap:4px;
|
z-index:100;
|
||||||
min-width:180px;
|
|
||||||
z-index:50;
|
|
||||||
}
|
}
|
||||||
#nav-buttons.menu-open .nav-btn {
|
#nav-buttons.menu-open .nav-btn {
|
||||||
text-align:left;
|
text-align:left;
|
||||||
width:100%;
|
width:100%;
|
||||||
padding: 8px 12px;
|
padding: 14px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
#nav-buttons.menu-open .nav-btn .ph {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#view-admin .section {
|
#view-admin .section {
|
||||||
@@ -757,18 +757,11 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compose view: 2 columns. Left is the compose editor. Right is a
|
/* Compose view: single column, centered. Inbox/shared/sent are in
|
||||||
sidebar with inbox → shared → sent stacked vertically. */
|
their own view now — no sidebar to fill the right-hand space. */
|
||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: grid !important;
|
max-width: 720px;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
margin: 0 auto;
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
#compose-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards: depth via shadow + larger padding */
|
/* Cards: depth via shadow + larger padding */
|
||||||
@@ -810,18 +803,6 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inbox card sticks under the header */
|
|
||||||
#inbox-section {
|
|
||||||
position: sticky;
|
|
||||||
top: 24px;
|
|
||||||
transition: box-shadow .15s;
|
|
||||||
}
|
|
||||||
#inbox-section:hover {
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0,0,0,0.3),
|
|
||||||
0 12px 32px -8px rgba(0,0,0,0.5),
|
|
||||||
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin: 2-column grid */
|
/* Admin: 2-column grid */
|
||||||
#view-admin:not(.hidden) .section {
|
#view-admin:not(.hidden) .section {
|
||||||
@@ -858,3 +839,210 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
||||||
.auth-form .auth-icon { font-size: 44px; }
|
.auth-form .auth-icon { font-size: 44px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
|
* Read view — list + distraction-free zen reader. Mobile-first; the
|
||||||
|
* article width is capped tight for comfortable line-lengths and the
|
||||||
|
* type scale is larger than anywhere else in the UI because this is
|
||||||
|
* meant for actual reading, not scanning.
|
||||||
|
* ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.read-wrap {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.read-empty-icon { font-size: 48px; display: block; margin: 0 auto 16px; color: var(--muted); }
|
||||||
|
|
||||||
|
.read-items {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.read-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.read-item:hover { border-color: var(--accent); background: var(--raised); }
|
||||||
|
.read-item-thumb {
|
||||||
|
width: 64px; height: 64px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg) center/cover no-repeat;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.read-item-thumb-placeholder {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.read-item-body { flex: 1; min-width: 0; }
|
||||||
|
.read-item-title {
|
||||||
|
color: var(--bright);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.read-item-meta {
|
||||||
|
font-family: var(--pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reader overlay: covers the main content area; header stays visible
|
||||||
|
* so the user can navigate back via the burger / nav buttons. */
|
||||||
|
.read-reader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 56px 0 0 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 80;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.read-reader-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.read-reader-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.read-reader-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.read-reader-btn-danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
|
.read-article {
|
||||||
|
max-width: 680px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 96px;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.read-article .read-hero {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 360px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.read-article h1.read-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bright);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.read-article .read-date {
|
||||||
|
font-family: var(--pixel);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.read-article h1, .read-article h2, .read-article h3 {
|
||||||
|
color: var(--bright);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.read-article h2 { font-size: 20px; }
|
||||||
|
.read-article h3 { font-size: 17px; }
|
||||||
|
.read-article p { margin: 0 0 16px; }
|
||||||
|
.read-article a { color: var(--accent); text-decoration: underline; }
|
||||||
|
.read-article ul, .read-article ol { padding-left: 24px; margin: 0 0 16px; }
|
||||||
|
.read-article li { margin-bottom: 6px; }
|
||||||
|
.read-article code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.read-article pre {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.read-article pre code { background: none; padding: 0; }
|
||||||
|
.read-article blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.read-article img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.read-article hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 28px 0;
|
||||||
|
}
|
||||||
|
.read-article table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.read-article th, .read-article td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: pad tighter, bump touch targets */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.read-article { font-size: 16px; padding: 20px 16px 96px; }
|
||||||
|
.read-article h1.read-title { font-size: 22px; }
|
||||||
|
.read-reader { inset: 52px 0 0 0; }
|
||||||
|
.read-reader-btn { width: 40px; height: 40px; font-size: 18px; }
|
||||||
|
.read-item-thumb { width: 52px; height: 52px; }
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class CalDavClient
|
|||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
]);
|
]);
|
||||||
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
if ($body !== '') {
|
if ($body !== '') {
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class Database
|
|||||||
|
|
||||||
self::$pdo->exec('PRAGMA journal_mode = WAL');
|
self::$pdo->exec('PRAGMA journal_mode = WAL');
|
||||||
self::$pdo->exec('PRAGMA foreign_keys = ON');
|
self::$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
// Safe speedups under WAL. synchronous=NORMAL trades a tiny
|
||||||
|
// durability window on power-loss (WAL checkpoint hasn't
|
||||||
|
// flushed) for noticeably quicker write-heavy flows — fine for
|
||||||
|
// a single-user local-first app. temp_store=MEMORY keeps
|
||||||
|
// transient sort / group scratch in RAM instead of touching
|
||||||
|
// disk for every ORDER BY.
|
||||||
|
self::$pdo->exec('PRAGMA synchronous = NORMAL');
|
||||||
|
self::$pdo->exec('PRAGMA temp_store = MEMORY');
|
||||||
|
|
||||||
self::migrate();
|
self::migrate();
|
||||||
}
|
}
|
||||||
@@ -124,6 +132,13 @@ class Database
|
|||||||
if (!in_array('relay_uid', $colNames)) {
|
if (!in_array('relay_uid', $colNames)) {
|
||||||
self::$pdo->exec('ALTER TABLE cards ADD COLUMN relay_uid TEXT DEFAULT NULL');
|
self::$pdo->exec('ALTER TABLE cards ADD COLUMN relay_uid TEXT DEFAULT NULL');
|
||||||
}
|
}
|
||||||
|
// Read-later push: when 1, saves propagate the card to the relay's
|
||||||
|
// /api/read endpoint so the user can read it in the mobile-friendly
|
||||||
|
// zen reader. Explicitly NOT tag-based (so reading-mode is
|
||||||
|
// independent of whatever tags the user already organises by).
|
||||||
|
if (!in_array('read_push', $colNames)) {
|
||||||
|
self::$pdo->exec('ALTER TABLE cards ADD COLUMN read_push INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
// Relay pushed message IDs (skip on own poll)
|
// Relay pushed message IDs (skip on own poll)
|
||||||
self::$pdo->exec('
|
self::$pdo->exec('
|
||||||
@@ -169,5 +184,29 @@ class Database
|
|||||||
)
|
)
|
||||||
');
|
');
|
||||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
|
||||||
|
|
||||||
|
// Login / password-change rate limiting. Keyed by IP so rotating the
|
||||||
|
// session cookie doesn't reset the counter. Old rows are purged at
|
||||||
|
// the top of every login request.
|
||||||
|
self::$pdo->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_ep ON login_attempts(ip, endpoint, attempted_at)');
|
||||||
|
|
||||||
|
// Hot-path indices for the board. /api/cards is called on every
|
||||||
|
// reload and runs `SELECT … FROM cards LEFT JOIN buckets ORDER BY
|
||||||
|
// position, created_at`. Without an index on position this is a
|
||||||
|
// full-table sort on every call — fine at 10 cards, noticeable at
|
||||||
|
// 300+.
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(position, created_at)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_bucket_id ON cards(bucket_id)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_parent_id ON cards(parent_id)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_updated_at ON cards(updated_at DESC)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_caldav_uid ON cards(caldav_uid) WHERE caldav_uid IS NOT NULL');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_relay_uid ON cards(relay_uid) WHERE relay_uid IS NOT NULL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,3 +23,113 @@ $pluginManager = PluginManager::instance();
|
|||||||
$pluginManager->scan(__DIR__ . '/../public/plugins');
|
$pluginManager->scan(__DIR__ . '/../public/plugins');
|
||||||
$pluginManager->loadDisabled(dirname(DB_PATH) . '/plugins.json');
|
$pluginManager->loadDisabled(dirname(DB_PATH) . '/plugins.json');
|
||||||
$pluginManager->loadBackends(); // require_once enabled backend.php files
|
$pluginManager->loadBackends(); // require_once enabled backend.php files
|
||||||
|
|
||||||
|
// ── SSRF guards ────────────────────────────────────────────────────
|
||||||
|
// Used by every outbound cURL that fetches a user-supplied URL.
|
||||||
|
// Default: restrict protocols to HTTP(S); caller opts into private-IP
|
||||||
|
// blocking when the target should be public-internet-only (bookmarks).
|
||||||
|
|
||||||
|
if (!defined('HYPRCRD_SAFE_PROTOS')) {
|
||||||
|
define('HYPRCRD_SAFE_PROTOS', CURLPROTO_HTTP | CURLPROTO_HTTPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hyprcrdApplySafeCurlOpts($ch): void {
|
||||||
|
curl_setopt($ch, CURLOPT_PROTOCOLS, HYPRCRD_SAFE_PROTOS);
|
||||||
|
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, HYPRCRD_SAFE_PROTOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Relay wire crypto ──────────────────────────────────────────────
|
||||||
|
// Dual-format envelope used by the relay push/poll path. Mirrors
|
||||||
|
// relay/inc/crypto.php exactly — same magic strings, same HKDF info.
|
||||||
|
//
|
||||||
|
// HYPRCRD-GCM-V2 (new) AES-256-GCM + HKDF(SHA-256) + per-cipher salt
|
||||||
|
// HYPRCRD-RELAY-V1 (legacy) AES-256-CBC + substr(hex(sha256())) KDF
|
||||||
|
//
|
||||||
|
// Encrypt always writes V2. Decrypt accepts either so hyprcrd keeps
|
||||||
|
// reading messages and attachments pushed before the v1.10 upgrade.
|
||||||
|
|
||||||
|
const HYPRCRD_RELAY_GCM_MAGIC = 'HYPRCRD-GCM-V2';
|
||||||
|
const HYPRCRD_RELAY_CBC_MAGIC = 'HYPRCRD-RELAY-V1';
|
||||||
|
const HYPRCRD_RELAY_HKDF_INFO = 'hyprcrd-relay-client-v2';
|
||||||
|
|
||||||
|
function hyprcrdRelayEncrypt(string $plain, string $secret): string {
|
||||||
|
$salt = random_bytes(16);
|
||||||
|
$nonce = random_bytes(12);
|
||||||
|
$key = hash_hkdf('sha256', $secret, 32, HYPRCRD_RELAY_HKDF_INFO, $salt);
|
||||||
|
$tag = '';
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||||
|
return HYPRCRD_RELAY_GCM_MAGIC . "\n" . base64_encode($salt . $nonce . $cipher . $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hyprcrdRelayDecrypt(string $envelope, string $secret): string {
|
||||||
|
if (str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC)) {
|
||||||
|
$b64 = trim(substr($envelope, strlen(HYPRCRD_RELAY_GCM_MAGIC) + 1));
|
||||||
|
$raw = base64_decode($b64, true);
|
||||||
|
if (!$raw || strlen($raw) < 16 + 12 + 16 + 1) return '';
|
||||||
|
$salt = substr($raw, 0, 16);
|
||||||
|
$nonce = substr($raw, 16, 12);
|
||||||
|
$tag = substr($raw, -16);
|
||||||
|
$cipher = substr($raw, 28, -16);
|
||||||
|
$key = hash_hkdf('sha256', $secret, 32, HYPRCRD_RELAY_HKDF_INFO, $salt);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||||
|
return $plain !== false ? $plain : '';
|
||||||
|
}
|
||||||
|
if (str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||||
|
$b64 = trim(substr($envelope, strlen(HYPRCRD_RELAY_CBC_MAGIC) + 1));
|
||||||
|
$raw = base64_decode($b64, true);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
$key = substr(hash('sha256', $secret), 0, 32);
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
$result = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return $result !== false ? $result : '';
|
||||||
|
}
|
||||||
|
// Passthrough for already-plaintext fields (legacy relays occasionally
|
||||||
|
// returned title fields unwrapped).
|
||||||
|
return $envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalise the HTTP response (headers + body already echoed) and let
|
||||||
|
* PHP continue working in the background without blocking the client.
|
||||||
|
* Under php-fpm this uses fastcgi_finish_request. Under mod_php / the
|
||||||
|
* built-in dev server we fall back to ignore_user_abort + flush — the
|
||||||
|
* client may still see the connection hold open, but at least it
|
||||||
|
* doesn't error on disconnect and the response is already buffered.
|
||||||
|
*/
|
||||||
|
function hyprcrdFinishRequest(): void {
|
||||||
|
if (function_exists('fastcgi_finish_request')) {
|
||||||
|
fastcgi_finish_request();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
if (ob_get_level() > 0) { @ob_end_flush(); }
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hyprcrdUrlHostIsPublic(string $url): bool {
|
||||||
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
|
if (!$host) return false;
|
||||||
|
$host = strtolower(trim($host, '[]'));
|
||||||
|
$ips = [];
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||||
|
$ips[] = $host;
|
||||||
|
} else {
|
||||||
|
$rec = @dns_get_record($host, DNS_A + DNS_AAAA);
|
||||||
|
foreach ($rec ?: [] as $r) {
|
||||||
|
if (!empty($r['ip'])) $ips[] = $r['ip'];
|
||||||
|
if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
|
||||||
|
}
|
||||||
|
if (!$ips) {
|
||||||
|
$resolved = @gethostbynamel($host);
|
||||||
|
if ($resolved) $ips = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$ips) return false;
|
||||||
|
foreach ($ips as $ip) {
|
||||||
|
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+29
-53
@@ -1,67 +1,43 @@
|
|||||||
# Website TODO
|
# Website TODO
|
||||||
|
|
||||||
Audit (app 1.9.17 vs. current site 1.3.3) found the site is ~14 releases
|
Updated 2026-04-17, app at 2.0.0.
|
||||||
behind on content and missing entire feature categories. Items grouped
|
|
||||||
by priority so it's obvious what's must-have vs. polish.
|
|
||||||
|
|
||||||
## P0 — critical freshness gap
|
## P0 — done
|
||||||
|
|
||||||
- [ ] **Bump footer version** to the actual app version (pull from
|
- [x] **Bump footer version** to 2.0.0
|
||||||
`config.php` APP_VERSION or `electron/package.json`). Today shows
|
- [x] **Favicon** added (favicon.svg linked in head)
|
||||||
`v1.3.3`, app is `1.9.17`.
|
- [x] **New section: Calendar + CalDAV** — month grid visual, journal/termin/geburtstag, CalDAV sync
|
||||||
- [ ] **Refresh hero screenshot** (`screenshot.png`). Probably 1+ year
|
- [x] **Relay section rewritten** — group chat, persistent history, shopping lists, inbox view, admin last-seen, disposable threads
|
||||||
stale; current board has new bucket bar with fade edges, loading
|
- [x] **New section: Bookmarks** — column-based bookmark manager, favicon fetch, card-links, DnD
|
||||||
spinner, peer strip in chat, etc.
|
- [x] **New section: Shopping** — phone-synced checklists, multi-list, headings, relay UI integration
|
||||||
- [ ] **New section: Calendar + CalDAV.** Completely absent today.
|
- [x] **Deploy plugins list expanded** — bookmarks, shopping, TIM, terminal added
|
||||||
Cover: month grid, journal entries (`YYYY-MM-DD` titled cards),
|
|
||||||
appointments via `#termin` template (Zeit/Bis/Ort/Teilnehmer),
|
## P0 — still open
|
||||||
bidirectional ownCloud/Nextcloud sync, day-popup quick-add. This
|
|
||||||
is a big differentiator vs. Obsidian/Notion and currently hidden.
|
- [ ] **Refresh hero screenshot** (`screenshot.png`). Board has new bucket bar with fade edges, loading spinner, shopping/bookmarks buttons, etc.
|
||||||
- [ ] **Rewrite Relay section.** Today only mentions 1:1 chat + collab
|
|
||||||
buckets. Add: group chat (named rooms, member mgmt, persistent
|
|
||||||
history, backfill on new device), disposable vs. persistent 1:1
|
|
||||||
threads, shared-bucket inbox in relay UI, sync-event toasts.
|
|
||||||
- [ ] **Flesh out bundled plugins.** Currently lists names only. Add
|
|
||||||
a short paragraph + tiny screenshot each for: TIM (time calc),
|
|
||||||
Terminal, Slideshow, Logseq-Import, Canvas (ASCII art).
|
|
||||||
|
|
||||||
## P1 — substance
|
## P1 — substance
|
||||||
|
|
||||||
- [ ] **Screenshots gallery section.** Only one screenshot (hero) exists.
|
- [ ] **Screenshots gallery section.** Shoot + embed: calendar month view, day-popup, chat flyout with peer strip, group chat + member list, detail split view, superview, gallery plugin, Gitea issue browser, bookmarks overlay, shopping phone view. Thumbnails with click-to-zoom.
|
||||||
Shoot + embed: calendar month view, day-popup with templates,
|
|
||||||
chat flyout with peer strip, group chat + member list, detail
|
|
||||||
split view, superview, gallery plugin, Gitea issue browser.
|
|
||||||
Thumbnails with click-to-zoom.
|
|
||||||
- [ ] **"Daily use" section** with three concrete workflows:
|
- [ ] **"Daily use" section** with three concrete workflows:
|
||||||
1. Journaling + calendar sync (date cards → ownCloud)
|
1. Journaling + calendar sync (date cards -> ownCloud)
|
||||||
2. Team collab (shared bucket + group chat + shared-bucket inbox)
|
2. Team collab (shared bucket + group chat)
|
||||||
3. Gitea ticket-to-card flow
|
3. Shopping list: write on desktop, check off on phone
|
||||||
- [ ] **CTA buttons point to real landing targets**, not raw
|
- [ ] **CTA buttons point to real landing targets**, not raw README.md. Minimum: a /install.html with Docker, Electron and PHP-hosting tabs.
|
||||||
`README.md`. Minimum: a `/install.html` with Docker, Electron
|
- [ ] **Mention Superview + Split View + Find-and-Replace + Saved Searches** explicitly. Same for Undo/Redo and editor draft auto-backup.
|
||||||
and PHP-hosting tabs.
|
|
||||||
- [ ] **Mention Superview + Split View + Find-and-Replace** explicitly.
|
|
||||||
Today buried or absent. Same for Undo/Redo across all mutations
|
|
||||||
and the editor draft auto-backup.
|
|
||||||
|
|
||||||
## P2 — nice-to-have
|
## P2 — nice-to-have
|
||||||
|
|
||||||
- [ ] Animated hero demo (GIF or ≤30s mp4): create card, tag-click,
|
- [ ] Animated hero demo (GIF or <=30s mp4)
|
||||||
detail open, calendar jump. No voiceover, just the keystrokes.
|
- [ ] Keyboard shortcut reference expanded (app has ~40+; site shows ~20)
|
||||||
- [ ] Keyboard shortcut reference expanded (app has ~40+; site shows
|
- [ ] Theming preview: 3-4 design-token presets side by side
|
||||||
~20). Maybe collapsible per-category.
|
- [ ] Visible changelog / "recently added" box
|
||||||
- [ ] Theming preview: 3–4 design-token presets side by side so the
|
- [ ] Comparison table vs. Obsidian / Notion / Joplin
|
||||||
"fully themed" claim has a picture behind it.
|
- [ ] Vault-key explainer
|
||||||
- [ ] Visible changelog / "recently added" box so the site doesn't
|
|
||||||
drift 14 releases behind again.
|
|
||||||
- [ ] Comparison table vs. Obsidian / Notion / Joplin — privacy +
|
|
||||||
self-hosted + collab + calendar all-in-one is the pitch.
|
|
||||||
- [ ] Vault-key explainer (multi-instance CalDAV sharing, separate
|
|
||||||
from login password). Niche but unique to hyprcrd.
|
|
||||||
|
|
||||||
## Not doing (deliberate)
|
## Not doing (deliberate)
|
||||||
|
|
||||||
- Pricing page — open-source/MIT, no revenue model to surface.
|
- Pricing page — open-source/MIT
|
||||||
- User testimonials / case studies — no real users to quote.
|
- User testimonials — no real users to quote
|
||||||
- Community links (Discord/Matrix) — none exist yet.
|
- Community links (Discord/Matrix) — none exist yet
|
||||||
- Mobile app shots — there isn't a mobile app, only mobile-friendly
|
- Mobile app shots — no mobile app, only mobile-friendly relay UI
|
||||||
relay UI.
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+127
-15
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>hyprcrd</title>
|
<title>hyprcrd — stop renting your memory</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
|
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
|
||||||
@@ -351,10 +352,10 @@ footer {
|
|||||||
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#ops</span>
|
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#ops</span>
|
||||||
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#done</span>
|
<span class="vtag" style="background:rgba(52,211,153,0.06);color:var(--gn);border-color:rgba(52,211,153,0.2);">#done</span>
|
||||||
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#meeting</span>
|
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#meeting</span>
|
||||||
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#termin</span>
|
<span class="vtag" style="background:rgba(251,191,36,0.06);color:var(--yl);border-color:rgba(251,191,36,0.2);">#appointment</span>
|
||||||
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#research</span>
|
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#research</span>
|
||||||
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#idea</span>
|
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#idea</span>
|
||||||
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#geburtstag</span>
|
<span class="vtag" style="background:rgba(244,114,182,0.06);color:var(--pk);border-color:rgba(244,114,182,0.2);">#birthday</span>
|
||||||
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#personal</span>
|
<span class="vtag" style="background:rgba(56,189,248,0.06);color:var(--ac);border-color:rgba(56,189,248,0.2);">#personal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,6 +399,49 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="calendar">
|
||||||
|
<div class="section-label">CALENDAR</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Cards become events.<br>Events stay synced.</h3>
|
||||||
|
<p>A month-grid calendar built into hyprcrd. Journal entries, appointments, and birthdays — all just cards with the right title or tag. Bidirectional CalDAV sync with ownCloud and Nextcloud.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Journal entries: title = <code>YYYY-MM-DD</code>, one per day, no tag needed</li>
|
||||||
|
<li>Appointments: <code>#appointment</code> tag + structured template (time, end, location, attendees)</li>
|
||||||
|
<li>Birthdays: <code>#birthday</code> tag, recurring annually</li>
|
||||||
|
<li>Click any day to create a journal entry, appointment, or birthday</li>
|
||||||
|
<li>CalDAV sync: bidirectional push/pull with ownCloud, Nextcloud, or any CalDAV server</li>
|
||||||
|
<li>Per-calendar color + icon configuration</li>
|
||||||
|
<li>Upcoming-event notifications with configurable lookahead</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div style="font-family:var(--mono);font-size:11px;color:var(--tx);line-height:2;">
|
||||||
|
<div style="text-align:center;margin-bottom:12px;"><span style="color:var(--ac);font-family:var(--pixel);font-size:10px;">APRIL 2026</span></div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;text-align:center;">
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">Mo</span>
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">Tu</span>
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">We</span>
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">Th</span>
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">Fr</span>
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">Sa</span>
|
||||||
|
<span style="color:var(--hi);font-family:var(--pixel);font-size:9px;">Su</span>
|
||||||
|
<span style="opacity:0.3">30</span><span style="opacity:0.3">31</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
||||||
|
<span>6</span><span>7</span><span>8</span><span>9</span><span>10</span><span>11</span><span>12</span>
|
||||||
|
<span>13</span><span>14</span><span style="color:var(--ac);border:1px solid var(--ac);border-radius:3px;">15</span><span>16</span><span style="color:var(--yl);">17</span><span>18</span><span>19</span>
|
||||||
|
<span>20</span><span>21</span><span>22</span><span style="color:var(--pk);">23</span><span>24</span><span>25</span><span>26</span>
|
||||||
|
<span>27</span><span>28</span><span>29</span><span>30</span><span style="opacity:0.3">1</span><span style="opacity:0.3">2</span><span style="opacity:0.3">3</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;font-size:10px;display:flex;gap:12px;justify-content:center;">
|
||||||
|
<span><span style="color:var(--ac);">•</span> journal</span>
|
||||||
|
<span><span style="color:var(--yl);">•</span> appointment</span>
|
||||||
|
<span><span style="color:var(--pk);">•</span> birthday</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section" id="gitea">
|
<section class="section" id="gitea">
|
||||||
<div class="section-label"><span class="accent">GITEA</span></div>
|
<div class="section-label"><span class="accent">GITEA</span></div>
|
||||||
<div class="feat reverse">
|
<div class="feat reverse">
|
||||||
@@ -476,14 +520,16 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
<div class="feat">
|
<div class="feat">
|
||||||
<div class="feat-text">
|
<div class="feat-text">
|
||||||
<h3>Sync between devices.<br>Share with people.</h3>
|
<h3>Sync between devices.<br>Share with people.</h3>
|
||||||
<p>A self-hosted message relay that connects multiple hyprcrd instances. Per-user encryption. Collaborative buckets with exclusive card locking. Encrypted chat with read receipts.</p>
|
<p>A self-hosted message relay that connects multiple hyprcrd instances. Per-user encryption. Collaborative buckets. Group chat with persistent history. Shopping lists on your phone.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Multi-tenant: each user gets their own login + encryption secret</li>
|
<li>Multi-tenant: each user gets their own login + encryption secret</li>
|
||||||
<li>Cards sync between devices — push from one, poll from another</li>
|
<li>Cards sync between devices — push from one, poll from another</li>
|
||||||
<li>Collaborative buckets: shared editing with exclusive locks</li>
|
<li>Collaborative buckets: shared editing with exclusive card locking</li>
|
||||||
<li>Chat: encrypted messages with delivery + read receipts</li>
|
<li>Group chat: named rooms, member management, persistent history that backfills on new devices</li>
|
||||||
<li>Compose + inbox UI works on mobile</li>
|
<li>1:1 chat with delivery + read receipts, disposable or persistent threads</li>
|
||||||
<li>24h ephemeral — the relay routes, it doesn't archive</li>
|
<li>Shopping lists: check off items on your phone, changes sync back to hyprcrd</li>
|
||||||
|
<li>Dedicated inbox view with inbox, shared buckets, and sent messages</li>
|
||||||
|
<li>Admin dashboard: user management, last-seen tracking</li>
|
||||||
<li>AES-256-CBC at every hop, bcrypt passwords, master key at rest</li>
|
<li>AES-256-CBC at every hop, bcrypt passwords, master key at rest</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,11 +545,11 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
<div style="margin-bottom:20px;padding-left:24px;border-left:2px solid var(--bdr);">
|
<div style="margin-bottom:20px;padding-left:24px;border-left:2px solid var(--bdr);">
|
||||||
<span style="font-size:11px;color:var(--tx);">
|
<span style="font-size:11px;color:var(--tx);">
|
||||||
1. authenticate sender<br>
|
1. authenticate sender<br>
|
||||||
2. decrypt with alice's secret<br>
|
2. decrypt with sender's secret<br>
|
||||||
3. re-encrypt with inter-tenant key<br>
|
3. re-encrypt with inter-tenant key<br>
|
||||||
4. store (24h TTL)<br>
|
4. store (persistent or ephemeral)<br>
|
||||||
5. on bob's poll: decrypt<br>
|
5. on recipient's poll: decrypt<br>
|
||||||
6. re-encrypt with bob's secret<br>
|
6. re-encrypt with recipient's secret<br>
|
||||||
7. deliver
|
7. deliver
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,6 +565,72 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="bookmarks">
|
||||||
|
<div class="section-label">BOOKMARKS</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Column-based bookmarks.<br>Stored as cards.</h3>
|
||||||
|
<p>A bookmark list is a card. Organise links in named columns, drag to reorder, paste a URL and the title + favicon are fetched automatically. Link to other cards for cross-referencing.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Tag any card with <code>#bookmarks</code> to turn it into a bookmark list</li>
|
||||||
|
<li>Named columns: create, rename, delete, drag items between them</li>
|
||||||
|
<li>Paste a URL from clipboard: title + favicon fetched server-side and stored as data: URI</li>
|
||||||
|
<li>Full-width column-width slider per list</li>
|
||||||
|
<li>Card links: <code>card:123</code> opens the linked card or switches to another bookmark list</li>
|
||||||
|
<li>Multiple lists with different names, colors, and bucket assignments</li>
|
||||||
|
<li>Board tiles show card color + link icon, no body preview</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div style="display:flex;gap:12px;font-family:var(--mono);font-size:11px;">
|
||||||
|
<div style="flex:1;border:1px solid var(--bdr);border-radius:6px;padding:10px;">
|
||||||
|
<div style="font-family:var(--pixel);font-size:9px;color:var(--ac);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--bdr);">Dev</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--s2);border:1px solid var(--bdr);border-radius:4px;"><span style="color:var(--ac);">●</span><span style="color:var(--hi);">GitHub</span></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--s2);border:1px solid var(--bdr);border-radius:4px;"><span style="color:var(--ac);">●</span><span style="color:var(--hi);">MDN</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;border:1px solid var(--bdr);border-radius:6px;padding:10px;">
|
||||||
|
<div style="font-family:var(--pixel);font-size:9px;color:var(--ac);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--bdr);">News</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--s2);border:1px solid var(--bdr);border-radius:4px;"><span style="color:var(--yl);">●</span><span style="color:var(--hi);">HN</span></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--s2);border:1px solid var(--bdr);border-radius:4px;"><span style="color:var(--yl);">●</span><span style="color:var(--hi);">Lobsters</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="shopping">
|
||||||
|
<div class="section-label">SHOPPING</div>
|
||||||
|
<div class="feat">
|
||||||
|
<div class="feat-text">
|
||||||
|
<h3>Write the list on desktop.<br>Check it off on your phone.</h3>
|
||||||
|
<p>Tag a card with <code>#einkauf</code> or <code>#shopping</code>. Activate it via the Shopping button. The card's checkbox items sync to the relay and appear as a phone-friendly checklist. Tap to check off, changes sync back.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Multiple shopping lists: activate any combination, all show on the phone</li>
|
||||||
|
<li>Drag-and-drop ordering in the picker, order syncs to the phone</li>
|
||||||
|
<li>Markdown headings (<code># Produce</code>) group items into sections</li>
|
||||||
|
<li>Phone only toggles checkboxes — text editing stays in hyprcrd</li>
|
||||||
|
<li>4-second poll on phone, instant push from hyprcrd on every edit</li>
|
||||||
|
<li>Works inside the relay UI: same login, same session, no separate app</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="feat-visual">
|
||||||
|
<div style="max-width:240px;margin:0 auto;font-family:var(--mono);font-size:13px;">
|
||||||
|
<div style="font-family:var(--pixel);font-size:10px;color:var(--ac);text-align:center;margin-bottom:12px;">Shopping</div>
|
||||||
|
<div style="font-weight:600;font-size:12px;color:var(--hi);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--bdr);">Baumarkt</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<div style="padding:10px 12px;background:var(--s2);border:1px solid var(--bdr);border-radius:8px;color:var(--hi);">Bretter</div>
|
||||||
|
<div style="padding:10px 12px;background:var(--s2);border:1px solid var(--bdr);border-radius:8px;color:var(--hi);">Kleber</div>
|
||||||
|
<div style="padding:10px 12px;background:var(--s2);border:1px solid var(--bdr);border-radius:8px;color:var(--hi);opacity:0.35;text-decoration:line-through;">Schrauben</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section" id="email">
|
<section class="section" id="email">
|
||||||
<div class="section-label">EMAIL</div>
|
<div class="section-label">EMAIL</div>
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
@@ -634,7 +746,7 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
<li>One SQLite file — back up by copying <code>data/hyprcrd.db</code></li>
|
<li>One SQLite file — back up by copying <code>data/hyprcrd.db</code></li>
|
||||||
<li>Export as JSON+media ZIP or Obsidian/Logseq markdown vault</li>
|
<li>Export as JSON+media ZIP or Obsidian/Logseq markdown vault</li>
|
||||||
<li>Plugin system: drop a folder, reload, feature appears</li>
|
<li>Plugin system: drop a folder, reload, feature appears</li>
|
||||||
<li>Bundled: chat, email, Gitea, canvas, slideshows, Logseq import, watch</li>
|
<li>Bundled: chat, email, Gitea, bookmarks, shopping lists, canvas, TIM (time calc), terminal, slideshows, Logseq import, watch</li>
|
||||||
<li>Fully themed: design tokens, presets, build your own</li>
|
<li>Fully themed: design tokens, presets, build your own</li>
|
||||||
<li>MIT licensed</li>
|
<li>MIT licensed</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -664,7 +776,7 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
|
|
||||||
<section class="cta-section">
|
<section class="cta-section">
|
||||||
<div>
|
<div>
|
||||||
<h2>your brain, self-hosted.</h2>
|
<h2>stop renting your memory.</h2>
|
||||||
<p>Set a password. Start writing. Find everything.</p>
|
<p>Set a password. Start writing. Find everything.</p>
|
||||||
<div class="cta-links">
|
<div class="cta-links">
|
||||||
<a href="https://git.uesome.de/tanztee/hyprcrd" class="cta-btn primary">get the source</a>
|
<a href="https://git.uesome.de/tanztee/hyprcrd" class="cta-btn primary">get the source</a>
|
||||||
@@ -677,7 +789,7 @@ See also: <span class="link">[[Infra checklist]]</span>
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>hyprcrd · MIT license · v1.3.3</footer>
|
<footer>hyprcrd · MIT license · v2.0.0</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
Reference in new issue
Block a user