$sessionLifetime, 'path' => '/', 'httponly' => true, 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF ]); session_start(); // CSRF token if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } // ── Auth ── $method = $_SERVER['REQUEST_METHOD']; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $basePath = dirname($_SERVER['SCRIPT_NAME']); $route = ($basePath === '/' || $basePath === '\\') ? $path : str_replace($basePath, '', $path); $pwHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); $isSetup = !$pwHash; $isLoggedIn = !empty($_SESSION['hyprcrd_auth']); // Auth API endpoints (always accessible) if (str_starts_with($route, '/api/auth')) { error_reporting(0); header('Content-Type: application/json'); // IP-keyed rate limiter for auth endpoints — session cookies are trivial // to rotate, so the in-session counter alone is bypassable. $hyprcrdClientIp = $_SERVER['REMOTE_ADDR'] ?? ''; $db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-10 minutes')"); $hyprcrdIpRateLimited = function (string $endpoint, int $max, int $windowMinutes = 5) use ($db, $hyprcrdClientIp): bool { if ($hyprcrdClientIp === '') return false; $q = $db->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')"); $q->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]); return (int)$q->fetchColumn() >= $max; }; $hyprcrdRecordAttempt = function (string $endpoint) use ($db, $hyprcrdClientIp): void { if ($hyprcrdClientIp === '') return; $db->prepare('INSERT INTO login_attempts (ip, endpoint) VALUES (:ip, :ep)') ->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]); }; // GET /api/auth/status if ($route === '/api/auth/status' && $method === 'GET') { echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]); exit; } // POST /api/auth/setup — set initial password if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) { // CSRF: same-origin + same-session guarantee. A drive-by POST from // another site won't have the csrf cookie-bound token, so it can't // silently claim a freshly-provisioned instance. $csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) { http_response_code(403); echo json_encode(['error' => 'invalid csrf token']); exit; } $input = json_decode(file_get_contents('php://input'), true); $pw = $input['password'] ?? ''; if (strlen($pw) < 10) { http_response_code(400); echo json_encode(['error' => 'password too short (min 10)']); exit; } $hash = password_hash($pw, PASSWORD_BCRYPT); $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('password_hash', :hash)")->execute(['hash' => $hash]); session_regenerate_id(true); $_SESSION['hyprcrd_auth'] = true; $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]); exit; } // POST /api/auth/login (IP-keyed rate limit) if ($route === '/api/auth/login' && $method === 'POST') { if ($hyprcrdIpRateLimited('login', 10, 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts, try again later']); exit; } $input = json_decode(file_get_contents('php://input'), true); $pw = $input['password'] ?? ''; if ($pwHash && password_verify($pw, $pwHash)) { session_regenerate_id(true); $_SESSION['hyprcrd_auth'] = true; $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]); } else { $hyprcrdRecordAttempt('login'); usleep(300000); // 300ms delay against timing attacks http_response_code(401); echo json_encode(['error' => 'wrong password']); } exit; } // POST /api/auth/logout if ($route === '/api/auth/logout' && $method === 'POST') { $_SESSION = []; session_destroy(); echo json_encode(['ok' => true]); exit; } // GET/POST /api/auth/vault-key if ($route === '/api/auth/vault-key' && $method === 'GET') { $vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); echo json_encode(['has_key' => !empty($vk)]); exit; } if ($route === '/api/auth/vault-key' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $newKey = $input['key'] ?? ''; if (strlen($newKey) < 8) { http_response_code(400); echo json_encode(['error' => 'min 8 characters']); exit; } $oldVk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); $oldKey = $oldVk ? substr(hash('sha256', $oldVk), 0, 32) : null; if (!$oldKey) { // Fallback: try old password-hash-based key $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); $oldKey = substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32); } $newKeyDerived = substr(hash('sha256', $newKey), 0, 32); // Re-encrypt CalDAV credentials $credKeys = ['caldav_url', 'caldav_username', 'caldav_password']; foreach ($credKeys as $ck) { $enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn(); if ($enc) { $raw = base64_decode($enc); if (strlen($raw) >= 17) { $iv = substr($raw, 0, 16); $cipher = base64_encode(substr($raw, 16)); $plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv); if ($plain !== false) { $newIv = random_bytes(16); $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKeyDerived, 0, $newIv); $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => base64_encode($newIv . base64_decode($newCipher)), 'k' => $ck]); } } } } $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('vault_key', :v)")->execute(['v' => $newKey]); echo json_encode(['ok' => true, 'rekey' => true]); exit; } // POST /api/auth/password — change password (requires current password) if ($route === '/api/auth/password' && $method === 'POST') { if ($hyprcrdIpRateLimited('password', 10, 5)) { http_response_code(429); echo json_encode(['error' => 'too many attempts, try again later']); exit; } $input = json_decode(file_get_contents('php://input'), true); $current = $input['current'] ?? ''; $newPass = $input['new'] ?? ''; $hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); if (!password_verify($current, $hash)) { $hyprcrdRecordAttempt('password'); http_response_code(403); echo json_encode(['error' => 'wrong password']); exit; } if (strlen($newPass) < 10) { http_response_code(400); echo json_encode(['error' => 'min 10 characters']); exit; } $newHash = password_hash($newPass, PASSWORD_BCRYPT); $hasVaultKey = !empty($db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn()); if ($hasVaultKey) { // Vault key handles encryption — just change the password $db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]); echo json_encode(['ok' => true]); } else { // Legacy: re-encrypt credentials with new password hash $oldKey = substr(hash('sha256', $hash), 0, 32); $newKey = substr(hash('sha256', $newHash), 0, 32); $credKeys = ['caldav_url', 'caldav_username', 'caldav_password']; $reEncrypted = []; $rekeyOk = true; foreach ($credKeys as $ck) { $enc = $db->query("SELECT value FROM settings WHERE key = '{$ck}'")->fetchColumn(); if (!$enc) continue; $raw = base64_decode($enc); if (strlen($raw) < 17) continue; $plain = openssl_decrypt(base64_encode(substr($raw, 16)), 'aes-256-cbc', $oldKey, 0, substr($raw, 0, 16)); if ($plain === false) { $rekeyOk = false; break; } $newIv = random_bytes(16); $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $newKey, 0, $newIv); $reEncrypted[$ck] = base64_encode($newIv . base64_decode($newCipher)); } if (!$rekeyOk) { http_response_code(500); echo json_encode(['error' => 'set a vault key first to avoid encryption issues']); exit; } $db->beginTransaction(); foreach ($reEncrypted as $ck => $val) $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]); $db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]); $db->commit(); echo json_encode(['ok' => true, 'rekey' => true]); } exit; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; } // All other API/page requests require auth (unless setup mode) if (!$isSetup && !$isLoggedIn) { if (str_starts_with($route, '/api/')) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['error' => 'unauthorized']); exit; } // For page requests, still serve the HTML shell — JS will show login dialog } if (str_starts_with($route, '/api/')) { error_reporting(0); header('Content-Type: application/json'); // CSRF check on all state-changing requests if (in_array($method, ['POST', 'PATCH', 'DELETE'])) { $csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) { http_response_code(403); echo json_encode(['error' => 'invalid csrf token']); exit; } } // Input sanitization function validColor($c) { return preg_match('/^#[0-9a-fA-F]{6}$/', $c ?? '') ? $c : '#ffffff'; } function validInt($v, $default = 0) { return is_numeric($v) ? (int)$v : $default; } function extractTags($body) { preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body ?? '', $matches); // English ↔ German tag aliases: the calendar + journal logic is // hardcoded against 'geburtstag' / 'termin', so accept the English // synonyms and normalise to the canonical German form at write // time. Keeps every downstream check (card.tags includes 'geburtstag') // working without touching dozens of JS call sites. $aliases = ['birthday' => 'geburtstag', 'appointment' => 'termin']; $tags = array_map(fn($t) => $aliases[$t] ?? $t, array_map('strtolower', $matches[1] ?? [])); return implode(',', array_unique($tags)); } // Read-later push helpers — called after card insert/update when // cards.read_push is 1, or when the user toggles it off and we need // to wipe the remote copy. Failures are logged and swallowed so // relay trouble never blocks a local save. function pushCardToRelayRead(array $card, PDO $db): void { $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn(); if (!$relayUrl || !$relayKey || !$relayUser) return; $relayUid = $card['relay_uid'] ?? null; if (!$relayUid) { $relayUid = bin2hex(random_bytes(16)); $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $card['id']]); } $body = (string)($card['body'] ?? ''); // Hero = first uploads/. reference. All inline images // get pushed as attachments so the reader can resolve them. preg_match_all('#uploads/([a-f0-9]+\.[a-z0-9]+)#i', $body, $matches); $imageFilenames = array_unique(array_map('strtolower', $matches[1] ?? [])); $heroFilename = $imageFilenames[0] ?? ''; $payload = json_encode([ 'relay_uid' => $relayUid, 'title' => hyprcrdRelayEncrypt((string)($card['title'] ?? ''), $relayKey), 'body' => hyprcrdRelayEncrypt($body, $relayKey), 'hero_filename' => $heroFilename, ]); // Aggressive timeouts: read-later is opt-in + best-effort. If the // relay is slow or unreachable we must NOT block the user's save — // this function still runs inline from the PATCH handler (we'll // also flush the response before invoking it), so the shorter // timeout caps the worst-case backlog even if flushing fails. $ch = curl_init(rtrim($relayUrl, '/') . '/api/read'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, ]); hyprcrdApplySafeCurlOpts($ch); $resp = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status < 200 || $status >= 300) { @error_log('[hyprcrd] read push failed: HTTP ' . $status . ' ' . substr((string)$resp, 0, 200)); return; // don't bother uploading images if the item didn't land } // Upload referenced images as read-attachments (encrypted blob, // same envelope format as collab-attachments). $uploadDir = UPLOAD_DIR; foreach ($imageFilenames as $fn) { $filePath = $uploadDir . '/' . $fn; if (!is_file($filePath)) continue; $bytes = file_get_contents($filePath); if ($bytes === false) continue; $attPayload = json_encode([ 'relay_uid' => $relayUid, 'filename' => $fn, 'data' => hyprcrdRelayEncrypt(base64_encode($bytes), $relayKey), ]); $ach = curl_init(rtrim($relayUrl, '/') . '/api/read-attachments'); curl_setopt_array($ach, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $attPayload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 10, ]); hyprcrdApplySafeCurlOpts($ach); curl_exec($ach); curl_close($ach); } } function deleteCardFromRelayRead(string $relayUid, PDO $db): void { if ($relayUid === '') return; $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn(); if (!$relayUrl || !$relayKey || !$relayUser) return; $ch = curl_init(rtrim($relayUrl, '/') . '/api/read/' . urlencode($relayUid)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, ]); hyprcrdApplySafeCurlOpts($ch); curl_exec($ch); curl_close($ch); } // Plugin route handlers (registered by plugin backend.php files). // Each handler is responsible for calling exit() on a match. PluginManager::instance()->dispatchRoute($route, $method); // Cards if ($route === '/api/cards' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $title = trim($input['title'] ?? ''); if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; } $pos = $input['position'] ?? 0; $body = $input['body'] ?? ''; $readPush = !empty($input['read_push']) ? 1 : 0; $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad, read_push) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad, :read_push)')->execute([ 'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'parent_id' => validInt($input['parent_id'] ?? null, null), 'position' => validInt($pos), 'tags' => extractTags($body), 'due_date' => $input['due_date'] ?? null, 'scratchpad' => $input['scratchpad'] ?? null, 'read_push' => $readPush, ]); $saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch(); // When the card opts into read-later, generate the relay_uid up // front so the response the client receives already carries it. // Actually pushing to the relay happens AFTER the response is // flushed — the relay hop is best-effort and shouldn't stretch // the user-facing save latency. if ($readPush && $saved && empty($saved['relay_uid'])) { $uid = bin2hex(random_bytes(16)); $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $saved['id']]); $saved['relay_uid'] = $uid; } echo json_encode($saved); if ($readPush && $saved) { hyprcrdFinishRequest(); pushCardToRelayRead($saved, $db); } exit; } if ($route === '/api/cards' && $method === 'GET') { if (!empty($_GET['ids'])) { $ids = array_filter(array_map('intval', explode(',', $_GET['ids']))); if ($ids) { $ph = implode(',', array_fill(0, count($ids), '?')); $stmt = $db->prepare("SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id IN ($ph)"); $stmt->execute($ids); echo json_encode($stmt->fetchAll()); exit; } } echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll()); exit; } if ($route === '/api/cards/meta' && $method === 'GET') { echo json_encode($db->query('SELECT c.id, c.title, c.color, c.tags, c.bucket_id, c.parent_id, c.position, c.created_at, c.updated_at, c.due_date, c.caldav_uid, c.caldav_rrule, c.relay_uid, c.read_push, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll()); exit; } if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') { // CalDAV tombstone for synced cards, plus a read-list wipe so the // mobile reader stops showing cards that no longer exist locally. $card = $db->prepare('SELECT caldav_uid, relay_uid, read_push FROM cards WHERE id = :id'); $card->execute(['id' => $m[1]]); $cardRow = $card->fetch(); if ($cardRow && !empty($cardRow['caldav_uid'])) { $db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]); } $wipeFromRelay = ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid'])) ? (string)$cardRow['relay_uid'] : null; $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); if ($wipeFromRelay) { hyprcrdFinishRequest(); deleteCardFromRelayRead($wipeFromRelay, $db); } exit; } if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') { $stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100'); $stmt->execute(['id' => $m[1]]); echo json_encode($stmt->fetchAll()); exit; } if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') { $input = json_decode(file_get_contents('php://input'), true); // Snapshot changed fields before update $tracked = ['title', 'body', 'color', 'bucket_id', 'due_date']; $changedTracked = array_intersect($tracked, array_keys($input)); if ($changedTracked) { $old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch(); if ($old) { $histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)'); foreach ($changedTracked as $f) { $oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null; if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); } if ((string)$oldVal !== (string)$newVal) { $histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]); } } } } $fields = []; $params = ['id' => $m[1]]; foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } } if (array_key_exists('read_push', $input)) { $fields[] = 'read_push = :read_push'; $params['read_push'] = !empty($input['read_push']) ? 1 : 0; } if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } // Snapshot the previous read_push / relay_uid before the write so we // can detect a toggle-off and remove the remote copy afterwards. // The history-tracking block above only loads $old when a tracked // field changed; we need it unconditionally when read_push is in // play, so backfill here if missing. $prevRead = null; $prevRelayUid = null; if (array_key_exists('read_push', $input) && !isset($old)) { $snap = $db->prepare('SELECT read_push, relay_uid FROM cards WHERE id = :id'); $snap->execute(['id' => $m[1]]); $old = $snap->fetch() ?: null; } if (isset($old) && $old) { $prevRead = (int)($old['read_push'] ?? 0); $prevRelayUid = $old['relay_uid'] ?? null; } if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); } $card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id'); $card->execute(['id' => $m[1]]); $savedCard = $card->fetch(); // Decide the relay-sync action BEFORE echoing the response, then // flush + run the actual cURL hop afterwards. The user doesn't // wait for the relay round-trip; a dead/slow relay no longer // stretches the card save into "eine Ewigkeit". $pushAction = null; // 'push' | 'delete' | null if ($savedCard) { $nowRead = (int)($savedCard['read_push'] ?? 0); if ($nowRead === 1) { // Mint relay_uid up front so the response carries it. if (empty($savedCard['relay_uid'])) { $uid = bin2hex(random_bytes(16)); $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $savedCard['id']]); $savedCard['relay_uid'] = $uid; } $pushAction = 'push'; } elseif ($prevRead === 1 && $prevRelayUid) { $pushAction = 'delete'; } } echo json_encode($savedCard); if ($pushAction === 'push') { hyprcrdFinishRequest(); pushCardToRelayRead($savedCard, $db); } elseif ($pushAction === 'delete') { hyprcrdFinishRequest(); deleteCardFromRelayRead((string)$prevRelayUid, $db); } exit; } // Plugins if ($route === '/api/plugins' && $method === 'GET') { echo json_encode(PluginManager::instance()->pluginsForApi()); exit; } if ($route === '/api/plugins/toggle' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $name = trim((string)($input['name'] ?? '')); $enabled = !empty($input['enabled']); if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; } $pm = PluginManager::instance(); $known = array_keys($pm->plugins()); if (!in_array($name, $known, true)) { http_response_code(404); echo json_encode(['error' => 'plugin not found']); exit; } $disabled = $pm->disabledList(); if ($enabled) { $disabled = array_values(array_diff($disabled, [$name])); } else if (!in_array($name, $disabled, true)) { $disabled[] = $name; } if (!$pm->setDisabled($disabled)) { http_response_code(500); echo json_encode(['error' => 'failed to write state']); exit; } echo json_encode(['ok' => true, 'plugins' => $pm->pluginsForApi()]); exit; } // Buckets if ($route === '/api/buckets' && $method === 'GET') { echo json_encode($db->query('SELECT * FROM buckets ORDER BY position ASC, created_at ASC')->fetchAll()); exit; } if ($route === '/api/buckets' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $name = trim($input['name'] ?? ''); if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; } // Normal POST creates a local bucket. Collab buckets go through // /api/collab-buckets (plugin-owned) and can't be created here. $db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]); echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit; } if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') { $input = json_decode(file_get_contents('php://input'), true); // Collab buckets: name and color must go through the relay plugin so // all members see the update. Only position (local reorder) is allowed. $b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); $existing = $b->fetch(); $isCollab = $existing && !empty($existing['collab_uid']); $allowed = $isCollab ? ['position'] : ['name', 'position', 'color', 'parent_id']; // Super-bucket depth + cycle enforcement: a bucket may only become a // child if (a) it has no children itself, (b) the prospective parent // is top-level (parent_id IS NULL), and (c) self-parent is forbidden. if (!$isCollab && array_key_exists('parent_id', $input)) { $newParent = $input['parent_id']; if ($newParent !== null && (int)$newParent !== 0) { $newParent = (int)$newParent; if ($newParent === (int)$m[1]) { http_response_code(400); echo json_encode(['error' => 'cannot parent to self']); exit; } $h = $db->prepare('SELECT COUNT(*) FROM buckets WHERE parent_id = :id'); $h->execute(['id' => $m[1]]); if ((int)$h->fetchColumn() > 0) { http_response_code(400); echo json_encode(['error' => 'this bucket has children — flatten it first']); exit; } $p = $db->prepare('SELECT parent_id FROM buckets WHERE id = :id'); $p->execute(['id' => $newParent]); $parentRow = $p->fetch(); if (!$parentRow) { http_response_code(400); echo json_encode(['error' => 'parent not found']); exit; } if ($parentRow['parent_id'] !== null) { http_response_code(400); echo json_encode(['error' => 'cannot nest under a child bucket']); exit; } } else { $input['parent_id'] = null; } } $fields = []; $params = ['id' => $m[1]]; foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } } if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); $b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit; } if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') { // Collab buckets must go through /api/collab-buckets/{uid} so the relay // and plugin mirror stay consistent. Refuse deletion here. $b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); $existing = $b->fetch(); if ($existing && !empty($existing['collab_uid'])) { http_response_code(400); echo json_encode(['error' => 'collab buckets must be deleted via /api/collab-buckets/' . $existing['collab_uid']]); exit; } $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]); // If this was a super-bucket parent, un-nest its children back to top-level // instead of cascade-deleting them. $db->prepare('UPDATE buckets SET parent_id = NULL WHERE parent_id = :id')->execute(['id' => $m[1]]); $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; } // List all upload files if ($route === '/api/uploads' && $method === 'GET') { $dir = UPLOAD_DIR; $files = []; if (is_dir($dir)) { foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) { $name = basename($f); $files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)]; } } usort($files, fn($a, $b) => $b['modified'] - $a['modified']); echo json_encode($files); exit; } // Delete an upload file if (preg_match('#^/api/uploads/([a-f0-9]+\.\w+)$#', $route, $m) && $method === 'DELETE') { $file = UPLOAD_DIR . '/' . basename($m[1]); if (is_file($file)) { unlink($file); echo json_encode(['ok' => true]); } else { http_response_code(404); echo json_encode(['error' => 'file not found']); } exit; } // Upload (max 10MB, real MIME check) if ($route === '/api/upload' && $method === 'POST') { $uploadDir = UPLOAD_DIR; if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'no file', 'debug_files' => array_keys($_FILES), 'debug_method' => $method, 'debug_ct' => $_SERVER['CONTENT_TYPE'] ?? 'none']); exit; } $file = $_FILES['file']; if ($file['size'] > 10 * 1024 * 1024) { http_response_code(400); echo json_encode(['error' => 'file too large (max 10MB)']); exit; } // Check MIME — finfo first, fallback to extension for audio $mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg', 'application/pdf' => 'pdf']; $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a', 'pdf' => 'pdf']; $finfo = new finfo(FILEINFO_MIME_TYPE); $realMime = $finfo->file($file['tmp_name']); $ext = $mimeMap[$realMime] ?? null; // Fallback: trust extension for audio when finfo returns octet-stream if (!$ext && ($realMime === 'application/octet-stream' || str_starts_with($realMime, 'audio/'))) { $origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $ext = $extMap[$origExt] ?? null; } if (!$ext) { http_response_code(400); echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]); exit; } $name = bin2hex(random_bytes(16)) . '.' . $ext; $dest = $uploadDir . '/' . $name; if (!move_uploaded_file($file['tmp_name'], $dest)) { http_response_code(500); echo json_encode(['error' => 'upload failed']); exit; } http_response_code(201); echo json_encode(['url' => 'uploads/' . $name, 'name' => $name]); exit; } // Export — Markdown vault (Obsidian / Logseq compatible) if ($route === '/api/export-md' && $method === 'GET') { $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; if ($ids) { $ph = implode(',', array_fill(0, count($ids), '?')); $stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id"); $stmt->execute($ids); $cards = $stmt->fetchAll(); } else { $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); } $allCards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); $buckets = $db->query('SELECT * FROM buckets ORDER BY position, id')->fetchAll(); $bucketMap = []; foreach ($buckets as $b) $bucketMap[$b['id']] = $b['name']; $cardMap = []; foreach ($allCards as $c) $cardMap[$c['id']] = $c; // Sanitise filename $safe = function ($name) { $name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $name); return trim($name) ?: 'untitled'; }; $tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_md_'); $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::OVERWRITE); $usedNames = []; $allBodies = ''; foreach ($cards as $c) { // Build folder path: bucket/group/ (skip folders for single-card export) $folder = ''; if (!$ids || count($ids) > 1) { if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) { $folder = $safe($bucketMap[$c['bucket_id']]) . '/'; } if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) { $folder .= $safe($cardMap[$c['parent_id']]['title']) . '/'; } } $baseName = $safe($c['title']); $fileName = $folder . $baseName . '.md'; // Deduplicate $n = 1; while (isset($usedNames[$fileName])) { $fileName = $folder . $baseName . ' ' . (++$n) . '.md'; } $usedNames[$fileName] = true; // YAML frontmatter $fm = "---\n"; $fm .= 'id: ' . $c['id'] . "\n"; $fm .= 'color: "' . $c['color'] . "\"\n"; if ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) { $fm .= 'bucket: "' . addcslashes($bucketMap[$c['bucket_id']], '"') . "\"\n"; } if ($c['parent_id'] && isset($cardMap[$c['parent_id']])) { $fm .= 'parent: "' . addcslashes($cardMap[$c['parent_id']]['title'], '"') . "\"\n"; } $fm .= 'created: ' . $c['created_at'] . "\n"; $fm .= 'updated: ' . $c['updated_at'] . "\n"; if (!empty($c['tags'])) { $fm .= 'tags: [' . implode(', ', array_map(fn($t) => '"' . trim($t) . '"', explode(',', $c['tags']))) . "]\n"; } $fm .= "---\n\n"; // Body: rewrite upload paths to attachments/ $body = $c['body'] ?? ''; $allBodies .= "\n" . $body; $body = preg_replace('#uploads/#', 'attachments/', $body); $zip->addFromString($fileName, $fm . '# ' . $c['title'] . "\n\n" . $body); } // Copy referenced uploads → attachments/ $uploadDir = UPLOAD_DIR; if (is_dir($uploadDir)) { foreach (glob($uploadDir . '/*') as $f) { $name = basename($f); if (is_file($f) && (!$ids || str_contains($allBodies, $name))) { $zip->addFile($f, 'attachments/' . $name); } } } $zip->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="hyprcrd-vault-' . date('Y-m-d') . '.zip"'); header('Content-Length: ' . filesize($tmp)); readfile($tmp); unlink($tmp); exit; } // Export — full or selective if ($route === '/api/export' && $method === 'GET') { $ids = isset($_GET['ids']) ? array_filter(array_map('intval', explode(',', $_GET['ids']))) : null; if ($ids) { $ph = implode(',', array_fill(0, count($ids), '?')); $cards = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id")->execute($ids) ? $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id") : null; // Re-query properly $stmt = $db->prepare("SELECT * FROM cards WHERE id IN ($ph) ORDER BY id"); $stmt->execute($ids); $cards = $stmt->fetchAll(); $bucketIds = array_unique(array_filter(array_column($cards, 'bucket_id'))); $buckets = []; if ($bucketIds) { $bph = implode(',', array_fill(0, count($bucketIds), '?')); $bstmt = $db->prepare("SELECT * FROM buckets WHERE id IN ($bph)"); $bstmt->execute(array_values($bucketIds)); $buckets = $bstmt->fetchAll(); } } else { $cards = $db->query('SELECT * FROM cards ORDER BY id')->fetchAll(); $buckets = $db->query('SELECT * FROM buckets ORDER BY id')->fetchAll(); } $tmp = tempnam(sys_get_temp_dir(), 'hyprcrd_'); $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::OVERWRITE); $zip->addFromString('data.json', json_encode(['version' => 1, 'buckets' => $buckets, 'cards' => $cards, 'selective' => !!$ids], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); // Only include referenced uploads $uploadDir = UPLOAD_DIR; $allBodies = implode("\n", array_column($cards, 'body')); if (is_dir($uploadDir)) { foreach (glob($uploadDir . '/*') as $f) { $name = basename($f); if (is_file($f) && (!$ids || str_contains($allBodies, $name))) { $zip->addFile($f, 'uploads/' . $name); } } } $zip->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="hyprcrd-export-' . date('Y-m-d') . '.zip"'); header('Content-Length: ' . filesize($tmp)); readfile($tmp); unlink($tmp); exit; } // Import — ZIP with JSON dump + uploads if ($route === '/api/import' && $method === 'POST') { if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'no file']); exit; } $zip = new ZipArchive(); if ($zip->open($_FILES['file']['tmp_name']) !== true) { http_response_code(400); echo json_encode(['error' => 'invalid zip']); exit; } $json = $zip->getFromName('data.json'); if (!$json) { $zip->close(); http_response_code(400); echo json_encode(['error' => 'no data.json in archive']); exit; } $data = json_decode($json, true); if (!$data || !isset($data['buckets']) || !isset($data['cards'])) { $zip->close(); http_response_code(400); echo json_encode(['error' => 'invalid data.json']); exit; } $merge = !empty($data['selective']) || !empty($_GET['merge']); if (!$merge) { $db->exec('DELETE FROM cards'); $db->exec('DELETE FROM buckets'); } // Import buckets (merge: match by name, create if missing) $bucketMap = []; foreach ($data['buckets'] as $b) { $oldId = $b['id']; if ($merge) { $existing = $db->prepare('SELECT id FROM buckets WHERE name = :name'); $existing->execute(['name' => $b['name']]); $found = $existing->fetchColumn(); if ($found) { $bucketMap[$oldId] = $found; continue; } } $stmt = $db->prepare('INSERT INTO buckets (name, position, color, collab_uid, created_at) VALUES (:name, :position, :color, :collab_uid, :created_at)'); $stmt->execute([ 'name' => $b['name'], 'position' => $b['position'] ?? 0, 'color' => $b['color'] ?? '#64748b', 'collab_uid' => $b['collab_uid'] ?? null, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s'), ]); $bucketMap[$oldId] = $db->lastInsertId(); } // Second pass: resolve parent_id (old id → new id) now that all // buckets have been inserted and the map is complete. foreach ($data['buckets'] as $b) { if (!empty($b['parent_id']) && isset($bucketMap[$b['id']]) && isset($bucketMap[$b['parent_id']])) { $db->prepare('UPDATE buckets SET parent_id = :p WHERE id = :id') ->execute(['p' => $bucketMap[$b['parent_id']], 'id' => $bucketMap[$b['id']]]); } } // Import cards (preserve all fields including CalDAV sync state) $cardMap = []; // old id → new id foreach ($data['cards'] as $c) { $oldId = $c['id'] ?? null; $bucketId = ($c['bucket_id'] && isset($bucketMap[$c['bucket_id']])) ? $bucketMap[$c['bucket_id']] : null; $stmt = $db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags, due_date, caldav_uid, caldav_etag, caldav_href, caldav_synced_at, caldav_rrule, relay_uid, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :tags, :due_date, :caldav_uid, :caldav_etag, :caldav_href, :caldav_synced_at, :caldav_rrule, :relay_uid, :created_at, :updated_at)'); $stmt->execute([ 'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'], 'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0, 'tags' => $c['tags'] ?? '', 'due_date' => $c['due_date'] ?? null, 'caldav_uid' => $c['caldav_uid'] ?? null, 'caldav_etag' => $c['caldav_etag'] ?? null, 'caldav_href' => $c['caldav_href'] ?? null, 'caldav_synced_at' => $c['caldav_synced_at'] ?? null, 'caldav_rrule' => $c['caldav_rrule'] ?? null, 'relay_uid' => $c['relay_uid'] ?? null, 'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'), 'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'), ]); if ($oldId) $cardMap[$oldId] = $db->lastInsertId(); } // Resolve parent_id references (old id → new id) foreach ($data['cards'] as $c) { if (!empty($c['parent_id']) && isset($cardMap[$c['id']]) && isset($cardMap[$c['parent_id']])) { $db->prepare('UPDATE cards SET parent_id = ? WHERE id = ?') ->execute([$cardMap[$c['parent_id']], $cardMap[$c['id']]]); } } // Extract uploads. Guard against zip bombs and malicious payloads: // per-file 25MB cap, 500MB total, whitelist allowed extensions (same // set as the live /api/upload endpoint), never overwrite existing // files, never leak paths via traversal. $uploadDir = UPLOAD_DIR; if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); $allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'ogg', 'wav', 'm4a', 'pdf']; $perFileMax = 25 * 1024 * 1024; // 25 MB $totalMax = 500 * 1024 * 1024; // 500 MB $totalWritten = 0; $skipped = []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, 'uploads/') || $name === 'uploads/') continue; $base = basename($name); $ext = strtolower(pathinfo($base, PATHINFO_EXTENSION)); if (!in_array($ext, $allowedExts, true)) { $skipped[] = $base; continue; } $stat = $zip->statIndex($i); if ($stat && isset($stat['size']) && $stat['size'] > $perFileMax) { $skipped[] = $base; continue; } if ($totalWritten + (int)($stat['size'] ?? 0) > $totalMax) { $skipped[] = $base; continue; } $dest = $uploadDir . '/' . $base; if (file_exists($dest)) { $skipped[] = $base; continue; } $content = $zip->getFromIndex($i); if ($content === false) continue; if (strlen($content) > $perFileMax) { $skipped[] = $base; continue; } file_put_contents($dest, $content); $totalWritten += strlen($content); } $zip->close(); echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards']), 'skipped_files' => count($skipped)]); exit; } // CalDAV endpoints $caldav = new CalDavSync($db); if ($route === '/api/caldav/settings' && $method === 'GET') { echo json_encode($caldav->getSettings()); exit; } if ($route === '/api/caldav/settings' && $method === 'POST') { $caldav->saveSettings(json_decode(file_get_contents('php://input'), true) ?? []); echo json_encode(['ok' => true]); exit; } if ($route === '/api/caldav/test' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true) ?? []; echo json_encode($caldav->testConnection($input['url'] ?? null, $input['username'] ?? null, $input['password'] ?? null)); exit; } if ($route === '/api/caldav/sync' && $method === 'POST') { echo json_encode($caldav->sync()); exit; } // ── Relay ── if ($route === '/api/relay/settings' && $method === 'GET') { $url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: ''; $username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: ''; $key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: ''; // Never echo the plaintext secret to the client — if it leaks via XSS // or a compromised session, the attacker can decrypt all relay traffic. // The UI just needs to know whether a secret is set. echo json_encode(['url' => $url, 'username' => $username, 'has_key' => !empty($key)]); exit; } if ($route === '/api/relay/settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]); if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]); // The UI no longer receives the stored secret, so empty strings are // just "field untouched" and we only write when the user types a new // value. An explicit null clears the secret for users who need it. if (array_key_exists('key', $input)) { $newKey = $input['key']; if ($newKey === null) { $db->prepare("DELETE FROM settings WHERE key = 'relay_key'")->execute(); } elseif (is_string($newKey) && $newKey !== '') { $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $newKey]); } } echo json_encode(['ok' => true]); exit; } 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(); $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-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 10, ]); hyprcrdApplySafeCurlOpts($ch); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); 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; } $pulled = 0; $decryptField = function($text) use ($relayKey) { $decoded = hyprcrdRelayDecrypt($text, $relayKey); // Dual-format decrypt returns '' only when a magic-prefixed // envelope failed to decrypt — surface the original string in // that case so the UI still shows something useful. if ($decoded === '' && (str_starts_with($text, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($text, HYPRCRD_RELAY_CBC_MAGIC))) { return $text; } return $decoded; }; // Load IDs we pushed ourselves $ownIds = array_column($db->query('SELECT relay_msg_id FROM relay_pushed')->fetchAll(), 'relay_msg_id'); foreach ($messages as $msg) { if ($msg['fetched'] ?? false) continue; if (in_array($msg['id'] ?? 0, $ownIds)) { $db->prepare('DELETE FROM relay_pushed WHERE relay_msg_id = ?')->execute([$msg['id']]); continue; } $title = $decryptField($msg['title'] ?? ''); $body = $decryptField($msg['body'] ?? ''); if (!$title) $title = substr(strip_tags($body), 0, 60) ?: 'Relay message'; $body = trim($body); $relayUid = $msg['relay_uid'] ?? null; // Check for existing card with this relay_uid $existingCard = null; if ($relayUid) { $stmt = $db->prepare('SELECT id FROM cards WHERE relay_uid = ?'); $stmt->execute([$relayUid]); $existingCard = $stmt->fetch(); } preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); if ($existingCard) { // Update existing card $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); $db->prepare('UPDATE cards SET title = ?, body = ?, color = ?, tags = ?, updated_at = datetime("now") WHERE id = ?') ->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $existingCard['id']]); } else { // New card if (!str_contains($body, '#inbox')) $body .= "\n\n#inbox"; preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm); $tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? []))); $db->prepare('INSERT INTO cards (title, body, color, tags, position, relay_uid) VALUES (?, ?, ?, ?, -1, ?)') ->execute([$title, $body, $msg['color'] ?? '#38bdf8', $tags, $relayUid]); } $pulled++; // Fire a sync-event notification to the sender so their hyprcrd // surfaces a toast. Best-effort — an older relay may not have // /api/sync-events, and we don't want to fail the whole poll. $senderName = $msg['sender_username'] ?? ''; if ($senderName && $senderName !== $relayUser) { $eventBody = $relayUser . ' synced: ' . $title; $bch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events'); curl_setopt_array($bch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['recipient' => $senderName, 'body' => $eventBody]), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey, ], CURLOPT_TIMEOUT => 5, ]); hyprcrdApplySafeCurlOpts($bch); $bRes = curl_exec($bch); $bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE); curl_close($bch); @error_log('[hyprcrd] sync-event POST → ' . $senderName . ' status=' . $bStat . ' body=' . substr((string)$bRes, 0, 120)); } // Download attachments if ($relayUid) { $ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid)); curl_setopt_array($ach, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 30, ]); hyprcrdApplySafeCurlOpts($ach); $attResponse = curl_exec($ach); $attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE); curl_close($ach); if ($attStatus === 200) { $attachments = json_decode($attResponse, true); $uploadDir = UPLOAD_DIR; if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); foreach ($attachments ?? [] as $att) { $fn = basename($att['filename']); if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue; $dest = $uploadDir . '/' . $fn; if (file_exists($dest)) continue; // Dual-format: GCM-V2 envelope (v1.10+) and legacy // CBC-V1 envelope; plaintext inside is base64(file // bytes). Very-old pre-envelope format was raw // base64(iv || cipher) — unlikely at this point but // cheap to keep as a last-resort fallback. $payload = $att['data'] ?? ''; $decrypted = false; if (str_starts_with($payload, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($payload, HYPRCRD_RELAY_CBC_MAGIC)) { $plain = hyprcrdRelayDecrypt($payload, $relayKey); if ($plain !== '') $decrypted = $plain; } else { $legacyKey = substr(hash('sha256', $relayKey), 0, 32); $raw = base64_decode($payload); if ($raw !== false && strlen($raw) >= 17) { $decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $legacyKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); } } if ($decrypted !== false) { $fileBytes = base64_decode($decrypted); if ($fileBytes !== false) $decrypted = $fileBytes; file_put_contents($dest, $decrypted); } } } } } // Drain pending sync-event notifications so the JS caller can toast // them. GET /api/sync-events on the relay auto-marks fetched events. // Best-effort — older relays won't have the endpoint. $syncEvents = []; $syncDebug = ['requested' => true]; $sch = curl_init(rtrim($relayUrl, '/') . '/api/sync-events'); curl_setopt_array($sch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 5, ]); hyprcrdApplySafeCurlOpts($sch); $sResp = curl_exec($sch); $sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE); curl_close($sch); $syncDebug['status'] = $sStatus; if ($sStatus === 200) { $decoded = json_decode($sResp, true); if (is_array($decoded) && isset($decoded['events'])) { $syncEvents = $decoded['events']; $syncDebug['count'] = count($syncEvents); } else { $syncDebug['parse_error'] = substr((string)$sResp, 0, 200); } } else { $syncDebug['body'] = substr((string)$sResp, 0, 200); } echo json_encode(['ok' => true, 'pulled' => $pulled, 'sync_events' => $syncEvents, 'sync_debug' => $syncDebug]); 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, ]); hyprcrdApplySafeCurlOpts($ch); $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(); $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 = ?'); $card->execute([$cardId]); $card = $card->fetch(); if (!$card) { http_response_code(404); echo json_encode(['error' => 'card not found']); exit; } // Generate relay_uid if not set $relayUid = $card['relay_uid'] ?? null; if (!$relayUid) { $relayUid = bin2hex(random_bytes(16)); $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]); } $encryptField = fn($text) => hyprcrdRelayEncrypt((string)$text, $relayKey); $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 = APP_DEBUG ? fopen('php://temp', 'w+') : null; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 10, ]); hyprcrdApplySafeCurlOpts($ch); if ($debugStream) { curl_setopt($ch, CURLOPT_VERBOSE, true); curl_setopt($ch, CURLOPT_STDERR, $debugStream); } $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlDebug = ''; if ($debugStream) { rewind($debugStream); $curlDebug = stream_get_contents($debugStream); fclose($debugStream); } curl_close($ch); if ($status === 200 || $status === 201) { $resData = json_decode($response, true); if (!empty($resData['id'])) { $db->prepare("INSERT OR IGNORE INTO relay_pushed (relay_msg_id) VALUES (?)")->execute([$resData['id']]); } // Upload attachments (images referenced in body). // Same envelope format as title/body so the relay can decrypt with // the sender's secret and re-encrypt with the recipient's secret. $cardBody = $card['body'] ?? ''; preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches); $uploadDir = UPLOAD_DIR; foreach (array_unique($attMatches[1] ?? []) as $filename) { $filePath = $uploadDir . '/' . $filename; if (!file_exists($filePath)) continue; $raw = file_get_contents($filePath); $envelope = $encryptField($raw); $attData = [ 'relay_uid' => $relayUid, 'filename' => $filename, 'data' => $envelope, ]; if ($recipient) $attData['recipient'] = $recipient; $attPayload = json_encode($attData); $ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments'); curl_setopt_array($ach, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $attPayload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 30, ]); hyprcrdApplySafeCurlOpts($ach); curl_exec($ach); curl_close($ach); } echo json_encode(['ok' => true]); 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); } // In production, don't leak upstream URL / raw bodies / curl traces — // APP_DEBUG=1 re-enables them for local troubleshooting. $out = ['ok' => false, 'error' => "relay returned HTTP $status$relayMsg", 'sent_username' => $relayUser]; if (APP_DEBUG) { $out['relay_url'] = rtrim($relayUrl, '/') . '/api/messages'; $out['response_body'] = is_string($response) ? substr($response, 0, 500) : null; $out['curl_debug'] = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1', $curlDebug); } echo json_encode($out); exit; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; } // ── HTML Shell ── ?> hyprcrd cssLinks() ?>

