This commit is contained in:
th committed 2026-04-09 19:14:52 +02:00
1 parent 5aa3abc860
commit 5f9db690a4
24 files changed
+1447 -597

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
RewriteEngine On
# Block direct access to data directory
RewriteRule ^data/ - [F,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
+11
View File
@@ -0,0 +1,11 @@
FROM php:8.3-apache
RUN a2enmod rewrite
RUN apt-get update && apt-get install -y libsqlite3-dev && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install pdo_sqlite
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf
RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data
EXPOSE 80
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Build hyprcrd-relay distribution ZIP
# Output: a ZIP you can extract into any PHP webspace
set -e
cd "$(dirname "$0")"
DIST="hyprcrd-relay.zip"
rm -f "$DIST"
zip -r "$DIST" index.php .htaccess fonts/
echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))"
echo ""
echo "Deploy: extract into your webspace document root."
echo "The data/ directory and SQLite DB are created automatically on first request."
Binary file not shown.
Binary file not shown.
Binary file not shown.
+476
View File
@@ -0,0 +1,476 @@
<?php
// ── hyprcrd Relay — Mobile Inbox ──
// Single-file service: API + Mobile UI + DB
error_reporting(0);
$dbPath = __DIR__ . '/data/relay.db';
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) mkdir($dbDir, 0755, 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');
}
$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;
}
$route = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$base = dirname($_SERVER['SCRIPT_NAME']);
if ($base !== '/') $route = substr($route, strlen($base));
$route = '/' . ltrim($route ?: '/', '/');
$method = $_SERVER['REQUEST_METHOD'];
$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();
// ── 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 ──
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('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; }
// 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; }
// 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;
}
// 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;
}
// 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>