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 ── ?> hyprcrd relay

hyprcrd relay