hyprcrd

v

keyboard shortcuts

help

concepts

cards are the basic unit — each card has a title, a markdown body, a color, and optional tags. Cards appear as tiles on the board. Click to preview, double-click to edit, right-click for actions. Cards can contain images, checklists, links to other cards, and any markdown content.

buckets organize cards into categories (like folders or columns). Each bucket has a name and color. The bucket bar at the top lets you filter the board. Special built-in views: all, current, favorites, journal, unsorted. Drag a card onto a bucket tab to move it. Drag to to create a new bucket. Buckets can be nested one level deep — drag a bucket onto another to create a super-bucket with a dropdown for its children.

groups let you nest cards. Drag a card to the center of another card to group them. The parent card shows a badge with the member count. Click the badge or use "open group" in the context menu to see all members. Inside a group you can reorder, drag cards out to ungroup, or drag the first card to promote a new parent. Use "dissolve group" to ungroup all at once.

tags are written as #tagname in the card body and extracted automatically. They support hierarchy: #project/frontend. Click a tag to filter — Ctrl+click to combine multiple tags (AND). Assign via right-click > tags. Each segment of a hierarchical tag is independently clickable.

links connect cards. Write [[Card Title]] in the body to create a link. Linked cards are clickable in preview and show up as backlinks on the target card. The split view shows related cards based on shared tags, links, and content overlap.

