bak
This commit is contained in:
1 parent
5aa3abc860
commit
5f9db690a4
24 files changed
+1447
-597
No files matched your search
@@ -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]
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Reference in new issue
Block a user