multi-tenant relay und card-push to user

This commit is contained in:
th committed 2026-04-10 18:23:07 +02:00
1 parent 7219bae655
commit 5b18c46ac2
22 files changed
+7454 -520

No files matched your search

+74 -33
View File
@@ -342,30 +342,37 @@ When running multiple hyprcrd instances that share CalDAV credentials, you need
## Relay
A minimal web service for mobile note capture and card sharing between hyprcrd instances.
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
share one relay instance — each one has their own login, their own
client encryption secret, and their own inbox. Cards (and in the future:
encrypted chat messages) can be sent **between tenants** on the same
relay.
**Full documentation:** [`relay/README.md`](relay/README.md)
### Architecture
```
[Phone/Browser] → https://relay.example.com → [Relay Service]
↓ poll
[hyprcrd (local)]
[hyprcrd alice] ──push──┐ ┌──poll── [hyprcrd alice]
├──► [Relay] ◄────────────┤
[hyprcrd bob] ──push──┘ tenants: alice, bob └──poll── [hyprcrd bob]
[hyprcrd A] → push card → [Relay] ← poll ← [hyprcrd B]
sender encrypts → relay decrypts with sender's secret →
re-encrypts with shared inter-tenant key → stores →
on recipient poll: decrypts → re-encrypts with recipient's secret
```
### Deploy to Webspace
### Deploy
**Webspace** (any PHP host with `mod_rewrite` and `pdo_sqlite`):
```bash
cd relay
./dist.sh
# upload hyprcrd-relay.zip and extract into your webspace
```
Upload the resulting `hyprcrd-relay.zip` to your webspace and extract it. The SQLite database is created automatically on first request. Open the URL and set a password + shared secret.
Requirements: PHP 8.3+ with `pdo_sqlite`, Apache with `mod_rewrite`.
### Deploy with Docker
**Docker**:
```bash
cd relay
@@ -373,42 +380,76 @@ docker build -t hyprcrd-relay .
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
```
The `data/` directory is created on first request and contains the
SQLite DB plus the master encryption key (chmod 600, blocked from web
access via `.htaccess`).
### Development (both together)
```bash
./dev.sh
```
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding.
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
### Setup workflow
### Setup
1. Open the relay URL **from a desktop browser** — the setup wizard
is desktop-friendly
2. Create the first **admin** account: username + password (UI login)
+ shared secret (client encryption)
3. As admin, click the **users** tab and create accounts for any
additional users you want to give relay access to
4. Each user enters their credentials in their own hyprcrd:
**Settings → Relay** → relay URL + username + shared secret
5. To send a card from one tenant to another, use the relay UI's
compose form (recipient picker) — or push cards from hyprcrd
1. Open the relay URL, set a **password** (for UI login) and a **shared secret** (for encryption)
2. In hyprcrd: **Settings > Relay** — enter the relay URL and the same shared secret
3. Click **save**
### How it works (high level)
### How it works
- **Mobile inbox**: type a note on the relay UI, it arrives as a card with `#inbox` tag
- **Push to relay**: right-click any card → "push to relay" — other instances pick it up on next poll
- Messages are encrypted client-side (AES-256-CBC) before sending to the relay
- The relay only stores ciphertext — it cannot read your messages
- hyprcrd polls the relay every 45 seconds in the background
- Messages are kept on the relay for 24 hours, then auto-deleted
- Cards pushed from the same instance are not re-imported
- Enter to send, Shift+Enter for newline
- **Per-tenant inboxes**: each user only sees their own messages
- **Cross-tenant routing**: send a card to another tenant on the same
relay; the relay decrypts with the sender's secret, re-encrypts with
a shared inter-tenant key for storage, then re-encrypts with the
recipient's secret on delivery
- **At-rest encryption**: every sensitive value in the DB (tenant
secrets, the inter-tenant key, message bodies) is encrypted. The
master key lives outside the DB in a chmod-600 file
- **24-hour ephemeral**: messages and attachments auto-delete after a
day. Cards extracted in hyprcrd are the long-term store
- **Future: end-to-end encrypted chat** between tenants is planned and
will require per-pair key exchange — see `relay/README.md` for the
roadmap
### Relay Security
- Separate password (UI login) and shared secret (API + encryption)
- Bcrypt hashing for both credentials
- Rate-limited login (5 attempts/minute)
- AES-256-CBC client-side encryption — relay never sees plaintext
- Per-tenant bcrypt-hashed passwords + secrets
- Master key encryption for tenant secrets and the inter-tenant key
at rest
- HttpOnly + SameSite=Strict session cookies
- Rate-limited login (5 attempts/minute per IP)
- AES-256-CBC for all message encryption
- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options)
- SQLite database blocked from direct download via `.htaccess`
- Request size limited to 256KB
- `data/` directory blocked from direct download via `.htaccess`
- Request size limited to 256KB (15MB for attachments)
- Last-admin protection prevents accidental lockout
For the full security model (including what's NOT protected and the
roadmap to strict E2E for chat), see [`relay/README.md`](relay/README.md).
### Migrating from the old single-tenant relay
When you upgrade an existing relay installation, the new code automatically
detects the legacy schema and drops the old `messages` and `attachments`
tables (the data is 24h-ephemeral anyway). The setup wizard appears on
your next visit. You'll need to:
1. Run through the desktop setup wizard to create the first admin
2. Recreate any other tenant accounts that need access
3. On every connected hyprcrd, update **Settings → Relay** to add the
new **username** field and the new shared secret
The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
client secrets need to be reissued.
## Rebuilding Tailwind CSS
+2
View File
@@ -17,6 +17,8 @@ services:
- ./relay/index.php:/var/www/html/index.php
- ./relay/.htaccess:/var/www/html/.htaccess
- ./relay/fonts:/var/www/html/fonts
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
- relay-data:/var/www/html/data
volumes:
+102 -14
View File
@@ -821,33 +821,61 @@ if (str_starts_with($route, '/api/')) {
if ($route === '/api/relay/settings' && $method === 'GET') {
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
$hasKey = !empty($db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn());
echo json_encode(['url' => $url, 'has_key' => $hasKey]); exit;
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->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
// see and edit it directly without guessing if it's still saved.
echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
}
if ($route === '/api/relay/settings' && $method === 'POST') {
$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 (!empty($input['key'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
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
// app, the input is now pre-populated so an empty submit is intentional
if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
echo json_encode(['ok' => true]); exit;
}
if ($route === '/api/relay/poll' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$strict = !empty($input['strict']); // test button sets this; background poll doesn't
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => true, 'pulled' => 0]); exit; }
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) {
if ($strict) {
$missing = [];
if (!$relayUrl) $missing[] = 'url';
if (!$relayUser) $missing[] = 'username';
if (!$relayKey) $missing[] = 'secret';
echo json_encode(['ok' => false, 'error' => 'relay not configured: missing ' . implode(', ', $missing)]); exit;
}
echo json_encode(['ok' => true, 'pulled' => 0]); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) { echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; }
if ($status !== 200) {
$relayMsg = '';
$decoded = json_decode($response, true);
if (is_array($decoded) && !empty($decoded['error'])) {
$relayMsg = ' — ' . $decoded['error'];
} elseif (is_string($response) && $response !== '') {
$relayMsg = ' — ' . substr($response, 0, 200);
}
echo json_encode(['ok' => false, 'error' => "HTTP $status$relayMsg", 'sent_username' => $relayUser]);
exit;
}
$messages = json_decode($response, true);
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
@@ -909,7 +937,7 @@ if (str_starts_with($route, '/api/')) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
$attResponse = curl_exec($ach);
@@ -938,14 +966,45 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit;
}
// List relay tenants (for the recipient picker)
if ($route === '/api/relay/tenants' && $method === 'GET') {
$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) {
echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . '/api/tenants');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
echo json_encode(['ok' => false, 'error' => "relay returned HTTP $status"]); exit;
}
$data = json_decode($response, true);
echo json_encode([
'ok' => true,
'tenants' => $data['tenants'] ?? [],
'self' => $data['self'] ?? $relayUser,
]);
exit;
}
// Push a card to relay
if ($route === '/api/relay/push' && $method === 'POST') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; }
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) { echo json_encode(['ok' => false, 'error' => 'relay not configured (need url, username, and secret)']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$cardId = $input['card_id'] ?? null;
$recipient = isset($input['recipient']) ? trim((string)$input['recipient']) : null;
if (!$cardId) { http_response_code(400); echo json_encode(['error' => 'card_id required']); exit; }
$card = $db->prepare('SELECT * FROM cards WHERE id = ?');
@@ -967,24 +1026,32 @@ if (str_starts_with($route, '/api/')) {
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
};
$payload = json_encode([
$payloadData = [
'title' => $encryptField($card['title'] ?? ''),
'body' => $encryptField($card['body'] ?? ''),
'color' => $card['color'] ?? '#38bdf8',
'tags' => $card['tags'] ?? '',
'relay_uid' => $relayUid,
]);
];
if ($recipient) $payloadData['recipient'] = $recipient;
$payload = json_encode($payloadData);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
$debugStream = fopen('php://temp', 'w+');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
CURLOPT_VERBOSE => true,
CURLOPT_STDERR => $debugStream,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
rewind($debugStream);
$curlDebug = stream_get_contents($debugStream);
fclose($debugStream);
curl_close($ch);
if ($status === 200 || $status === 201) {
@@ -1013,7 +1080,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $attPayload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ach);
@@ -1022,7 +1089,25 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit;
}
echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit;
// Surface the relay's actual error message so the user can see why
$relayMsg = '';
$decoded = json_decode($response, true);
if (is_array($decoded) && !empty($decoded['error'])) {
$relayMsg = ' — ' . $decoded['error'];
} elseif (is_string($response) && $response !== '') {
$relayMsg = ' — ' . substr($response, 0, 200);
}
// Strip the X-Relay-Key value from the debug log so we don't leak the secret
$safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
echo json_encode([
'ok' => false,
'error' => "relay returned HTTP $status$relayMsg",
'sent_username' => $relayUser,
'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
'curl_debug' => $safeDebug,
]);
exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
@@ -1438,13 +1523,15 @@ if (str_starts_with($route, '/api/')) {
</div>
<div id="settings-panel-relay" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-broadcast text-xs"></i> relay</div>
<p class="text-[10px] text-t-dim leading-relaxed">The relay is multi-tenant: each user has their own account on the relay server. Ask the relay admin to create a user for you, then enter the credentials below.</p>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">relay url</label><input id="relay-url" type="text" placeholder="https://relay.example.com" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="relay-username" type="text" placeholder="your relay username" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">shared secret</label><input id="relay-key" type="text" placeholder="shared secret" autocomplete="off" style="-webkit-text-security:disc;" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
<div class="flex gap-2">
<button id="relay-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/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="relay-status" class="font-pixel text-[9px] text-t-dim self-center"></span>
</div>
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
@@ -1819,6 +1906,7 @@ if (str_starts_with($route, '/api/')) {
</div>
<div id="ctx-buckets" class="hidden border-t border-t-border py-1"></div>
<div id="ctx-members" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-relay-tenants" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-tags" class="hidden border-t border-t-border py-1">
<div class="px-3 py-1.5"><input id="ctx-tag-input" type="text" placeholder="#tag..." class="w-full bg-t-bg border border-t-border text-t-text text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none"></div>
<div id="ctx-tag-list" class="max-h-36 overflow-y-auto"></div>
+40 -11
View File
@@ -222,24 +222,48 @@ function showConnectionLog(lines) {
// ── Relay settings ──
function setRelayStatus(text, kind = 'info') {
const el = $('relay-status');
if (!el) return;
if (!text) {
el.classList.add('hidden');
el.textContent = '';
el.style.borderColor = '';
el.style.color = '';
return;
}
el.classList.remove('hidden');
el.textContent = text;
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
}
$('relay-save')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
const body = { url: $('relay-url')?.value || '' };
const key = $('relay-key')?.value;
if (key) body.key = key;
const body = {
url: $('relay-url')?.value || '',
username: $('relay-username')?.value || '',
key: $('relay-key')?.value || '',
};
setRelayStatus('saving...');
await api('api/relay/settings', { method: 'POST', body });
if (relayStatus) { relayStatus.textContent = 'saved'; relayStatus.style.color = 'var(--t-accent)'; }
setRelayStatus('saved', 'success');
setTimeout(() => setRelayStatus(''), 1500);
});
$('relay-test')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
if (relayStatus) { relayStatus.textContent = 'testing...'; relayStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
setRelayStatus('testing...');
const res = await fetch('api/relay/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ strict: true }),
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
if (relayStatus) { relayStatus.textContent = `ok — ${res.pulled} pulled`; relayStatus.style.color = 'var(--t-accent)'; }
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
if (res.pulled) await window._reloadAll?.();
} else {
if (relayStatus) { relayStatus.textContent = res?.error || 'failed'; relayStatus.style.color = 'var(--t-danger)'; }
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
setRelayStatus((res?.error || 'failed') + detail, 'error');
}
});
@@ -247,9 +271,14 @@ export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const userInput = $('relay-username');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (keyInput) keyInput.placeholder = s.has_key ? '(saved)' : 'shared secret';
if (userInput) userInput.value = s.username || '';
if (keyInput) {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
}
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
+93 -11
View File
@@ -11,6 +11,12 @@ const colors = $('ctx-colors');
const buckets = $('ctx-buckets');
const members = $('ctx-members');
const tags = $('ctx-tags');
const relayTenants = $('ctx-relay-tenants');
// Cached relay tenant list. Loaded on first push-relay submenu open
// for the session, then reused. Refreshed if the user closes/reopens
// the menu without success.
let cachedRelayTenants = null;
export function hideContextMenu() { menu.classList.add('hidden'); }
@@ -38,7 +44,7 @@ export function showContextMenu(x, y, card) {
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span></button>
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
<div class="ctx-sep"></div>
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
@@ -162,7 +168,7 @@ export function showContextMenu(x, y, card) {
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
// Actions
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
@@ -210,15 +216,7 @@ export function showContextMenu(x, y, card) {
}
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'push-relay') {
hideContextMenu();
const res = await api(`api/relay/push`, { method: 'POST', body: { card_id: card.id } });
const msg = res?.ok ? 'pushed to relay' : (res?.error || 'push failed');
const color = res?.ok ? 'var(--t-accent)' : 'var(--t-danger)';
const n = document.createElement('div');
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);`;
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${msg}</div>`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000);
await openRelayTenantSubmenu(card);
}
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
@@ -252,3 +250,87 @@ export function showContextMenu(x, y, card) {
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(); }));
}
// ── Push to relay submenu (cross-tenant recipient picker) ──
async function openRelayTenantSubmenu(card) {
// Show submenu immediately with a loading state, then fetch the
// tenant list (cached after first successful load).
const toggle = (panel) => {
[colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); });
panel.classList.remove('hidden');
};
toggle(relayTenants);
if (!cachedRelayTenants) {
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel">loading tenants...</div>';
try {
const res = await fetch('api/relay/tenants').then(r => r.json());
if (!res.ok) {
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${res.error || 'failed'}</div>`;
return;
}
cachedRelayTenants = res;
} catch (err) {
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${err.message}</div>`;
return;
}
}
renderRelayTenants(card);
}
function renderRelayTenants(card) {
const list = cachedRelayTenants?.tenants || [];
const self = cachedRelayTenants?.self || '';
if (!list.length) {
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic">no tenants</div>';
return;
}
// Self first, then others alphabetical
const ordered = [
...list.filter(t => t.username === self),
...list.filter(t => t.username !== self),
];
relayTenants.innerHTML = ordered.map(t => {
const isSelf = t.username === self;
const icon = isSelf ? 'ph-user' : 'ph-user-circle';
const label = isSelf ? `${t.username} (you)` : t.username;
return `<button class="ctx-item" data-relay-recipient="${t.username}"><i class="ph ${icon} text-sm"></i><span class="font-pixel text-[10px]">${label}</span></button>`;
}).join('');
relayTenants.querySelectorAll('[data-relay-recipient]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const recipient = btn.dataset.relayRecipient;
hideContextMenu();
const res = await api('api/relay/push', { method: 'POST', body: { card_id: card.id, recipient } });
relayPushToast(res, recipient);
});
});
}
function relayPushToast(res, recipient) {
const ok = res?.ok;
const msg = ok
? (recipient && recipient !== cachedRelayTenants?.self ? `pushed to ${recipient}` : 'pushed to relay')
: (res?.error || 'push failed');
const color = ok ? 'var(--t-accent)' : 'var(--t-danger)';
if (!ok) {
// Dump every diagnostic the backend gave us so we can see the wire-level
// truth instead of guessing why the relay said no
console.group('%c[relay push failed]', 'color:#fb7185;font-weight:bold');
console.log('error:', res?.error);
if (res?.relay_url) console.log('url:', res.relay_url);
if (res?.sent_username) console.log('sent username:', res.sent_username);
if (res?.response_body) console.log('response body:', res.response_body);
if (res?.curl_debug) console.log('curl debug:\n' + res.curl_debug);
console.groupEnd();
}
const n = document.createElement('div');
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:340px;`;
const hint = ok ? '' : '<div style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);margin-top:4px;">see browser console for wire-level debug</div>';
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${esc(msg)}</div>${hint}`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000);
}
+5
View File
@@ -3,6 +3,11 @@ RewriteEngine On
# Block direct access to data directory
RewriteRule ^data/ - [F,L]
# Block direct execution of include files (defense in depth — they also
# check RELAY_BOOTSTRAPPED at the top of each file).
RewriteRule ^inc/ - [F,L]
RewriteRule ^ui/.*\.php$ - [F,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
+612
View File
@@ -0,0 +1,612 @@
# hyprcrd Relay
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
share one relay instance; each one has their own login, their own client
encryption secret, and their own inbox. Cards (and in the future:
encrypted chat messages) can be sent **between tenants** on the same
relay.
The relay is a single-file PHP app — drop the directory into any PHP
webspace and it works.
## Concepts
| Term | Meaning |
|---|---|
| **Tenant** | A user account on the relay. Has a username + UI password (set by admin) + a self-managed client secret (set by the user themselves). |
| **Admin** | A tenant with `is_admin = 1`. Can create, modify, and delete other tenants. The first tenant created at install time is automatically admin. |
| **Client secret (per-tenant)** | The key your hyprcrd client uses to encrypt outgoing messages. **Set by the user themselves** in the relay UI's "your account" view (not by the admin). Each user picks or generates their own. The relay stores it encrypted with the master key so it can route messages between tenants. |
| **Super-shared secret** | A relay-wide secret **set by the admin during initial setup**. This is the cross-tenant routing key — used by the relay to re-encrypt messages at rest so they can flow between any pair of tenants. Stored as `inter_tenant_key` in the settings table, encrypted with the master key. Set once, can't be changed without re-setup (would invalidate all stored messages). |
| **Master key** | A 32-byte random value generated on first install, stored in `data/master.key` (chmod 600, blocked by `.htaccess`). Encrypts the super-shared secret and per-tenant client secrets at rest, so a DB leak alone doesn't compromise the relay. |
| **Inbox** | Each tenant's incoming messages. 24-hour ephemeral storage. |
### Three secrets, three jobs
1. **UI login password** (set by admin) — opens the relay web UI for that tenant
2. **Client secret** (set by the user themselves on first login) — encrypts hyprcrd ↔ relay traffic
3. **Super-shared secret** (set by admin once at install) — encrypts everything in the DB and is the pivot for cross-tenant routing
The admin only ever touches secrets #1 and #3. The user only ever touches secret #2. They are stored separately and serve different purposes.
## Encryption flow (cross-tenant)
```
┌──────────────┐ ┌─────────────────────┐
│ hyprcrd A │ │ relay │
│ (alice) │ │ │
│ │ │ 1. auth alice │
│ encrypts │ POST /messages │ 2. decrypt body │
│ body with ├─────────────────►│ with alice's │
│ Sa (alice's │ X-Relay-User:A │ secret │
│ secret) │ X-Relay-Key:Sa │ 3. re-encrypt with │
│ │ │ inter-tenant key │
└──────────────┘ │ 4. store with │
│ recipient = bob │
┌──────────────┐ │ │
│ hyprcrd B │ │ 5. on B's poll: │
│ (bob) │ GET /messages │ decrypt with │
│ │ X-Relay-User:B │ inter-tenant key │
│ decrypts │◄─────────────────┤ 6. re-encrypt with │
│ body with │ X-Relay-Key:Sb │ bob's secret │
│ Sb (bob's │ │ 7. return │
│ secret) │ │ │
└──────────────┘ └─────────────────────┘
```
The relay **does** see plaintext briefly during the cross-tenant hop
(steps 2–3 and 5–6). This is the same trust model as email: you trust
the relay operator. **Strict end-to-end encryption** for the planned
chat feature is on the roadmap and will require a per-pair key exchange
(Curve25519/ECDH) — this is documented as future work in `index.php`.
For at-rest data, all sensitive values are encrypted with the master
key, so a stolen DB without the master key file gives an attacker
nothing.
## Setup
### 1. Deploy
**Webspace** (any PHP host with `mod_rewrite` and `pdo_sqlite`):
```bash
cd relay
./dist.sh
# upload hyprcrd-relay.zip and extract into your webspace
```
**Docker**:
```bash
cd relay
docker build -t hyprcrd-relay .
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
```
The `data/` directory is created on first request, contains the
SQLite DB and the master key file, and is blocked from web access via
`.htaccess`.
### 2. Initial admin setup
Open the relay URL **from a desktop browser** (the setup wizard is
desktop-friendly). On first visit you'll see the setup form.
Fill in:
- **admin username** (2-32 chars, letters/digits/`_`/`-`)
- **admin password** (min 4 chars, used for the relay UI login)
- **super-shared secret** (min 12 chars, the relay-wide cross-tenant
routing key — set once, can't be changed)
Click **create admin + activate relay**. You're logged in and dropped
straight onto the **account** page where you'll set your own client
secret.
### 3. Set your own client secret
Every tenant — including the admin — sets their own client secret on
the **account** page. The admin never knows or sets it for other users.
You can either:
- **Type your own secret** (min 8 chars) and click save
- **Generate a random one** (48-char hex) and click the dice button
The secret is shown in plaintext on the account page so you can copy
it to your hyprcrd's relay settings. You can return to the account
page anytime to view it again.
### 4. Create more users (admin)
Once logged in as admin, click the **users** tab in the header. Use the
form to create additional tenants:
- their own **username**
- a **temporary password** (the user is forced to change it on first login)
- optional admin privileges (checkbox)
The admin does **not** set the new user's client secret — the user
themselves sets it on first login from the account page.
Tell each user their credentials (just username + temporary password)
out-of-band (signal, encrypted email, in person). On their first
login, the relay UI will:
1. Show a **forced password change** screen — they must replace the
admin-issued temp password with one of their own (min 4 chars).
No other view is reachable until they do.
2. Drop them on the **account** page to set their client secret.
3. Then they can copy the secret to their hyprcrd Settings → Relay.
The admin themselves is **never** forced to change password (the
admin set their own password during the initial setup wizard).
### 5. Connect a hyprcrd instance to the relay
In any hyprcrd instance:
1. Open **Settings → Relay**
2. Enter:
- **relay url** (e.g. `https://relay.example.com`)
- **username** (the one the admin gave you)
- **shared secret** (the one **you** set on the account page)
3. Click **save**, then **test** to verify the connection works
After this, hyprcrd polls the relay every 45 seconds and will fetch
incoming messages.
### 6. Send a card to another tenant
(currently in the relay UI; hyprcrd-side recipient picker is coming)
In the relay UI **compose** tab:
- Pick a recipient from the dropdown (defaults to yourself)
- Type a title and body
- Click **send**
The recipient will see it on their next hyprcrd poll, materialized as a
card in their board.
You can also right-click a card in hyprcrd and pick **push to relay** —
this currently sends to your own inbox; recipient picker on the hyprcrd
side is on the TODO.
## Data lifetime
- Messages auto-delete after **24 hours** (whether fetched or not)
- Attachments same
- Sessions expire after 24 hours of inactivity
- Rate-limit entries cleaned up every 5 minutes
The relay is **not a long-term archive**. Anything you want to keep
should be extracted as a card in hyprcrd.
## Database schema
```
tenants
id, username, password_hash (bcrypt), secret_hash (bcrypt),
secret_encrypted (master-key encrypted), is_admin, created_at
settings
key='inter_tenant_key', value=master-encrypted blob
messages
id, recipient_id → tenants(id), sender_id → tenants(id),
sender_username, title, body (both encrypted with inter-tenant key),
color, tags, relay_uid, fetched, created_at
attachments
id, recipient_id → tenants(id), relay_uid, filename, data, created_at
sessions
id (random token), tenant_id, created_at, expires_at
rate_limits
ip, endpoint, attempted_at
```
`recipient_id` and `sender_id` cascade-delete when a tenant is removed.
## API reference
All endpoints under `/api/`. JSON in/out.
### Public (no auth)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | `/api/status` | — | `{setup, has_admin, logged_in, username?, is_admin?, inbox_count?}` |
| POST | `/api/setup` | `{username, password, secret}` | `{ok, username, is_admin}` — only works when no tenants exist |
| POST | `/api/login` | `{username, password}` | `{ok, username, is_admin}` — sets session cookie |
| POST | `/api/logout` | — | `{ok}` |
### Session-authenticated (UI cookie)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | `/api/me` | — | Current tenant info |
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
### Admin only (session + `is_admin`)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | `/api/users` | — | List all tenants |
| POST | `/api/users` | `{username, password, secret, is_admin?}` | Create new tenant |
| PATCH | `/api/users/{id}` | `{password?, secret?, is_admin?}` | Update existing tenant |
| DELETE | `/api/users/{id}` | — | Cascade-delete tenant + their messages |
Last-admin protection: you cannot demote or delete the only remaining
admin, and you cannot delete your own account.
### Client API (header-authenticated)
Headers required:
```
X-Relay-User: alice
X-Relay-Key: <alice's shared secret>
```
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | `/api/messages` | `{title, body, recipient?, color?, tags?, relay_uid?}` | title and body must already be client-encrypted with sender's secret. Server decrypts, re-encrypts with inter-tenant key, stores. |
| GET | `/api/messages?since=ISO` | — | Returns messages re-encrypted with the requesting tenant's secret. Marks them as `fetched`. |
| DELETE | `/api/messages/{id}` | — | Only deletes own messages (recipient_id must match) |
| POST | `/api/attachments` | `{relay_uid, filename, data, recipient?}` | data is base64-encoded encrypted blob |
| GET | `/api/attachments?relay_uid=...` | — | Fetches own attachments only |
## Encryption format
Client-side messages use a versioned envelope:
```
HYPRCRD-RELAY-V1\n<base64(IV || ciphertext)>
```
- **Cipher**: AES-256-CBC
- **Key**: `substr(sha256(secret_string), 0, 32)` interpreted as 32 ASCII bytes
- **IV**: 16 random bytes per message, prepended to ciphertext
This format is compatible with the JS encryption in hyprcrd's relay
plugin and the PHP `clientEncrypt`/`clientDecrypt` helpers in the relay.
The **inter-tenant key** uses the exact same scheme — it's just a longer
random secret (64 hex chars = 32 bytes after the `substr` step gives 32
ASCII chars from the hex prefix). All re-encryption hops in the relay
use the same code path.
## Security model
**What's protected:**
- Tenant secrets and the inter-tenant key are encrypted at rest with the
master key
- The master key file lives in `data/`, blocked from web access by
`.htaccess`, with `chmod 600`
- All passwords (UI login + client secrets) are bcrypt-hashed for auth
verification
- Sessions use random 32-byte tokens, HttpOnly + SameSite=Strict cookies
- Rate-limiting: 5 attempts/minute per IP for setup, login, and verify
- Request body limited to 256 KB (15 MB for attachment uploads)
- Security headers: X-Frame-Options, X-Content-Type-Options, HSTS,
Referrer-Policy
- All sensitive endpoints require authentication
- Last-admin protection prevents accidental lockout
**What's NOT protected (by design, current trust model):**
- The relay operator can decrypt all messages — the master key file +
the DB give them access to everything. This is the same trust model
as email.
- Cross-tenant messages are seen in plaintext briefly during the
re-encryption hop in PHP memory. They are not written anywhere
unencrypted.
- A compromised relay machine = compromised messages. Use HTTPS, keep
PHP up to date, restrict shell access.
**Future work for stronger guarantees** (planned for the encrypted chat
feature):
- Per-tenant Curve25519 key pair stored in the DB (public key plus
master-key-encrypted private key, or even private key never on the
server)
- ECDH key exchange between tenant pairs
- Per-conversation symmetric key derived from the shared secret
- Forward secrecy via ratcheting
- This would make the relay a true blind router: it sees only opaque
blobs and metadata.
## Migrations
### v1 (single-tenant) → v3 (multi-tenant, self-set secrets)
The old schema had `relay_password` and `relay_key` in the `settings`
table and messages/attachments tables without `recipient_id`. On first
request after the upgrade, the relay automatically:
1. Detects the legacy schema by checking if `messages.recipient_id`
exists. If not, it `DROP`s the old `messages` and `attachments`
tables. (They contain only 24h-ephemeral data anyway.)
2. Creates the new `tenants`, `sessions`, `messages`, and `attachments`
tables via `CREATE TABLE IF NOT EXISTS`.
3. The `tenants` table starts empty → the **setup wizard** is shown
the next time you load the relay URL in a browser.
### v2 (admin-set secrets) → v3 (self-set secrets)
In v2 the admin set the client secret when creating each user. In v3
this changed: secrets are nullable and each user sets their own.
On first request after the upgrade, the relay automatically:
1. Detects the v2 schema by checking if `tenants.secret_hash` is
`NOT NULL`. If so, the table is recreated with nullable columns
(and the new `password_changed` column).
2. Existing tenant rows (username, password, is_admin) are kept
intact, but their `secret_hash` and `secret_encrypted` are
cleared to NULL.
3. **Non-admin users get `password_changed = 0`** so they're forced
to change their password on the next login (the v2 password was
admin-set, not self-chosen). Admins keep `password_changed = 1`.
4. The next time each user logs in, the relay walks them through:
forced password change → set client secret → start using.
5. The previously generated `inter_tenant_key` is kept as-is so
cross-tenant messages flow through the same key. Admin can rotate
it later (currently requires a manual DB edit; rotate API endpoint
is on the TODO).
### v3.0 → v3.1 (forced password change flag)
A small additive migration. If `tenants.password_changed` is missing,
the relay does:
1. `ALTER TABLE tenants ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 1`
2. `UPDATE tenants SET password_changed = 0 WHERE is_admin = 0` —
forces existing non-admin users to change their password too,
since they were originally admin-set.
Admins keep `password_changed = 1` because they set their own password
during the initial setup wizard.
After running through the setup wizard once on a desktop:
- You're the new admin
- A fresh inter-tenant key is generated and stored encrypted with the
master key
- All previously connected hyprcrd instances need to update their
**Settings → Relay** to add the new `username` field and the new
shared secret you set
There's **no plaintext recovery** of the old `relay_key` because it
was bcrypt-hashed in the old schema — you have to re-create users and
re-enter the secret on every connected hyprcrd. Treat the upgrade as a
fresh install with credential reset.
The legacy `relay_password` and `relay_key` rows in the `settings` table
are left in place (unused) so you can downgrade if needed.
## Development (both relay + hyprcrd together)
From the project root:
```bash
./dev.sh
```
Starts hyprcrd on `http://localhost:8088` and the relay on
`http://localhost:8081` in the same Docker network. Source files are
bind-mounted — changes are live without rebuilding.
From inside the hyprcrd container, the relay is reachable at
`http://relay:80`. This is the URL to put in hyprcrd's relay settings
when developing locally.
### Docker bind-mounts
The relay's `docker-compose.yml` bind-mounts these paths from the host:
```yaml
volumes:
- ./relay/index.php:/var/www/html/index.php
- ./relay/.htaccess:/var/www/html/.htaccess
- ./relay/fonts:/var/www/html/fonts
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
- relay-data:/var/www/html/data
```
If you add new top-level directories to `relay/`, mount them here too —
otherwise the container won't see them and `require` will fail silently
(`error_reporting(0)` swallows the fatal). Symptoms: blank page or 500
on every request.
After updating bind mounts you must `docker compose down && docker compose up -d --build`
— Compose remembers the previous mount config until you tear down.
### Wiping the relay state
Sometimes you want a completely fresh DB (e.g. after schema changes
during development):
```bash
docker compose down -v # -v also removes the relay-data volume
docker compose up -d
```
Without `-v` the volume persists between restarts. Wiping forces the
relay back to setup-wizard mode on the next visit.
## UI
### Responsive layout
The UI is desktop-first but works on mobile. The header collapses to
icon-only buttons on narrow screens. The compose card and inbox stack
naturally.
### Recipient picker auto-hides for solo tenants
When the relay only has one tenant (typical for a fresh install or a
single-user deployment), the **recipient** dropdown in the compose form
is hidden — there's no other tenant to send to. The compose form
becomes just title + body.
As soon as the admin creates a second tenant via the users panel, the
dropdown reappears on the next render of the compose view, defaulting
to "you" with the other tenants listed below.
The default recipient is always "self" if not explicitly set, so the
backend behaves the same whether the picker is visible or not.
### Mobile is compose-only
On mobile (screens narrower than 700px) the relay strips down to a
single-purpose tool: dump content into the inbox. There's no
navigation, no admin, no identity display — just compose + your own
inbox count.
What's hidden on mobile via CSS media query (`@media (max-width: 700px)`):
- The **users** nav button (`#nav-admin`)
- The entire **users management view** (`#view-admin`)
- The **logged-in username + admin badge** in the header (`#nav-user`)
- The **compose nav button** (`#nav-compose`) — since there's nothing
to navigate between, the button is dead weight
The mobile header shrinks to just: pulsing dot + "hyprcrd relay" title
+ inbox count + logout button.
For defense-in-depth, the JS also redirects `showView('admin')` calls
to `compose` when the viewport is mobile, so the admin panel can't be
opened programmatically either.
The backend admin API endpoints (`/api/users`) are still reachable —
they're protected by the session+`is_admin` check, so only the UI is
restricted. The intent is to:
1. Keep mobile users out of admin tasks (form is cramped on small screens)
2. Avoid showing the username in shoulder-surfing range — most relay
use on phone is ad-hoc note capture in public
3. Reduce the mobile UI to its single purpose: capture and send
If you need to administrate from a mobile device, use a desktop
browser's mobile-responsive emulator OR temporarily widen the viewport
(rotate to landscape on a tablet etc.).
### Icons (Phosphor, locally hosted)
The relay uses [Phosphor Icons](https://phosphoricons.com) — the same
icon family hyprcrd uses. They're hosted **locally** (no CDN), so the
relay remains a fully self-contained drop-in app with zero external
dependencies at runtime.
The font + CSS files live in `relay/fonts/`:
- `phosphor.css` (~78 KB) — the `.ph` and `.ph-{name}` rules
- `Phosphor.woff2` (~147 KB) — the actual font file
To add an icon anywhere in the UI:
```html
<i class="ph ph-paper-plane-tilt"></i> send
```
Sizing is controlled in `ui/style.css` per context (nav buttons 11px,
section headers 13px, action buttons 12px). Custom sizes can be set
inline via `style="font-size:20px;"`.
The full icon catalog is at https://phosphoricons.com.
## Files
```
relay/
├── README.md # this file
├── index.php # entry point: bootstraps, dispatches API routes, renders UI
├── inc/
│ ├── bootstrap.php # DB connect, master key, schema migration, helpers
│ ├── crypto.php # masterEncrypt/Decrypt, clientEncrypt/Decrypt, inter-tenant key
│ ├── tenants.php # tenant DB lookup helpers
│ ├── auth.php # rate limit, sessions, header-based client auth
│ └── routes/
│ ├── public.php # /api/status, /setup, /login, /logout, /me
│ ├── admin.php # /api/users CRUD, /api/tenants directory
│ └── messages.php # /api/messages, /api/attachments, cross-tenant routing
├── ui/
│ ├── layout.php # HTML shell + view containers
│ ├── style.css # all CSS (including responsive media queries)
│ └── app.js # all JS
├── fonts/ # all locally-hosted UI assets
│ ├── silkscreen-400.ttf # pixel display font
│ ├── silkscreen-700.ttf
│ ├── jetbrains-mono-400.ttf # monospace body font
│ ├── phosphor.css # Phosphor icon font CSS rules
│ └── Phosphor.woff2 # Phosphor icon font binary
├── .htaccess # rewrite + blocks /data/, /inc/, /ui/*.php
├── Dockerfile
├── dist.sh # builds hyprcrd-relay.zip with all of the above
└── data/ # created on first run, NOT in the dist
├── relay.db # SQLite database
└── master.key # 32-byte master key (chmod 600)
```
Each include file in `inc/` and `ui/` checks `RELAY_BOOTSTRAPPED` at the
top and exits with 404 if accessed directly. The `.htaccess` adds a
defense-in-depth `[F,L]` block on `inc/` and `ui/*.php` so they're never
served by Apache anyway.
The `dist.sh` script zips up `index.php`, `.htaccess`, `inc/`, `ui/`,
`fonts/`, and `README.md` recursively — drop the resulting
`hyprcrd-relay.zip` into any PHP webspace.
## Troubleshooting
**"too many attempts" on login**
Rate limit. Wait one minute.
**"unauthorized" from a hyprcrd instance**
- Verify `relay-username` is set in hyprcrd Settings → Relay
- Verify the username matches a tenant created on the relay
- Verify the shared secret matches what the admin gave you
- Check the relay log if you have access — bcrypt mismatch is the most
common cause
**Can't see new tenants in the dropdown after admin created them**
The recipient picker is loaded once per page session. Refresh the relay
UI tab to repopulate.
**Lost the master key**
You're locked out. There's no recovery: the inter-tenant key and all
stored tenant secrets are unrecoverable. You'll need to wipe `data/`
and re-setup. Back up `data/master.key` to a safe place after install
if you care about durability beyond the 24h message window.
**Lost the admin password**
Open `data/relay.db` directly with `sqlite3` and reset:
```sql
UPDATE tenants SET password_hash = '$2y$10$...' WHERE username = 'admin';
```
Generate the bcrypt hash with `php -r "echo password_hash('newpw', PASSWORD_BCRYPT);"`.
**Blank page / 500 error after upgrade in Docker**
The most common cause is that `docker-compose.yml` is missing bind
mounts for `relay/inc` and `relay/ui`. When the new modular `index.php`
runs `require __DIR__ . '/inc/bootstrap.php'` and the file doesn't exist
in the container, PHP fatally errors. `error_reporting(0)` in the relay
swallows the error → blank page.
Fix: ensure these mounts are present in `docker-compose.yml`:
```yaml
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
```
Then `docker compose down && docker compose up -d --build`.
**Setup wizard doesn't appear after upgrading from v1**
The auto-migration drops the legacy `messages` and `attachments` tables
on first request. If you still don't see the setup wizard:
1. Clear browser cookies + localStorage for the relay domain (the v1
stored credentials in localStorage which is now stale)
2. Check the relay log for `[hyprcrd-relay] migrated:` lines confirming
the legacy tables were dropped
3. As a last resort, wipe the volume:
`docker compose down -v && docker compose up -d`
You lose nothing important — the relay is 24h-ephemeral by design.
**Mobile user wants admin access**
By design, mobile screens (≤700px) hide the entire admin UI. Use a
desktop browser, or in your phone browser's dev tools rotate to
landscape on a tablet, or use the request-desktop-site option to widen
the viewport. The admin API endpoints still work via curl with a valid
session cookie if you really need scripted access.
+1 -1
View File
@@ -8,7 +8,7 @@ cd "$(dirname "$0")"
DIST="hyprcrd-relay.zip"
rm -f "$DIST"
zip -r "$DIST" index.php .htaccess fonts/
zip -r "$DIST" index.php .htaccess fonts/ inc/ ui/ README.md
echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))"
echo ""
Binary file not shown.
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
<?php
// Auth helpers: rate limiting, sessions (UI), client API auth (header).
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
function isRateLimited(PDO $db, string $endpoint, int $max = 10): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$count = $db->prepare("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-1 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;
}
function makeSession(PDO $db, int $tenantId, int $hours = 24): string {
$token = bin2hex(random_bytes(32));
$db->prepare("INSERT INTO sessions (id, tenant_id, expires_at) VALUES (:t, :u, datetime('now', '+{$hours} hours'))")
->execute(['t' => $token, 'u' => $tenantId]);
return $token;
}
function getSessionTenant(PDO $db): ?array {
$token = $_COOKIE['relay_session'] ?? '';
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->execute(['tok' => $token]);
return $stmt->fetch() ?: null;
}
function destroySession(PDO $db): void {
$token = $_COOKIE['relay_session'] ?? '';
if ($token) {
$db->prepare('DELETE FROM sessions WHERE id = :t')->execute(['t' => $token]);
}
setcookie('relay_session', '', [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true,
'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https',
]);
}
function setSessionCookie(string $token): void {
setcookie('relay_session', $token, [
'expires' => time() + 86400,
'path' => '/',
'httponly' => true,
'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https',
]);
}
/**
* Authenticate a tenant via the client API headers (X-Relay-User + X-Relay-Key).
* Returns the tenant row on success, null on failure.
*/
function authClient(PDO $db): ?array {
$username = $_SERVER['HTTP_X_RELAY_USER'] ?? '';
$key = $_SERVER['HTTP_X_RELAY_KEY'] ?? '';
if (!$username || !$key) return null;
$tenant = getTenantByUsername($db, $username);
if (!$tenant) return null;
if (!password_verify($key, $tenant['secret_hash'])) return null;
return $tenant;
}
+203
View File
@@ -0,0 +1,203 @@
<?php
// Bootstrap: DB connection, master key, schema + migration, common helpers.
// All include files require RELAY_BOOTSTRAPPED to be defined by index.php.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
error_reporting(0);
ini_set('display_errors', '0');
// ── Paths ─────────────────────────────────────────────────────────
$RELAY_ROOT = dirname(__DIR__);
$dbPath = $RELAY_ROOT . '/data/relay.db';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);
// ── Master key ────────────────────────────────────────────────────
//
// Generated on first install, stored in data/master.key (chmod 600,
// blocked from web access via .htaccess). Encrypts everything that
// needs to be recoverable: tenant secrets, the inter-tenant key.
$masterKeyPath = $RELAY_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));
// ── Database ──────────────────────────────────────────────────────
$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 foreign_keys = ON');
// ── Schema migration from single-tenant relay ──────────────────────
//
// Old schema (pre-multitenant) had `messages` and `attachments` tables
// without recipient_id/sender_id. CREATE TABLE IF NOT EXISTS would skip
// recreating them, leaving the old shape in place — and inserts with
// the new column list would silently fail. Detect and drop the old
// tables here. Old data is 24h ephemeral so it's a safe drop.
function tableHasColumn(PDO $db, string $table, string $col): bool {
try {
$cols = array_column($db->query("PRAGMA table_info($table)")->fetchAll(), 'name');
return in_array($col, $cols, true);
} catch (\Throwable $e) {
return false;
}
}
function tableExists(PDO $db, string $table): bool {
try {
$r = $db->prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = :n");
$r->execute(['n' => $table]);
return (bool)$r->fetchColumn();
} catch (\Throwable $e) {
return false;
}
}
if (tableExists($db, 'messages') && !tableHasColumn($db, 'messages', 'recipient_id')) {
$db->exec('DROP TABLE IF EXISTS messages');
@error_log('[hyprcrd-relay] migrated: dropped legacy single-tenant messages table');
}
if (tableExists($db, 'attachments') && !tableHasColumn($db, 'attachments', 'recipient_id')) {
$db->exec('DROP TABLE IF EXISTS attachments');
@error_log('[hyprcrd-relay] migrated: dropped legacy single-tenant attachments table');
}
// Old single-tenant settings keys (relay_password, relay_key) are unused
// but left in place to allow downgrade.
// v2 → v3 migration: tenants.secret_hash and .secret_encrypted are now
// nullable because users set their own client secret rather than having
// the admin assign one. SQLite can't ALTER COLUMN constraints, so we
// recreate the table while preserving rows.
function tableColumnIsNotNull(PDO $db, string $table, string $col): bool {
try {
$cols = $db->query("PRAGMA table_info($table)")->fetchAll();
foreach ($cols as $c) {
if ($c['name'] === $col) return (bool)$c['notnull'];
}
} catch (\Throwable $e) {}
return false;
}
if (tableExists($db, 'tenants') && tableColumnIsNotNull($db, 'tenants', 'secret_hash')) {
$db->exec('PRAGMA foreign_keys = OFF');
$db->exec('CREATE TABLE tenants_v3_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
secret_hash TEXT,
secret_encrypted TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
password_changed INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
// Copy existing rows but blank the secret columns — old admin-assigned
// secrets are deprecated; users must re-set their own via the account UI.
// Non-admin tenants in v2 had admin-set passwords, so force them to change.
$db->exec('INSERT INTO tenants_v3_new (id, username, password_hash, secret_hash, secret_encrypted, is_admin, password_changed, created_at)
SELECT id, username, password_hash, NULL, NULL, is_admin,
CASE WHEN is_admin = 1 THEN 1 ELSE 0 END,
created_at FROM tenants');
$db->exec('DROP TABLE tenants');
$db->exec('ALTER TABLE tenants_v3_new RENAME TO tenants');
$db->exec('PRAGMA foreign_keys = ON');
@error_log('[hyprcrd-relay] migrated v2->v3: tenant secrets now self-set, non-admin passwords flagged for forced change');
}
// v3.0 → v3.1 migration: add password_changed column if missing
if (tableExists($db, 'tenants') && !tableHasColumn($db, 'tenants', 'password_changed')) {
$db->exec('ALTER TABLE tenants ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 1');
// Force non-admin users to change their password (they were admin-set)
$db->exec('UPDATE tenants SET password_changed = 0 WHERE is_admin = 0');
@error_log('[hyprcrd-relay] migrated v3.0->v3.1: added password_changed flag, non-admins forced to change');
}
// ── Schema ─────────────────────────────────────────────────────────
$db->exec('CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
secret_hash TEXT,
secret_encrypted TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
password_changed INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
$db->exec('CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)');
$db->exec('CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_id INTEGER NOT NULL,
sender_id INTEGER,
sender_username TEXT,
title TEXT DEFAULT "",
body TEXT NOT NULL,
color TEXT DEFAULT "#38bdf8",
tags TEXT DEFAULT "",
relay_uid TEXT DEFAULT NULL,
fetched INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL
)');
$db->exec('CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_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,
FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$db->exec('CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
tenant_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
$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
)');
// 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 attachments WHERE created_at < datetime('now', '-24 hours')");
$db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')");
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
// ── Common response helper ────────────────────────────────────────
function jsonResponse($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
// ── Security headers ──────────────────────────────────────────────
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');
}
+78
View File
@@ -0,0 +1,78 @@
<?php
// Crypto helpers: at-rest encryption with master key, plus the
// HYPRCRD-RELAY-V1 envelope format used by both the JS client and
// the relay's internal re-encryption hops.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
/**
* Encrypt a value at-rest with the master key. Used for tenant secrets
* and the inter-tenant key in the DB.
*/
function masterEncrypt(string $plain, string $masterKeyHex): string {
$key = hex2bin($masterKeyHex);
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
function masterDecrypt(string $encoded, string $masterKeyHex): string {
$key = hex2bin($masterKeyHex);
$raw = base64_decode($encoded);
if (!$raw || strlen($raw) < 17) return '';
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
}
/**
* Match the JS client's encryption format exactly:
* key = first 32 chars of hex(SHA-256(secret)) used as 32-byte ASCII key
* envelope = "HYPRCRD-RELAY-V1\n" + base64(iv || ciphertext)
*/
function clientEncrypt(string $plain, string $secret): string {
$key = substr(hash('sha256', $secret), 0, 32);
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
}
function clientDecrypt(string $envelope, string $secret): string {
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return $envelope;
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
$raw = base64_decode($b64);
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 : '';
}
/**
* Inter-tenant key: relay-wide shared "super secret" key. Set by the admin
* 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.
*
* Returns null if not set yet (e.g. legacy v2 installs that haven't been
* upgraded through the new setup wizard).
*/
function getInterTenantKey(PDO $db, string $masterKey): ?string {
$stored = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
if (!$stored) return null;
$decrypted = masterDecrypt($stored, $masterKey);
return $decrypted !== '' ? $decrypted : null;
}
/** Set/replace the inter-tenant key. Stored encrypted with the master key. */
function setInterTenantKey(PDO $db, string $masterKey, string $newKey): void {
$encrypted = masterEncrypt($newKey, $masterKey);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('inter_tenant_key', :v)")
->execute(['v' => $encrypted]);
}
/** True if the relay-wide super secret has been configured. */
function hasInterTenantKey(PDO $db): bool {
$stored = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
return !empty($stored);
}
+73
View File
@@ -0,0 +1,73 @@
<?php
// Account routes — every logged-in user manages their own client secret
// here. The admin never sees or sets it.
//
// GET /api/account/secret show plaintext (decrypted via master key)
// POST /api/account/secret set/replace (body: {secret} or {generate:true})
// DELETE /api/account/secret clear (disconnects all clients)
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// $sessionTenant comes from routes/public.php earlier in the chain
if ($route === '/api/account/secret' && $method === 'GET') {
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
if (empty($sessionTenant['secret_encrypted'])) {
jsonResponse(['has_secret' => false]);
}
$plain = masterDecrypt($sessionTenant['secret_encrypted'], $MASTER_KEY);
jsonResponse(['has_secret' => true, 'secret' => $plain]);
}
if ($route === '/api/account/secret' && $method === 'POST') {
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
// Either accept a user-supplied secret OR generate a random one
if (!empty($input['generate'])) {
$secret = bin2hex(random_bytes(24)); // 48-char hex
} else {
$secret = (string)($input['secret'] ?? '');
if (strlen($secret) < 8) {
jsonResponse(['error' => 'secret must be at least 8 chars'], 400);
}
}
$db->prepare('UPDATE tenants SET secret_hash = :sh, secret_encrypted = :se WHERE id = :id')
->execute([
'sh' => password_hash($secret, PASSWORD_BCRYPT),
'se' => masterEncrypt($secret, $MASTER_KEY),
'id' => $sessionTenant['id'],
]);
jsonResponse(['ok' => true, 'secret' => $secret]);
}
if ($route === '/api/account/secret' && $method === 'DELETE') {
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
$db->prepare('UPDATE tenants SET secret_hash = NULL, secret_encrypted = NULL WHERE id = :id')
->execute(['id' => $sessionTenant['id']]);
jsonResponse(['ok' => true]);
}
if ($route === '/api/account/password' && $method === 'POST') {
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$current = (string)($input['current'] ?? '');
$new = (string)($input['new'] ?? '');
if (strlen($new) < 4) jsonResponse(['error' => 'new password must be at least 4 chars'], 400);
if (!password_verify($current, $sessionTenant['password_hash'])) {
jsonResponse(['error' => 'current password is wrong'], 401);
}
if ($current === $new) {
jsonResponse(['error' => 'new password must differ from current'], 400);
}
$db->prepare('UPDATE tenants SET password_hash = :p, password_changed = 1 WHERE id = :id')
->execute([
'p' => password_hash($new, PASSWORD_BCRYPT),
'id' => $sessionTenant['id'],
]);
jsonResponse(['ok' => true]);
}
+98
View File
@@ -0,0 +1,98 @@
<?php
// Admin-only routes (session + is_admin):
// GET /api/users
// POST /api/users
// PATCH /api/users/{id}
// DELETE /api/users/{id}
//
// Plus the tenant directory available to any logged-in user:
// GET /api/tenants
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// $sessionTenant is set by routes/public.php earlier in the chain
if ($route === '/api/users' && $method === 'GET') {
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();
jsonResponse(['users' => $rows]);
}
if ($route === '/api/users' && $method === 'POST') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$username = trim((string)($input['username'] ?? ''));
$password = (string)($input['password'] ?? '');
$isAdmin = !empty($input['is_admin']) ? 1 : 0;
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
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 (getTenantByUsername($db, $username)) jsonResponse(['error' => 'username already exists'], 409);
// No client secret — the new user sets that themselves after first login
// via the account view. password_hash is bcrypted; secret columns stay NULL.
// password_changed = 0 forces a password change on first login (the
// admin-supplied password is meant as a temporary credential).
// Admin-flagged users still need to change because the admin set it.
$db->prepare('INSERT INTO tenants (username, password_hash, is_admin, password_changed) VALUES (:u, :p, :a, 0)')
->execute([
'u' => $username,
'p' => password_hash($password, PASSWORD_BCRYPT),
'a' => $isAdmin,
]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'PATCH') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$id = (int)$m[1];
$target = getTenantById($db, $id);
if (!$target) jsonResponse(['error' => 'user not found'], 404);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$fields = []; $params = ['id' => $id];
if (!empty($input['password'])) {
if (strlen($input['password']) < 4) jsonResponse(['error' => 'password too short'], 400);
$fields[] = 'password_hash = :pw';
$params['pw'] = password_hash($input['password'], PASSWORD_BCRYPT);
}
if (array_key_exists('is_admin', $input)) {
if (!$input['is_admin'] && $target['is_admin'] && adminCount($db) <= 1) {
jsonResponse(['error' => 'cannot demote the last admin'], 400);
}
$fields[] = 'is_admin = :ad';
$params['ad'] = $input['is_admin'] ? 1 : 0;
}
// Note: client secret is set by the user themselves via /api/account/secret,
// not by the admin. Admins can only reset password and toggle admin flag.
if (!$fields) jsonResponse(['ok' => true]);
$db->prepare('UPDATE tenants SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
jsonResponse(['ok' => true]);
}
if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
$id = (int)$m[1];
$target = getTenantById($db, $id);
if (!$target) jsonResponse(['error' => 'user not found'], 404);
if ($target['is_admin'] && adminCount($db) <= 1) {
jsonResponse(['error' => 'cannot delete the last admin'], 400);
}
if ($id === (int)$sessionTenant['id']) {
jsonResponse(['error' => 'cannot delete yourself'], 400);
}
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
jsonResponse(['ok' => true]);
}
// ── Tenant directory (visible to all authenticated tenants) ──────
// Accepts both UI session auth and client API headers, so hyprcrd
// instances can fetch the list to populate their recipient picker.
if ($route === '/api/tenants' && $method === 'GET') {
$authedTenant = $sessionTenant ?: authClient($db);
if (!$authedTenant) jsonResponse(['error' => 'unauthorized'], 401);
$rows = $db->query('SELECT id, username FROM tenants ORDER BY username ASC')->fetchAll();
jsonResponse(['tenants' => $rows, 'self' => $authedTenant['username']]);
}
+160
View File
@@ -0,0 +1,160 @@
<?php
// Message + attachment routes:
// POST /api/messages (UI session: server encrypts)
// POST /api/messages (client API: decrypt-reencrypt hop)
// GET /api/messages (client API: re-encrypt for recipient)
// DELETE /api/messages/{id} (own only)
// POST /api/attachments (client API)
// GET /api/attachments (client API)
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// $sessionTenant comes from routes/public.php earlier in the chain
// ── UI compose path: server encrypts plaintext with inter-tenant key ──
if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty($_SERVER['HTTP_X_RELAY_KEY'])) {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$title = (string)($input['title'] ?? '');
$body = (string)($input['body'] ?? '');
$recipientName = trim((string)($input['recipient'] ?? $sessionTenant['username']));
if ($body === '') jsonResponse(['error' => 'body required'], 400);
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$storedTitle = clientEncrypt($title, $interKey);
$storedBody = clientEncrypt($body, $interKey);
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
->execute([
'r' => $recipient['id'],
's' => $sessionTenant['id'],
'sn' => $sessionTenant['username'],
't' => $storedTitle,
'b' => $storedBody,
'c' => $input['color'] ?? '#38bdf8',
'tg' => $input['tags'] ?? '',
'uid' => $input['relay_uid'] ?? null,
]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
}
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
$clientTenant = authClient($db);
if ($route === '/api/messages' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$encryptedTitle = (string)($input['title'] ?? '');
$encryptedBody = (string)($input['body'] ?? '');
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400);
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
// Decrypt with sender's secret
if (empty($clientTenant['secret_encrypted'])) {
jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400);
}
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
$plainTitle = clientDecrypt($encryptedTitle, $senderSecret);
$plainBody = clientDecrypt($encryptedBody, $senderSecret);
// Re-encrypt with the inter-tenant (super-shared) key for at-rest storage
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500);
$storedTitle = clientEncrypt($plainTitle, $interKey);
$storedBody = clientEncrypt($plainBody, $interKey);
$db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)')
->execute([
'r' => $recipient['id'],
's' => $clientTenant['id'],
'sn' => $clientTenant['username'],
't' => $storedTitle,
'b' => $storedBody,
'c' => $input['color'] ?? '#38bdf8',
'tg' => $input['tags'] ?? '',
'uid' => $input['relay_uid'] ?? null,
]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
}
if ($route === '/api/messages' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (empty($clientTenant['secret_encrypted'])) {
jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400);
}
$since = $_GET['since'] ?? null;
if ($since) {
$stmt = $db->prepare("SELECT * FROM messages WHERE recipient_id = :r AND created_at > :s ORDER BY created_at ASC");
$stmt->execute(['r' => $clientTenant['id'], 's' => $since]);
} else {
$stmt = $db->prepare("SELECT * FROM messages WHERE recipient_id = :r ORDER BY created_at ASC");
$stmt->execute(['r' => $clientTenant['id']]);
}
$msgs = $stmt->fetchAll();
// Decrypt with inter-tenant key, re-encrypt with recipient's secret
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
$out = [];
foreach ($msgs as $msg) {
$plainTitle = clientDecrypt($msg['title'] ?? '', $interKey);
$plainBody = clientDecrypt($msg['body'] ?? '', $interKey);
$msg['title'] = clientEncrypt($plainTitle, $recipientSecret);
$msg['body'] = clientEncrypt($plainBody, $recipientSecret);
$out[] = $msg;
}
$db->prepare("UPDATE messages SET fetched = 1 WHERE recipient_id = :r AND fetched = 0")
->execute(['r' => $clientTenant['id']]);
jsonResponse($out);
}
if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
$tenant = $clientTenant ?: $sessionTenant;
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
$id = (int)$m[1];
$db->prepare('DELETE FROM messages WHERE id = :id AND recipient_id = :r')
->execute(['id' => $id, 'r' => $tenant['id']]);
jsonResponse(['ok' => true]);
}
if ($route === '/api/attachments' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$relayUid = (string)($input['relay_uid'] ?? '');
$filename = (string)($input['filename'] ?? '');
$data = (string)($input['data'] ?? '');
$recipientName = trim((string)($input['recipient'] ?? $clientTenant['username']));
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400);
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn')
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $filename]);
$db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)')
->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $filename, 'data' => $data]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if ($route === '/api/attachments' && $method === 'GET') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$relayUid = $_GET['relay_uid'] ?? '';
if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400);
$stmt = $db->prepare('SELECT id, relay_uid, filename, data, created_at FROM attachments WHERE recipient_id = :r AND relay_uid = :uid');
$stmt->execute(['r' => $clientTenant['id'], 'uid' => $relayUid]);
jsonResponse($stmt->fetchAll());
}
+110
View File
@@ -0,0 +1,110 @@
<?php
// Public + UI session routes:
// GET /api/status
// POST /api/setup
// POST /api/login
// POST /api/logout
// GET /api/me
//
// Each handler exits on match. Falls through to other route files if
// none of these matched.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// ── Public: status ─────────────────────────────────────────────────
if ($route === '/api/status' && $method === 'GET') {
$data = [
'setup' => $isSetup,
'has_admin' => adminCount($db) > 0,
'has_super_secret' => hasInterTenantKey($db),
];
$sessionTenant = getSessionTenant($db);
if ($sessionTenant) {
$cnt = $db->prepare('SELECT COUNT(*) FROM messages WHERE recipient_id = :r AND fetched = 0');
$cnt->execute(['r' => $sessionTenant['id']]);
$data['logged_in'] = true;
$data['username'] = $sessionTenant['username'];
$data['is_admin'] = (bool)$sessionTenant['is_admin'];
$data['inbox_count'] = (int)$cnt->fetchColumn();
$data['has_secret'] = !empty($sessionTenant['secret_hash']);
$data['password_changed'] = (bool)$sessionTenant['password_changed'];
} else {
$data['logged_in'] = false;
}
jsonResponse($data);
}
// ── Public: setup (only when no tenants exist) ────────────────────
if ($route === '/api/setup' && $method === 'POST') {
if (isRateLimited($db, 'setup', 5)) jsonResponse(['error' => 'too many attempts'], 429);
if (!$isSetup) jsonResponse(['error' => 'already configured'], 403);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$username = trim((string)($input['username'] ?? ''));
$password = (string)($input['password'] ?? '');
$superSecret = (string)($input['super_secret'] ?? '');
if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) {
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($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
// user) sets that themselves later via the account view.
$passHash = password_hash($password, PASSWORD_BCRYPT);
$db->prepare('INSERT INTO tenants (username, password_hash, is_admin) VALUES (:u, :p, 1)')
->execute([
'u' => $username,
'p' => $passHash,
]);
// Set the relay-wide super-shared key. This is the cross-tenant
// routing key — used by the relay to re-encrypt messages at rest.
setInterTenantKey($db, $MASTER_KEY, $superSecret);
$tenantId = (int)$db->lastInsertId();
$token = makeSession($db, $tenantId);
setSessionCookie($token);
jsonResponse(['ok' => true, 'username' => $username, 'is_admin' => true]);
}
// ── Public: login (UI session) ────────────────────────────────────
if ($route === '/api/login' && $method === 'POST') {
if (isRateLimited($db, 'login', 5)) jsonResponse(['error' => 'too many attempts'], 429);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$username = trim((string)($input['username'] ?? ''));
$password = (string)($input['password'] ?? '');
$tenant = getTenantByUsername($db, $username);
if (!$tenant || !password_verify($password, $tenant['password_hash'])) {
jsonResponse(['error' => 'wrong username or password'], 401);
}
$token = makeSession($db, (int)$tenant['id']);
setSessionCookie($token);
jsonResponse([
'ok' => true,
'username' => $tenant['username'],
'is_admin' => (bool)$tenant['is_admin'],
]);
}
if ($route === '/api/logout' && $method === 'POST') {
destroySession($db);
jsonResponse(['ok' => true]);
}
// ── Session-authenticated: /api/me ────────────────────────────────
$sessionTenant = getSessionTenant($db);
if ($route === '/api/me' && $method === 'GET') {
if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401);
jsonResponse([
'username' => $sessionTenant['username'],
'is_admin' => (bool)$sessionTenant['is_admin'],
'created_at' => $sessionTenant['created_at'],
]);
}
+24
View File
@@ -0,0 +1,24 @@
<?php
// Tenant DB lookup helpers.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
function getTenantById(PDO $db, int $id): ?array {
$stmt = $db->prepare('SELECT * FROM tenants WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
function getTenantByUsername(PDO $db, string $username): ?array {
$stmt = $db->prepare('SELECT * FROM tenants WHERE username = :u');
$stmt->execute(['u' => $username]);
return $stmt->fetch() ?: null;
}
function tenantCount(PDO $db): int {
return (int)$db->query('SELECT COUNT(*) FROM tenants')->fetchColumn();
}
function adminCount(PDO $db): int {
return (int)$db->query('SELECT COUNT(*) FROM tenants WHERE is_admin = 1')->fetchColumn();
}
+44 -450
View File
@@ -1,62 +1,32 @@
<?php
// ── hyprcrd Relay — Mobile Inbox ──
// Single-file service: API + Mobile UI + DB
// ── hyprcrd Relay — Multi-tenant ──
//
// Entry point. Loads bootstrap + helpers, dispatches API routes,
// renders the UI layout. The actual logic lives in inc/ and ui/.
//
// Architecture (encryption flow for cross-tenant messages):
// 1. Client A encrypts a message with A's tenant secret
// 2. POST /api/messages, server authenticates A, decrypts with A's secret
// 3. Server re-encrypts with the inter-tenant key (relay-wide shared
// key, generated at install, stored encrypted with the master key)
// 4. Stored in messages table with recipient_id = B's tenant id
// 5. B fetches /api/messages, server decrypts with inter-tenant key,
// re-encrypts with B's tenant secret, returns to client B
// 6. Client B decrypts with its own secret
//
// The relay sees plaintext briefly during the cross-tenant hop. This
// is not strict end-to-end — that requires per-pair key exchange and
// is on the roadmap for the planned encrypted chat feature. See
// README.md.
error_reporting(0);
$dbPath = __DIR__ . '/data/relay.db';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);
define('RELAY_BOOTSTRAPPED', true);
$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('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
$db->exec('CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT DEFAULT "",
body TEXT NOT NULL,
color TEXT DEFAULT "#38bdf8",
tags TEXT DEFAULT "",
relay_uid TEXT DEFAULT NULL,
fetched INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)');
// Migration: add relay_uid if missing
$msgCols = array_column($db->query('PRAGMA table_info(messages)')->fetchAll(), 'name');
if (!in_array('relay_uid', $msgCols)) {
$db->exec('ALTER TABLE messages ADD COLUMN relay_uid TEXT DEFAULT NULL');
}
require_once __DIR__ . '/inc/bootstrap.php';
require_once __DIR__ . '/inc/crypto.php';
require_once __DIR__ . '/inc/tenants.php';
require_once __DIR__ . '/inc/auth.php';
$db->exec('CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
relay_uid TEXT NOT NULL,
filename TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)');
// Cleanup: delete older than 24h
$db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')");
$db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')");
// Rate-limit table
$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
)');
$db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')");
function isRateLimited(PDO $db, string $endpoint, int $max = 10): bool {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$count = $db->prepare("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-1 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;
}
// ── Routing ────────────────────────────────────────────────────────
$route = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
@@ -64,413 +34,37 @@ if ($base !== '/') $route = substr($route, strlen($base));
$route = '/' . ltrim($route ?: '/', '/');
$method = $_SERVER['REQUEST_METHOD'];
$getKey = fn() => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: null;
$getPassword = fn() => $db->query("SELECT value FROM settings WHERE key = 'relay_password'")->fetchColumn() ?: null;
$isSetup = !$getKey() || !$getPassword();
$isSetup = tenantCount($db) === 0;
// ── Security headers ──
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 ──
// ── API ────────────────────────────────────────────────────────────
if (str_starts_with($route, '/api/')) {
header('Content-Type: application/json');
// CORS: allow same-origin + configured hyprcrd instances
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
}
header('Access-Control-Allow-Headers: Content-Type, X-Relay-Key');
header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
if ($method === 'OPTIONS') { exit; }
header('Access-Control-Allow-Headers: Content-Type, X-Relay-User, X-Relay-Key');
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS');
if ($method === 'OPTIONS') exit;
// Reject oversized requests (max 256KB)
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
$sizeLimit = ($route === '/api/attachments') ? 15728640 : 262144; // 15MB for attachments, 256KB otherwise
if ($contentLength > $sizeLimit) { http_response_code(413); echo json_encode(['error' => 'payload too large']); exit; }
$sizeLimit = ($route === '/api/attachments') ? 15728640 : 262144;
if ($contentLength > $sizeLimit) jsonResponse(['error' => 'payload too large'], 413);
// Setup
if ($route === '/api/setup' && $method === 'POST') {
if (isRateLimited($db, 'setup', 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts']); exit; }
if (!$isSetup && $getKey() && $getPassword()) { http_response_code(403); echo json_encode(['error' => 'already configured']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
$key = $input['key'] ?? '';
if (strlen($password) < 4) { http_response_code(400); echo json_encode(['error' => 'password must be at least 4 characters']); exit; }
if (strlen($key) < 4) { http_response_code(400); echo json_encode(['error' => 'shared secret must be at least 4 characters']); exit; }
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_password', :v)")->execute(['v' => password_hash($password, PASSWORD_BCRYPT)]);
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => password_hash($key, PASSWORD_BCRYPT)]);
echo json_encode(['ok' => true]); exit;
// Each route file contains its handlers as if-blocks that exit on
// match. Order matters: public.php sets up $sessionTenant which
// admin.php and messages.php use later in the chain.
require __DIR__ . '/inc/routes/public.php';
require __DIR__ . '/inc/routes/account.php';
require __DIR__ . '/inc/routes/admin.php';
require __DIR__ . '/inc/routes/messages.php';
jsonResponse(['error' => 'not found'], 404);
}
// Verify UI password
if ($route === '/api/login' && $method === 'POST') {
if (isRateLimited($db, 'login', 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
$storedHash = $getPassword();
if ($storedHash && password_verify($password, $storedHash)) {
echo json_encode(['ok' => true]); exit;
}
http_response_code(401); echo json_encode(['error' => 'wrong password']); exit;
}
// ── UI ─────────────────────────────────────────────────────────────
// Verify shared secret (for client-side key check)
if ($route === '/api/verify' && $method === 'POST') {
if (isRateLimited($db, 'verify', 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$key = $input['key'] ?? '';
$storedHash = $getKey();
if ($storedHash && password_verify($key, $storedHash)) {
echo json_encode(['ok' => true]); exit;
}
http_response_code(401); echo json_encode(['error' => 'wrong key']); exit;
}
// Status
if ($route === '/api/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'messages' => (int)$db->query('SELECT COUNT(*) FROM messages WHERE fetched = 0')->fetchColumn()]); exit;
}
// Auth check for all other endpoints (shared secret)
$authKey = $_SERVER['HTTP_X_RELAY_KEY'] ?? '';
$storedHash = $getKey();
if (!$storedHash || !password_verify($authKey, $storedHash)) {
http_response_code(401); echo json_encode(['error' => 'unauthorized']); exit;
}
// Post message
if ($route === '/api/messages' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$body = $input['body'] ?? '';
if (!$body) { http_response_code(400); echo json_encode(['error' => 'body required']); exit; }
$stmt = $db->prepare('INSERT INTO messages (title, body, color, tags, relay_uid) VALUES (:t, :b, :c, :tags, :uid)');
$stmt->execute([
't' => $input['title'] ?? '',
'b' => $body,
'c' => $input['color'] ?? '#38bdf8',
'tags' => $input['tags'] ?? '',
'uid' => $input['relay_uid'] ?? null,
]);
echo json_encode(['ok' => true, 'id' => $db->lastInsertId()]); exit;
}
// Get messages (for hyprcrd polling)
if ($route === '/api/messages' && $method === 'GET') {
$since = $_GET['since'] ?? null;
if ($since) {
$stmt = $db->prepare("SELECT * FROM messages WHERE created_at > :s ORDER BY created_at ASC");
$stmt->execute(['s' => $since]);
} else {
$stmt = $db->query("SELECT * FROM messages ORDER BY created_at ASC");
}
$msgs = $stmt->fetchAll();
$db->exec("UPDATE messages SET fetched = 1 WHERE fetched = 0");
echo json_encode($msgs); exit;
}
// Delete message
if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') {
$db->prepare('DELETE FROM messages WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
// Upload attachment
if ($route === '/api/attachments' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$relayUid = $input['relay_uid'] ?? '';
$filename = $input['filename'] ?? '';
$data = $input['data'] ?? '';
if (!$relayUid || !$filename || !$data) { http_response_code(400); echo json_encode(['error' => 'relay_uid, filename, and data required']); exit; }
// Replace existing attachment with same uid+filename
$db->prepare('DELETE FROM attachments WHERE relay_uid = :uid AND filename = :fn')->execute(['uid' => $relayUid, 'fn' => $filename]);
$db->prepare('INSERT INTO attachments (relay_uid, filename, data) VALUES (:uid, :fn, :data)')->execute(['uid' => $relayUid, 'fn' => $filename, 'data' => $data]);
echo json_encode(['ok' => true, 'id' => $db->lastInsertId()]); exit;
}
// Get attachments for a relay_uid
if ($route === '/api/attachments' && $method === 'GET') {
$relayUid = $_GET['relay_uid'] ?? '';
if (!$relayUid) { http_response_code(400); echo json_encode(['error' => 'relay_uid required']); exit; }
$stmt = $db->prepare('SELECT id, relay_uid, filename, data, created_at FROM attachments WHERE relay_uid = :uid');
$stmt->execute(['uid' => $relayUid]);
echo json_encode($stmt->fetchAll()); exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
}
// ── Mobile UI ──
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1e293b">
<title>hyprcrd relay</title>
<style>
@font-face { font-family:'Silkscreen'; font-weight:400; src:url('fonts/silkscreen-400.ttf') format('truetype'); }
@font-face { font-family:'Silkscreen'; font-weight:700; src:url('fonts/silkscreen-700.ttf') format('truetype'); }
@font-face { font-family:'JetBrains Mono'; font-weight:400; src:url('fonts/jetbrains-mono-400.ttf') format('truetype'); }
:root { --bg:#1e293b; --surface:#273349; --border:#384a64; --dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9; --accent:#38bdf8; --danger:#fb7185; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'JetBrains Mono',monospace; background:var(--bg); color:var(--text); height:100dvh; display:flex; flex-direction:column; overflow:hidden; }
.pixel { font-family:'Silkscreen',cursive; }
.hidden { display:none !important; }
header { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; flex-shrink:0; }
header h1 { font-family:'Silkscreen',cursive; font-size:14px; color:var(--accent); }
header .dot { width:8px; height:8px; border-radius:50%; background:var(--accent); animation:pulse 2s ease-in-out infinite; }
header .logout { background:none; border:none; color:var(--dim); cursor:pointer; font-size:14px; padding:4px; }
header .logout:hover { color:var(--danger); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
.compose { flex:1; display:flex; flex-direction:column; padding:12px; gap:8px; min-height:0; }
.compose input { background:var(--surface); border:1px solid var(--border); color:var(--bright); padding:8px 12px; font-size:13px; font-family:'Silkscreen',cursive; border-radius:4px; }
.compose input:focus { border-color:var(--accent); outline:none; }
.compose textarea { flex:1; background:var(--surface); border:1px solid var(--border); color:var(--text); padding:12px; font-size:14px; font-family:inherit; border-radius:4px; resize:none; line-height:1.6; }
.compose textarea:focus { border-color:var(--accent); outline:none; }
.actions { padding:8px 12px; border-top:1px solid var(--border); display:flex; gap:8px; flex-shrink:0; }
.btn { padding:10px 20px; border:none; border-radius:4px; font-family:'Silkscreen',cursive; font-size:11px; cursor:pointer; transition:opacity .15s; }
.btn:active { opacity:.7; }
.btn-send { background:var(--accent); color:var(--bg); flex:1; }
.btn-clear { background:transparent; border:1px solid var(--border); color:var(--muted); }
.toast { position:fixed; top:12px; left:50%; transform:translateX(-50%); background:var(--accent); color:var(--bg); padding:8px 20px; border-radius:4px; font-family:'Silkscreen',cursive; font-size:11px; opacity:0; transition:opacity .3s; z-index:10; }
.toast.show { opacity:1; }
.pending { padding:0 12px 12px; overflow-y:auto; flex-shrink:0; max-height:30dvh; }
.pending-item { display:flex; align-items:center; gap:8px; padding:8px; border-bottom:1px solid var(--border); font-size:12px; }
.pending-item .time { font-family:'Silkscreen',cursive; font-size:9px; color:var(--dim); flex-shrink:0; }
.pending-item .text { flex:1; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pending-item .del { background:none; border:none; color:var(--dim); cursor:pointer; font-size:16px; padding:4px; }
.pending-item .fetched { color:var(--accent); font-size:10px; }
.auth-form { display:flex; flex-direction:column; align-items:center; padding:48px 24px; gap:12px; }
.auth-form input { width:100%; max-width:280px; background:var(--surface); border:1px solid var(--border); color:var(--bright); padding:8px 12px; font-size:13px; font-family:inherit; border-radius:4px; }
.auth-form input:focus { border-color:var(--accent); outline:none; }
.auth-form .btn { width:100%; max-width:280px; }
.auth-form .label { font-size:10px; color:var(--dim); width:100%; max-width:280px; margin-top:4px; }
</style>
</head>
<body>
<header>
<span class="dot"></span>
<h1>hyprcrd relay</h1>
<span style="flex:1"></span>
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
<button id="logout-btn" class="logout hidden" onclick="doLogout()" title="logout">✕</button>
</header>
<!-- Setup: first time -->
<div id="view-setup" class="auth-form hidden">
<p class="pixel" style="font-size:11px;color:var(--muted);">Set up relay</p>
<span class="pixel label">password (for login)</span>
<input type="password" id="setup-pw" placeholder="min 4 chars">
<span class="pixel label" style="margin-top:8px;">shared secret (for encryption)</span>
<input type="password" id="setup-key" placeholder="min 4 chars">
<button class="btn btn-send" onclick="doSetup()" style="margin-top:4px;">activate</button>
<div id="setup-err" class="pixel" style="font-size:10px;color:var(--danger);"></div>
</div>
<!-- Login -->
<div id="view-login" class="auth-form hidden">
<p class="pixel" style="font-size:11px;color:var(--muted);">Login</p>
<input type="password" id="login-pw" placeholder="password">
<button class="btn btn-send" onclick="doLogin()">unlock</button>
<div id="login-err" class="pixel" style="font-size:10px;color:var(--danger);"></div>
</div>
<!-- Shared secret prompt (after login, if missing from localStorage) -->
<div id="view-secret" class="auth-form hidden">
<p class="pixel" style="font-size:11px;color:var(--muted);">Enter shared secret for encryption</p>
<input type="password" id="secret-key" placeholder="shared secret">
<button class="btn btn-send" onclick="doSetSecret()">confirm</button>
<div id="secret-err" class="pixel" style="font-size:10px;color:var(--danger);"></div>
</div>
<!-- Compose -->
<div id="view-compose" class="compose hidden">
<input type="text" id="msg-title" placeholder="title (optional)" autocomplete="off">
<textarea id="msg-body" placeholder="write something..." autofocus></textarea>
</div>
<div id="view-pending" class="pending hidden"></div>
<div id="view-actions" class="actions hidden">
<button class="btn btn-clear" onclick="document.getElementById('msg-body').value='';document.getElementById('msg-title').value='';document.getElementById('msg-body').focus();">clear</button>
<button class="btn btn-send" onclick="sendMsg()">send</button>
</div>
<div class="toast" id="toast"></div>
<script>
const PW_STORE = 'hyprcrd-relay-pw';
const KEY_STORE = 'hyprcrd-relay-key';
const isSetup = <?= json_encode($isSetup) ?>;
function getKey() { return localStorage.getItem(KEY_STORE) || ''; }
function getPw() { return localStorage.getItem(PW_STORE) || ''; }
function deriveEncKey(secret) {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
.then(buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('').substring(0, 32));
}
async function encrypt(text) {
const key = getKey();
if (!key) return text;
const encKey = await deriveEncKey(key);
const iv = crypto.getRandomValues(new Uint8Array(16));
const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(encKey), 'AES-CBC', false, ['encrypt']);
const cipher = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, keyMaterial, new TextEncoder().encode(text));
const combined = new Uint8Array(iv.length + cipher.byteLength);
combined.set(iv); combined.set(new Uint8Array(cipher), iv.length);
return 'HYPRCRD-RELAY-V1\n' + btoa(String.fromCharCode(...combined));
}
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
function showView(name) {
['setup','login','secret','compose'].forEach(v => {
document.getElementById('view-' + v)?.classList.add('hidden');
});
document.getElementById('view-pending')?.classList.add('hidden');
document.getElementById('view-actions')?.classList.add('hidden');
document.getElementById('logout-btn')?.classList.add('hidden');
document.getElementById('view-' + name)?.classList.remove('hidden');
if (name === 'compose') {
document.getElementById('view-pending')?.classList.remove('hidden');
document.getElementById('view-actions')?.classList.remove('hidden');
document.getElementById('logout-btn')?.classList.remove('hidden');
document.getElementById('msg-body')?.focus();
}
if (name === 'login' || name === 'secret') {
document.getElementById('logout-btn')?.classList.remove('hidden');
}
}
async function doSetup() {
const pw = document.getElementById('setup-pw').value;
const key = document.getElementById('setup-key').value;
const err = document.getElementById('setup-err');
if (pw.length < 4) { err.textContent = 'password: min 4 chars'; return; }
if (key.length < 4) { err.textContent = 'secret: min 4 chars'; return; }
const res = await fetch('api/setup', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password: pw, key }) });
const data = await res.json();
if (data.ok) {
localStorage.setItem(PW_STORE, '1');
localStorage.setItem(KEY_STORE, key);
location.reload();
} else {
err.textContent = data.error || 'failed';
}
}
async function doLogin() {
const pw = document.getElementById('login-pw').value;
const err = document.getElementById('login-err');
if (!pw) return;
const res = await fetch('api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ password: pw }) });
if (res.ok) {
localStorage.setItem(PW_STORE, '1');
if (!getKey()) { showView('secret'); return; }
showView('compose');
loadPending();
} else {
err.textContent = 'wrong password';
}
}
async function doSetSecret() {
const key = document.getElementById('secret-key').value;
const err = document.getElementById('secret-err');
if (!key) return;
const res = await fetch('api/verify', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ key }) });
if (res.ok) {
localStorage.setItem(KEY_STORE, key);
showView('compose');
loadPending();
} else {
err.textContent = 'wrong secret';
}
}
function doLogout() {
localStorage.removeItem(PW_STORE);
showView('login');
document.getElementById('login-pw').value = '';
document.getElementById('login-err').textContent = '';
document.getElementById('header-status').textContent = '';
}
async function loadPending() {
try {
const res = await fetch('api/status');
const data = await res.json();
document.getElementById('header-status').textContent = data.messages + ' pending';
} catch {}
}
async function sendMsg() {
const title = document.getElementById('msg-title')?.value || '';
const body = document.getElementById('msg-body')?.value || '';
if (!body.trim()) return;
const key = getKey();
if (!key) { toast('no encryption key'); return; }
const encTitle = await encrypt(title);
const encBody = await encrypt(body);
const res = await fetch('api/messages', {
method:'POST',
headers:{ 'Content-Type':'application/json', 'X-Relay-Key': key },
body: JSON.stringify({ title: encTitle, body: encBody }),
});
const data = await res.json();
if (data.ok) {
toast('sent');
document.getElementById('msg-body').value = '';
document.getElementById('msg-title').value = '';
document.getElementById('msg-body').focus();
loadPending();
} else {
if (data.error === 'unauthorized') { doLogout(); toast('session expired'); return; }
toast(data.error || 'failed');
}
}
async function delMsg(id, btn) {
const key = getKey();
await fetch('api/messages/' + id, { method:'DELETE', headers:{'X-Relay-Key':key} });
btn.closest('.pending-item')?.remove();
loadPending();
}
document.getElementById('msg-body')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
document.getElementById('login-pw')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); doLogin(); }
});
document.getElementById('secret-key')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); doSetSecret(); }
});
// Init
(async () => {
if (isSetup) { showView('setup'); return; }
const hasPw = getPw();
if (!hasPw) { showView('login'); return; }
if (getKey()) { showView('compose'); loadPending(); return; }
showView('secret');
})();
</script>
</body>
</html>
require __DIR__ . '/ui/layout.php';
+449
View File
@@ -0,0 +1,449 @@
// hyprcrd relay UI
let me = null;
let allTenants = [];
function $(id) { return document.getElementById(id); }
function toast(msg, isError = false) {
const t = $('toast');
t.textContent = msg;
t.classList.toggle('err', isError);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
}
async function api(path, opts = {}) {
const res = await fetch(path, {
method: opts.method || 'GET',
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
body: opts.body ? JSON.stringify(opts.body) : undefined,
credentials: 'same-origin',
});
return res.json().catch(() => ({ error: 'invalid response' }));
}
function showView(name) {
// If the user hasn't changed their password yet, force them to that view
// unless they're currently in setup/login/change-password (admin/anon flows)
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password';
}
// Block admin view on mobile — even if someone tries programmatically
if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) {
name = 'compose';
}
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
if (['compose', 'account', 'admin'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-buttons').style.display = 'flex';
$('nav-compose').classList.toggle('active', name === 'compose');
$('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin');
} else {
// Setup, login, and forced change-password hide all nav
$('nav-buttons').classList.add('hidden');
}
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
loadInbox();
$('compose-body')?.focus();
} else if (name === 'admin') {
loadUsers();
} else if (name === 'account') {
loadAccountSecret();
} else if (name === 'change-password') {
$('cp-current')?.focus();
$('cp-err').textContent = '';
}
}
async function doChangePassword() {
const current = $('cp-current').value;
const newPw = $('cp-new').value;
const newPw2 = $('cp-new2').value;
const err = $('cp-err');
err.textContent = '';
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
if (newPw.length < 4) { err.textContent = 'new password too short'; return; }
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
const res = await api('api/account/password', { method: 'POST', body: { current, new: newPw } });
if (res.ok) {
toast('password changed');
$('cp-current').value = '';
$('cp-new').value = '';
$('cp-new2').value = '';
// After password change, route based on whether they have a secret
const status = await refreshMe();
if (!status.has_secret) {
showView('account');
toast('now set your client secret');
} else {
showView('compose');
}
} else {
err.textContent = res.error || 'failed';
}
}
// ── Setup ──
async function doSetup() {
const username = $('setup-username').value.trim();
const password = $('setup-password').value;
const superSecret = $('setup-super-secret').value;
const err = $('setup-err');
err.textContent = '';
if (!username) { err.textContent = 'username required'; return; }
if (password.length < 4) { err.textContent = 'password too short'; 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 } });
if (res.ok) {
await refreshMe();
showView('account');
toast('admin created — now set your client secret');
} else {
err.textContent = res.error || 'failed';
}
}
// ── Login ──
async function doLogin() {
const username = $('login-username').value.trim();
const password = $('login-password').value;
const err = $('login-err');
err.textContent = '';
if (!username || !password) { err.textContent = 'username and password required'; return; }
const res = await api('api/login', { method: 'POST', body: { username, password } });
if (res.ok) {
const status = await refreshMe();
routePostLogin(status);
} else {
err.textContent = res.error || 'login failed';
}
}
// Decide which view to land on after a successful login / on initial load.
// Order: forced password change → set client secret → compose.
function routePostLogin(status) {
if (!status.password_changed) {
showView('change-password');
// Pre-fill the current-password input from what they just typed if
// we're coming from the login form (otherwise it's empty)
const lp = $('login-password');
if (lp && lp.value) {
$('cp-current').value = lp.value;
lp.value = '';
}
toast('change your password before continuing');
} else if (!status.has_secret) {
showView('account');
toast('set your client secret to start using the relay');
} else {
showView('compose');
}
}
async function doLogout() {
await api('api/logout', { method: 'POST' });
me = null;
$('nav-user').textContent = '';
$('nav-admin').classList.add('hidden');
showView('login');
}
async function refreshMe() {
const status = await api('api/status');
if (status.logged_in) {
me = {
username: status.username,
is_admin: status.is_admin,
has_secret: status.has_secret,
password_changed: status.password_changed,
};
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
$('nav-admin').classList.toggle('hidden', !me.is_admin);
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
}
return status;
}
// ── Compose ──
async function loadTenants() {
const res = await api('api/tenants');
if (res.tenants) allTenants = res.tenants;
}
function populateRecipientPicker() {
const sel = $('compose-recipient');
const field = $('compose-recipient-field');
sel.innerHTML = '';
// Always include "you" first as the default recipient (the value
// the form submits even when the picker is hidden)
if (me) {
const opt = document.createElement('option');
opt.value = me.username;
opt.textContent = `${me.username} (you)`;
sel.appendChild(opt);
}
for (const t of allTenants) {
if (me && t.username === me.username) continue;
const opt = document.createElement('option');
opt.value = t.username;
opt.textContent = t.username;
sel.appendChild(opt);
}
// Solo tenant → no choice to make. Hide the picker entirely so
// the compose form is just title + body.
if (field) {
field.classList.toggle('hidden', allTenants.length <= 1);
}
}
function clearCompose() {
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-body').focus();
setComposeStatus('');
}
function setComposeStatus(text, kind = '') {
const el = $('compose-status');
el.textContent = text;
el.className = 'status ' + kind;
}
async function sendCompose() {
const title = $('compose-title').value;
const body = $('compose-body').value;
const recipient = $('compose-recipient').value;
if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
setComposeStatus('sending...');
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
if (res.ok) {
setComposeStatus(`sent to ${res.recipient}`, 'ok');
$('compose-title').value = '';
$('compose-body').value = '';
$('compose-body').focus();
loadInbox();
refreshMe();
} else {
setComposeStatus(res.error || 'failed', 'err');
}
}
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
async function loadInbox() {
const status = await api('api/status');
$('inbox-count').textContent = status.inbox_count || 0;
const list = $('inbox-list');
if (!status.inbox_count) {
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
} else {
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
}
}
// ── Admin: users ──
async function loadUsers() {
const res = await api('api/users');
if (res.error) { toast(res.error, true); return; }
const users = res.users || [];
$('users-count').textContent = users.length;
const list = $('users-list');
if (!users.length) {
list.innerHTML = '<div class="empty">no users yet</div>';
return;
}
list.innerHTML = users.map(u => `
<div class="user-row" data-id="${u.id}">
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
<div class="info">
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
<div class="meta">created ${u.created_at}</div>
</div>
<div class="actions">
<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>` : ''}
</div>
</div>
`).join('');
}
async function createUser() {
const username = $('new-username').value.trim();
const password = $('new-password').value;
const isAdmin = $('new-is-admin').checked;
const status = $('create-user-status');
status.textContent = 'creating...';
status.className = 'status';
const res = await api('api/users', { method: 'POST', body: { username, password, is_admin: isAdmin } });
if (res.ok) {
status.textContent = 'created — they set their own client secret on first login';
status.className = 'status ok';
$('new-username').value = '';
$('new-password').value = '';
$('new-is-admin').checked = false;
loadUsers();
setTimeout(() => { status.textContent = ''; }, 3000);
} else {
status.textContent = res.error || 'failed';
status.className = 'status err';
}
}
async function resetUserPassword(id, username) {
const newPw = prompt(`new password for ${username} (min 4 chars):`);
if (!newPw || newPw.length < 4) { toast('password too short', true); return; }
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
if (res.ok) toast('password updated');
else toast(res.error || 'failed', true);
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return;
const res = await api('api/users/' + id, { method: 'DELETE' });
if (res.ok) { toast('user deleted'); loadUsers(); }
else toast(res.error || 'failed', true);
}
// ── Account: client secret management ──
let currentAccountSecret = null;
async function loadAccountSecret() {
setAccountStatus('');
const res = await api('api/account/secret');
if (res.has_secret) {
currentAccountSecret = res.secret;
$('account-current').classList.remove('hidden');
$('account-no-secret').classList.add('hidden');
$('account-secret-display').type = 'password';
$('account-secret-display').value = currentAccountSecret;
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye"></i> show';
} else {
currentAccountSecret = null;
$('account-current').classList.add('hidden');
$('account-no-secret').classList.remove('hidden');
}
$('account-secret-input').value = '';
}
function toggleAccountSecret() {
const inp = $('account-secret-display');
const btn = $('account-secret-toggle');
if (inp.type === 'password') {
inp.type = 'text';
btn.innerHTML = '<i class="ph ph-eye-slash"></i> hide';
} else {
inp.type = 'password';
btn.innerHTML = '<i class="ph ph-eye"></i> show';
}
}
async function copyAccountSecret() {
if (!currentAccountSecret) return;
try {
await navigator.clipboard.writeText(currentAccountSecret);
toast('secret copied to clipboard');
} catch {
toast('copy failed — show and copy manually', true);
}
}
async function saveAccountSecret() {
const secret = $('account-secret-input').value;
if (secret.length < 8) { setAccountStatus('secret needs at least 8 chars', 'err'); return; }
setAccountStatus('saving...');
const res = await api('api/account/secret', { method: 'POST', body: { secret } });
if (res.ok) {
setAccountStatus('saved', 'ok');
await loadAccountSecret();
await refreshMe();
setTimeout(() => setAccountStatus(''), 2000);
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
async function generateAccountSecret() {
if (currentAccountSecret) {
if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return;
}
setAccountStatus('generating...');
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
if (res.ok) {
setAccountStatus('generated — copy it to your hyprcrd clients now', 'ok');
await loadAccountSecret();
await refreshMe();
// Auto-show the new secret so user can copy
$('account-secret-display').type = 'text';
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye-slash"></i> hide';
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
async function clearAccountSecret() {
if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return;
const res = await api('api/account/secret', { method: 'DELETE' });
if (res.ok) {
setAccountStatus('cleared', 'ok');
await loadAccountSecret();
await refreshMe();
} else {
setAccountStatus(res.error || 'failed', 'err');
}
}
function setAccountStatus(text, kind = '') {
const el = $('account-status');
if (!el) return;
el.textContent = text;
el.className = 'status ' + kind;
}
// ── Helpers ──
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
// ── Init ──
(async () => {
const status = await api('api/status');
if (status.setup) {
showView('setup');
} else if (!status.logged_in) {
showView('login');
} else {
await refreshMe();
routePostLogin(status);
}
})();
// Keyboard
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
if (document.activeElement?.id === 'login-password') { e.preventDefault(); doLogin(); }
if (document.activeElement?.id === 'setup-secret') { e.preventDefault(); doSetup(); }
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
}
});
+186
View File
@@ -0,0 +1,186 @@
<?php
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1e293b">
<title>hyprcrd relay</title>
<link rel="stylesheet" href="fonts/phosphor.css">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<header>
<span class="dot"></span>
<h1>hyprcrd relay</h1>
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
<span class="spacer"></span>
<div id="nav-buttons" class="hidden" style="display:flex;gap:6px;align-items:center;">
<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-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 class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
</div>
</header>
<main>
<!-- ── Setup view (first install) ── -->
<div id="view-setup" class="auth-form hidden">
<h2>welcome to relay</h2>
<p>This is the first time the relay is being set up. The user you create here will become the <strong>admin</strong>. You'll also set the <strong>super-shared secret</strong> — a relay-wide key used to route messages between tenants.</p>
<div class="field">
<label>admin username</label>
<input type="text" id="setup-username" placeholder="alice" autocomplete="username">
</div>
<div class="field">
<label>admin password</label>
<input type="password" id="setup-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<div class="field">
<label>super-shared secret</label>
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
<p style="font-size:9px;color:var(--dim);margin-top:4px;line-height:1.4;">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
</div>
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
<div id="setup-err" class="err"></div>
</div>
<!-- ── Login view ── -->
<div id="view-login" class="auth-form hidden">
<h2>login</h2>
<div class="field">
<label>username</label>
<input type="text" id="login-username" autocomplete="username">
</div>
<div class="field">
<label>password</label>
<input type="password" id="login-password" autocomplete="current-password">
</div>
<button class="btn btn-primary" onclick="doLogin()"><i class="ph ph-sign-in"></i> unlock</button>
<div id="login-err" class="err"></div>
</div>
<!-- ── Compose view ── -->
<div id="view-compose" class="hidden">
<div class="compose-card">
<h2><i class="ph ph-paper-plane-tilt"></i> compose</h2>
<div class="field" id="compose-recipient-field">
<label>recipient</label>
<select id="compose-recipient"></select>
</div>
<div class="field">
<label>title (optional)</label>
<input type="text" id="compose-title" autocomplete="off">
</div>
<div class="field">
<label>body</label>
<textarea id="compose-body" placeholder="write something..."></textarea>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
<span class="spacer"></span>
<span id="compose-status" class="status"></span>
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
</div>
</div>
<div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span></h2>
<div id="inbox-list"></div>
</div>
</div>
<!-- ── Forced password change (first login) ── -->
<div id="view-change-password" class="auth-form hidden">
<h2>change your password</h2>
<p>This is your first login. Please choose a new password before you can use the relay.</p>
<div class="field">
<label>current password</label>
<input type="password" id="cp-current" placeholder="the one the admin gave you" autocomplete="current-password">
</div>
<div class="field">
<label>new password</label>
<input type="password" id="cp-new" placeholder="min 4 chars" autocomplete="new-password">
</div>
<div class="field">
<label>confirm new password</label>
<input type="password" id="cp-new2" placeholder="repeat" autocomplete="new-password">
</div>
<button class="btn btn-primary" onclick="doChangePassword()"><i class="ph ph-key"></i> change password</button>
<div id="cp-err" class="err"></div>
</div>
<!-- ── Account view ── -->
<div id="view-account" class="hidden">
<div class="section">
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
<p style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:14px;">
Your hyprcrd clients use this secret to encrypt messages before sending them to the relay.
All your devices share the same secret. Other tenants never see it. The relay stores it
encrypted with the master key so it can route your messages.
</p>
<div id="account-no-secret" class="hidden notice warn" style="margin-bottom:14px;">
⚠ no secret set yet — your hyprcrd clients can't connect until you generate or set one below
</div>
<div id="account-current" class="hidden" style="margin-bottom:14px;">
<label class="field-label" style="display:block;margin-bottom:4px;">current secret</label>
<div style="display:flex;gap:8px;align-items:stretch;">
<input id="account-secret-display" type="password" readonly class="w-full" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
<button class="btn btn-secondary" onclick="toggleAccountSecret()" id="account-secret-toggle"><i class="ph ph-eye"></i> show</button>
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
<div>
<label class="field-label">set your own secret</label>
<div style="display:flex;gap:8px;">
<input id="account-secret-input" type="text" placeholder="min 8 chars" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
</div>
</div>
<div style="text-align:center;color:var(--dim);font-family:var(--pixel);font-size:9px;">— or —</div>
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
</div>
<div id="account-status" class="status" style="margin-top:12px;"></div>
<div style="margin-top:24px;padding-top:14px;border-top:1px solid var(--border);">
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
</div>
</div>
</div>
<!-- ── Admin view ── -->
<div id="view-admin" class="hidden">
<div class="section">
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
<div id="users-list"></div>
<div class="create-user-form">
<div>
<label for="new-username" class="field-label">username</label>
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
</div>
<div>
<label for="new-password" class="field-label">password</label>
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<label class="checkbox">
<input type="checkbox" id="new-is-admin"> grant admin privileges
</label>
<p style="font-size:9px;color:var(--dim);line-height:1.4;margin-top:2px;">The new user sets their own client secret on first login.</p>
<div class="actions">
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
<span id="create-user-status" class="status"></span>
</div>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script src="ui/app.js"></script>
</body>
</html>
+407
View File
@@ -0,0 +1,407 @@
@font-face { font-family:'Silkscreen'; font-weight:400; src:url('../fonts/silkscreen-400.ttf') format('truetype'); }
@font-face { font-family:'Silkscreen'; font-weight:700; src:url('../fonts/silkscreen-700.ttf') format('truetype'); }
@font-face { font-family:'JetBrains Mono'; font-weight:400; src:url('../fonts/jetbrains-mono-400.ttf') format('truetype'); }
:root {
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
--dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9;
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185;
--pixel:'Silkscreen', cursive;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family:'JetBrains Mono', monospace;
background:var(--bg); color:var(--text);
min-height:100dvh; display:flex; flex-direction:column;
}
.pixel { font-family:var(--pixel); }
.hidden { display:none !important; }
.shrink { flex-shrink:0; }
/* ── Header ── */
header {
padding:12px 20px; border-bottom:1px solid var(--border);
display:flex; align-items:center; gap:12px; flex-shrink:0;
background:var(--surface);
}
header h1 { font-family:var(--pixel); font-size:14px; color:var(--accent); }
header .dot {
width:8px; height:8px; border-radius:50%;
background:var(--accent); animation:pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
header .spacer { flex:1; }
header .user {
font-family:var(--pixel); font-size:10px; color:var(--muted);
display:flex; align-items:center; gap:6px;
}
header .user .badge {
background:var(--accent); color:var(--bg);
padding:1px 6px; font-size:8px; border-radius:2px;
}
header .nav-btn {
background:none; border:1px solid var(--border); color:var(--muted);
cursor:pointer; padding:5px 10px;
font-family:var(--pixel); font-size:9px; border-radius:3px;
transition:all .15s;
}
header .nav-btn:hover { color:var(--accent); border-color:var(--accent); }
header .nav-btn.active { background:var(--accent); color:var(--bg); border-color:var(--accent); }
header .nav-btn.danger:hover { color:var(--danger); border-color:var(--danger); }
/* ── Layouts ── */
main {
flex:1; display:flex; flex-direction:column;
padding:20px; gap:16px; max-width:920px; width:100%;
margin:0 auto;
}
@media (max-width: 600px) { main { padding:12px; gap:12px; } }
.auth-form {
display:flex; flex-direction:column; align-items:center;
padding:48px 24px; gap:14px; max-width:380px; margin:0 auto;
}
.auth-form h2 { font-family:var(--pixel); font-size:13px; color:var(--accent); margin-bottom:4px; }
.auth-form p { font-size:11px; color:var(--muted); text-align:center; line-height:1.5; }
.auth-form .field { width:100%; }
.auth-form .field label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; display:block; margin-bottom:4px;
}
.auth-form input {
width:100%; background:var(--surface); border:1px solid var(--border);
color:var(--bright); padding:9px 12px; font-size:13px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.auth-form input:focus { border-color:var(--accent); outline:none; }
.auth-form .btn { width:100%; }
.auth-form .err {
font-family:var(--pixel); font-size:9px; color:var(--danger);
min-height:11px; text-align:center;
}
/* ── Buttons ── */
.btn {
padding:10px 18px; border:none; border-radius:3px;
font-family:var(--pixel); font-size:11px; cursor:pointer;
transition:all .15s;
}
.btn:active { opacity:.7; }
.btn-primary { background:var(--accent); color:var(--bg); }
.btn-primary:hover { filter:brightness(1.1); }
.btn-secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
.btn-secondary:hover { color:var(--accent); border-color:var(--accent); }
.btn-danger { background:transparent; border:1px solid var(--danger); color:var(--danger); }
.btn-danger:hover { background:var(--danger); color:var(--bg); }
/* ── Compose view ── */
#view-compose:not(.hidden) {
display: flex;
flex-direction: column;
gap: 16px;
}
.compose-card {
background:var(--surface); border:1px solid var(--border);
border-radius:4px; padding:16px;
display:flex; flex-direction:column; gap:10px;
}
.compose-card h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase;
}
.compose-card .field { display:flex; flex-direction:column; gap:4px; }
.compose-card .field label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
}
.compose-card input, .compose-card select, .compose-card textarea {
background:var(--bg); border:1px solid var(--border); color:var(--bright);
padding:8px 10px; font-size:13px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.compose-card input:focus, .compose-card select:focus, .compose-card textarea:focus {
border-color:var(--accent); outline:none;
}
.compose-card textarea { min-height:140px; resize:vertical; line-height:1.55; }
.compose-card .actions { display:flex; gap:8px; align-items:center; }
.compose-card .actions .spacer { flex:1; }
.compose-card .status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
.compose-card .status.ok { color:var(--accent); }
.compose-card .status.err { color:var(--danger); }
/* ── Inbox + admin lists ── */
.section {
background:var(--surface); border:1px solid var(--border);
border-radius:4px; padding:16px;
}
.section h2 {
font-family:var(--pixel); font-size:11px; color:var(--accent);
text-transform:uppercase; margin-bottom:10px;
display:flex; align-items:center; gap:8px;
}
.section h2 .count {
background:var(--bg); padding:2px 6px; border-radius:2px;
font-size:9px; color:var(--muted);
}
.section .empty { font-size:11px; color:var(--dim); font-style:italic; padding:8px 0; }
.inbox-item {
display:flex; align-items:flex-start; gap:10px;
padding:10px 0; border-bottom:1px solid var(--border);
}
.inbox-item:last-child { border-bottom:none; }
.inbox-item .meta {
font-family:var(--pixel); font-size:9px; color:var(--dim);
white-space:nowrap; flex-shrink:0;
}
.inbox-item .content { flex:1; min-width:0; }
.inbox-item .content .title {
font-size:12px; color:var(--bright); font-weight:bold;
margin-bottom:2px; word-break:break-word;
}
.inbox-item .content .body {
font-size:11px; color:var(--text); white-space:pre-wrap;
word-break:break-word; max-height:120px; overflow:auto;
}
.inbox-item .from { font-family:var(--pixel); font-size:9px; color:var(--accent2); }
.inbox-item .del {
background:none; border:none; color:var(--dim); cursor:pointer;
padding:4px; font-size:14px; flex-shrink:0;
}
.inbox-item .del:hover { color:var(--danger); }
.user-row {
display:flex; align-items:center; gap:12px;
padding:10px 0; border-bottom:1px solid var(--border);
}
.user-row:last-child { border-bottom:none; }
.user-row .info { flex:1; }
.user-row .name { font-family:var(--pixel); font-size:11px; color:var(--bright); }
.user-row .meta { font-size:10px; color:var(--muted); margin-top:2px; }
.user-row .badge {
background:var(--accent); color:var(--bg);
padding:1px 6px; font-family:var(--pixel); font-size:8px;
text-transform:uppercase; border-radius:2px;
}
.user-row .actions { display:flex; gap:6px; }
.user-row .actions .btn { padding:4px 10px; font-size:9px; }
.create-user-form {
display:flex; flex-direction:column; gap:10px;
padding-top:14px; border-top:1px solid var(--border); margin-top:14px;
}
.create-user-form .field-label {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase; display:block; margin-bottom:4px;
}
.create-user-form input[type="text"],
.create-user-form input[type="password"] {
width:100%;
background:var(--bg); border:1px solid var(--border); color:var(--bright);
padding:8px 11px; font-size:12px;
font-family:'JetBrains Mono', monospace; border-radius:3px;
}
.create-user-form input:focus { border-color:var(--accent); outline:none; }
.create-user-form label.checkbox {
font-family:var(--pixel); font-size:9px; color:var(--dim);
text-transform:uppercase;
display:flex; align-items:center; gap:6px; cursor:pointer;
}
.create-user-form label.checkbox input[type="checkbox"] {
accent-color:var(--accent); cursor:pointer;
}
.create-user-form .actions {
display:flex; gap:8px; align-items:center;
margin-top:4px;
}
.toast {
position:fixed; top:20px; right:20px;
background:var(--accent); color:var(--bg);
padding:10px 18px; border-radius:3px;
font-family:var(--pixel); font-size:11px;
opacity:0; transition:opacity .3s; z-index:100; pointer-events:none;
}
.toast.show { opacity:1; }
.toast.err { background:var(--danger); }
.notice { padding:10px 14px; border-radius:3px; font-size:11px; margin-bottom:12px; }
.notice.info { background:rgba(56,189,248,0.1); border:1px solid var(--accent); color:var(--accent); }
.notice.warn { background:rgba(251,113,133,0.1); border:1px solid var(--danger); color:var(--danger); }
/* ── Phosphor icons ── */
.ph { display:inline-block; vertical-align:-2px; }
header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
header h1 + .ph { font-size:12px; }
.btn .ph { font-size:12px; vertical-align:-2px; margin-right:2px; }
.section h2 .ph, .compose-card h2 .ph { font-size:13px; vertical-align:-2px; }
/* ── Mobile: hide admin, identity, and the compose nav button ── */
@media (max-width: 700px) {
#nav-admin { display:none !important; }
#view-admin { display:none !important; }
#nav-user { display:none !important; }
#nav-compose { display:none !important; }
}
/* ── Desktop layout ── */
@media (min-width: 901px) {
/* Subtle background gradient gives the page some depth */
body {
background:
radial-gradient(ellipse at top left, rgba(56,189,248,0.04), transparent 50%),
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.04), transparent 50%),
var(--bg);
}
/* Wider canvas, more breathing room */
main {
max-width: 1200px;
padding: 32px 32px 48px;
gap: 24px;
}
/* Header: bigger title, more padding */
header {
padding: 14px 32px;
}
header h1 {
font-size: 15px;
letter-spacing: 1px;
}
header .nav-btn {
padding: 7px 12px;
}
header .user {
padding: 4px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
}
/* Compose view: 2-column grid (compose | inbox) */
#view-compose:not(.hidden) {
display: grid !important;
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
/* Cards: depth via shadow + larger padding */
.compose-card,
.section {
padding: 22px 24px;
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 8px 24px -8px rgba(0,0,0,0.4);
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
}
.compose-card h2,
.section h2 {
font-size: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
/* Big textarea for desktop typing */
.compose-card textarea {
min-height: 320px;
font-size: 14px;
padding: 12px 14px;
}
.compose-card input,
.compose-card select {
padding: 9px 12px;
font-size: 14px;
}
.compose-card .field label {
font-size: 10px;
margin-bottom: 6px;
}
.compose-card .actions {
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.compose-card .actions .btn {
padding: 11px 22px;
font-size: 12px;
}
/* Inbox card: stick to top of viewport, hover lifts it */
#inbox-section {
position: sticky;
top: 24px;
transition: transform .15s, 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 view: 2-column grid (user list | create form) */
#view-admin:not(.hidden) .section {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-areas:
"header header"
"list form";
gap: 28px;
align-items: start;
}
#view-admin .section h2 { grid-area: header; }
#view-admin .section #users-list { grid-area: list; }
#view-admin .section .create-user-form {
grid-area: form;
margin-top: 0;
padding-top: 0;
border-top: none;
padding-left: 24px;
border-left: 1px solid var(--border);
}
/* User row polish: hover lift */
.user-row {
padding: 14px 12px;
margin: 0 -12px;
border-radius: 3px;
transition: background .12s;
}
.user-row:hover {
background: color-mix(in srgb, var(--raised) 70%, transparent);
}
.user-row .name {
font-size: 12px;
}
/* Auth forms (setup/login) — vertically center on viewport */
.auth-form {
margin-top: 64px;
padding: 40px 32px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow:
0 1px 2px rgba(0,0,0,0.3),
0 16px 48px -16px rgba(0,0,0,0.5);
max-width: 420px;
}
.auth-form h2 {
font-size: 14px;
margin-bottom: 8px;
}
/* Buttons: subtle press */
.btn { transition: all .12s; }
.btn-primary:hover {
filter: brightness(1.15);
transform: translateY(-1px);
box-shadow: 0 4px 12px -4px rgba(56,189,248,0.5);
}
.btn-secondary:hover { transform: translateY(-1px); }
.btn-danger:hover { transform: translateY(-1px); }
}