Calendar shows cards on their dates. Journal cards (YYYY-MM-DD) and appointments (YYYY-MM-DD — Subject) appear on their date. Birthdays (#geburtstag) repeat yearly. Sync with ownCloud/Nextcloud via CalDAV.

shortcuts

Press ? for the full keyboard reference.

board

click card open preview (or side panel in split mode)
double-click open editor
right-click context menu (color, bucket, tags, copy text, mark done, join, ...)
Ctrl+click multi-select cards
drag card reorder, move to bucket, or group
navigate cards with arrow keys, Enter to open
click tag filter by tag
Ctrl+click tag add/remove tag filter (AND)
group badge expand group
Ctrl+N quick capture — create card in active bucket
1–9 switch to bucket by number

context menu

colorchange card color
movemove to a different bucket
tagsassign tags via picker
copy textcopy card body as plain text (strips markdown)
mark donetoggle #done tag on any card
join cardsmerge multi-selected cards into one
duplicateduplicate card
save .mddownload as markdown + images
deletedelete card (with undo)

preview

double-click image fullscreen (scroll to zoom, drag to pan)
prev/next image in gallery
click [[link]] open linked card
click checkbox toggle task completion
zen modefull-screen reading (drag edges to resize)
split viewshow related / tagged cards side-by-side

editor

Ctrl+S save
Ctrl+Shift+S save and close
Ctrl+H find + replace in card
Ctrl+F find in card
Alt+/move line up/down
Shift+Alt+/duplicate line
Tabindent (4 spaces)
paste / drag upload image, audio, or PDF
extractselection to new card
PDFupload + link a PDF from toolbar
drag dividerresize editor / preview split

autocomplete

[[ link to card (search by title)
# tag autocomplete (existing tags)
? snippet menu (see below)
navigate suggestions
Enter / Tabaccept suggestion

snippets type ? + keyword + space

?dat current date
?time current time
?now date + time
?week calendar week (KW15)
?todo checkbox - [ ]
?hr horizontal rule ---
?h1 ?h2 ?h3 headings
?table markdown table template
?code fenced code block

inline calculator

?3+5 Space replaces with 8
34+56? Space becomes 34+56=90
operators+ - * / () % ^

tags

#tagnamewrite tags in card body
#parent/child hierarchical tags
right-click > tagsassign tags via context menu
click segment each path level is clickable

search

text search titles + body
#tag filter by tag
#tag wordtag filter + text search combined
#tag1 #tag2 multiple tags (AND)
bucket filter dropdown to restrict scope
Replacetoggle replace mode — bulk find + replace across all matching cards
Ctrl+Shift+H open search directly in replace mode
drag result drag onto another card to group

markdown

[[title]] card link
```gallery image gallery grid (click to fullscreen)
```tree tree-style list
```bullets bullet-style list
- [ ] / - [x] interactive checkboxes
http://... external links open in new tab

calendar

click day open day popup with all cards
click item open card preview
double-click item open card editor
right-click item context menu
+ termin new appointment (choose calendar)
+ journal new/open journal for that day
geb.new birthday (repeats yearly)
navigate months

CalDAV sync

gear iconCalDAV settings (URL, credentials, calendar colors)
syncmanual sync (also auto every 2 min)
#termin card syncs as calendar event
#geburtstag card syncs as yearly birthday
#termin #privat pushes to "Privat" calendar
recurringbirthdays + RRULE events repeat in view

views & toolbar

splitside-by-side board + preview panel
superviewcompact multi-column title list with hover cards
hide donetoggle cards tagged #done on/off
hide terminetoggle appointment cards on/off
hide taggedtoggle tagged cards on/off
sortmanual / newest / oldest / alpha / tag
assetsgallery of all images, audio, and PDFs across cards — with orphan detection

relay

The relay is an optional self-hosted server that connects multiple hyprcrd instances. Configure the relay URL and your client secret in Settings > Relay. The relay has its own admin UI for user management.

collab-bucketsshared buckets between tenants — exclusive card locking, unread indicators, E2E encrypted sync
chatencrypted chat between tenants — footer flyout, delivery + read receipts
composesend messages to other tenants via the relay inbox
inboxread received messages — synced badge shows delivery status
card lockingediting a collab card locks it for all others — takeover on first keystroke
client secretper-tenant E2E key — set in relay account settings, shared across devices

shopping list (phone sync)

Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Click the header's Shopping button and check any number of lists to activate them — all active lists are pushed to the relay and shown stacked on the phone.

The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open shopping from the menu. You see only the unchecked - [ ] items as tappable rows. Tapping an item flips it to - [x] on the relay in-place; the next client tick pulls that back and updates the local card.

Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).

trigger tags#einkauf or #shopping — either counts
pick activeheader Shopping button → radio select → Save
phone accessrelay UI → shopping tab (same login)
sync cadencepiggybacks on the shared sync tick (see Settings → Relay)

import / export

exportJSON + media ZIP (re-importable)
vaultObsidian / Logseq markdown vault
save .mdsingle card as markdown + images (right-click)
importrestore from exported ZIP
© hyprcrd · v

settings

hyprcrd

v

Self-hosted card manager with markdown, tags, calendar, CalDAV sync, encrypted WebDAV notes, and Obsidian-compatible export.

—
cards
—
buckets
—
tags

Press F1 for help

global_tasks

htmlFragments() ?> scriptTags() ?>