true, 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'samesite' => 'Strict', ]); 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'); // 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) { $input = json_decode(file_get_contents('php://input'), true); $pw = $input['password'] ?? ''; if (strlen($pw) < 4) { http_response_code(400); echo json_encode(['error' => 'password too short (min 4)']); 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 (rate limited) if ($route === '/api/auth/login' && $method === 'POST') { $attempts = $_SESSION['login_attempts'] ?? 0; $lastAttempt = $_SESSION['login_last'] ?? 0; if ($attempts >= 5 && time() - $lastAttempt < 60) { http_response_code(429); echo json_encode(['error' => 'too many attempts, wait 60s']); 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)); $_SESSION['login_attempts'] = 0; echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]); } else { $_SESSION['login_attempts'] = $attempts + 1; $_SESSION['login_last'] = time(); 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') { $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)) { http_response_code(403); echo json_encode(['error' => 'wrong password']); exit; } if (strlen($newPass) < 4) { http_response_code(400); echo json_encode(['error' => 'min 4 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); return implode(',', array_unique(array_map('strtolower', $matches[1] ?? []))); } // 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'] ?? ''; $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags)')->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), ]); 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 WHERE c.id = ' . $db->lastInsertId())->fetch()); 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, 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 $card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id'); $card->execute(['id' => $m[1]]); $uid = $card->fetchColumn(); if ($uid) { $db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]); } $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; } if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') { $input = json_decode(file_get_contents('php://input'), true); $fields = []; $params = ['id' => $m[1]]; foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } } if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } 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]]); echo json_encode($card->fetch()); 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; } $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); $fields = []; $params = ['id' => $m[1]]; foreach (['name', 'position', 'color'] 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') { $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_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 = __DIR__ . '/uploads'; $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 = __DIR__ . '/uploads/' . 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 = __DIR__ . '/uploads'; 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']; $extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a']; $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 = __DIR__ . '/uploads'; 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 = __DIR__ . '/uploads'; $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, created_at) VALUES (:name, :position, :created_at)'); $stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]); $bucketMap[$oldId] = $db->lastInsertId(); } // Import cards foreach ($data['cards'] as $c) { $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, created_at, updated_at) VALUES (:title, :body, :color, :bucket_id, :position, :created_at, :updated_at)'); $stmt->execute([ 'title' => $c['title'], 'body' => $c['body'], 'color' => $c['color'], 'bucket_id' => $bucketId, 'position' => $c['position'] ?? 0, 'created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'), 'updated_at' => $c['updated_at'] ?? date('Y-m-d H:i:s'), ]); } // Extract uploads $uploadDir = __DIR__ . '/uploads'; if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') { $content = $zip->getFromIndex($i); if ($content !== false) { file_put_contents($uploadDir . '/' . basename($name), $content); } } } $zip->close(); echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]); exit; } // Import Logseq vault if ($route === '/api/import-logseq' && $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; } $uploadDir = __DIR__ . '/uploads'; if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); // Find the vault root (directory containing journals/ or pages/) $prefix = ''; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; } if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; } } // 1. Copy assets → uploads with name mapping $assetMap = []; // original filename → new hex filename for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, $prefix . 'assets/')) continue; if (str_ends_with($name, '/')) continue; $content = $zip->getFromIndex($i); if ($content === false) continue; $origName = basename($name); $ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin'; $newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext); file_put_contents($uploadDir . '/' . $newName, $content); $assetMap[$origName] = $newName; } // Helper: rewrite image references in markdown body $rewriteImages = function($body) use ($assetMap) { // Logseq patterns: ![desc](../assets/file.png), ![desc](/assets/file.png), ![desc](assets/file.png) $body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function($m) use ($assetMap) { $file = basename($m[2]); if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')'; return $m[0]; }, $body); // Logseq block-level image embed: {{embed ((image))}} or just bare refs // Also handle: ![image.png](../assets/image.png) with URL-encoded filenames $body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function($m) use ($assetMap) { $file = urldecode(basename($m[2])); if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')'; return $m[0]; }, $body); return $body; }; // Helper: clean Logseq markdown (remove bullet prefixes, properties) $cleanBody = function($body) { $lines = explode("\n", $body); $cleaned = []; foreach ($lines as $line) { // Skip Logseq properties (key:: value at start) if (preg_match('/^[a-z_-]+::\s/', $line)) continue; // Remove leading "- " bullet outline format (Logseq default) $line = preg_replace('/^(\t*)- /', '$1', $line); $cleaned[] = $line; } return implode("\n", $cleaned); }; // Get or create "import" bucket $importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn(); if (!$importBucket) { $db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute(); $importBucket = $db->lastInsertId(); } $imported = 0; // 2. Import journals → cards with date title for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, $prefix . 'journals/')) continue; if (!str_ends_with($name, '.md')) continue; $content = $zip->getFromIndex($i); if ($content === false || !trim($content)) continue; $basename = pathinfo(basename($name), PATHINFO_FILENAME); // 2025_06_18 → 2025-06-18 $title = str_replace('_', '-', $basename); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue; $body = $rewriteImages($cleanBody($content)); // Extract tags 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, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)') ->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']); $imported++; } // 3. Import pages → cards with page name as title for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (!str_starts_with($name, $prefix . 'pages/')) continue; if (!str_ends_with($name, '.md')) continue; $content = $zip->getFromIndex($i); if ($content === false || !trim($content)) continue; $title = pathinfo(basename($name), PATHINFO_FILENAME); // Skip Logseq internal pages if (in_array(strtolower($title), ['contents', 'favorites'])) continue; $body = $rewriteImages($cleanBody($content)); 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, bucket_id) VALUES (?, ?, ?, ?, -1, ?)') ->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]); $imported++; } $zip->close(); echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]); 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() ?: ''; $hasKey = !empty($db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn()); echo json_encode(['url' => $url, 'has_key' => $hasKey]); exit; } if ($route === '/api/relay/settings' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]); if (!empty($input['key'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]); echo json_encode(['ok' => true]); exit; } if ($route === '/api/relay/poll' && $method === 'POST') { $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => true, 'pulled' => 0]); exit; } $ch = curl_init(rtrim($relayUrl, '/') . '/api/messages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200) { echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; } $messages = json_decode($response, true); if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; } $pulled = 0; $encKey = substr(hash('sha256', $relayKey), 0, 32); $decryptField = function($text) use ($encKey) { if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text; $raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? '')); if (strlen($raw) < 17) return $text; $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); return $plain !== false ? $plain : $text; }; // 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++; // 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-Key: ' . $relayKey], CURLOPT_TIMEOUT => 30, ]); $attResponse = curl_exec($ach); $attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE); curl_close($ach); if ($attStatus === 200) { $attachments = json_decode($attResponse, true); $uploadDir = __DIR__ . '/uploads'; 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; $raw = base64_decode($att['data']); if (strlen($raw) >= 17) { $decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); if ($decrypted !== false) file_put_contents($dest, $decrypted); } } } } } echo json_encode(['ok' => true, 'pulled' => $pulled]); exit; } // Push a card to relay if ($route === '/api/relay/push' && $method === 'POST') { $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; } $input = json_decode(file_get_contents('php://input'), true); $cardId = $input['card_id'] ?? 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]); } $encKey = substr(hash('sha256', $relayKey), 0, 32); $encryptField = function($text) use ($encKey) { $iv = random_bytes(16); $cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher); }; $payload = json_encode([ 'title' => $encryptField($card['title'] ?? ''), 'body' => $encryptField($card['body'] ?? ''), 'color' => $card['color'] ?? '#38bdf8', 'tags' => $card['tags'] ?? '', 'relay_uid' => $relayUid, ]); $ch = curl_init(rtrim($relayUrl, '/') . '/api/messages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey], CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); 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) $cardBody = $card['body'] ?? ''; preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $cardBody, $attMatches); $uploadDir = __DIR__ . '/uploads'; foreach (array_unique($attMatches[1] ?? []) as $filename) { $filePath = $uploadDir . '/' . $filename; if (!file_exists($filePath)) continue; $raw = file_get_contents($filePath); $iv = random_bytes(16); $cipher = openssl_encrypt($raw, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv); $attPayload = json_encode([ 'relay_uid' => $relayUid, 'filename' => $filename, 'data' => base64_encode($iv . $cipher), ]); $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-Key: ' . $relayKey], CURLOPT_TIMEOUT => 30, ]); curl_exec($ach); curl_close($ach); } echo json_encode(['ok' => true]); exit; } echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; } // ── HTML Shell ── ?> hyprcrd

hyprcrd

v

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.

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

Ctrl+K open / close search
Ctrl+Z undo last action
F1 open / close help
Escape close any overlay

board

click card open preview (or side panel in split mode)
double-click open editor
right-click context menu (color, bucket, tags, ...)
Ctrl+click multi-select cards
drag card reorder, move to bucket, or group
click tag filter by tag
Ctrl+click tag add/remove tag filter (AND)
group badge expand group

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
Alt+/move line up/down
Shift+Alt+/duplicate line
Tabindent (4 spaces)
paste / drag upload and insert image
extractselection to new card
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

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

splitside-by-side board + preview panel
superviewcompact multi-column title list with hover cards
hide terminetoggle appointment cards on/off
sortmanual / newest / oldest / alpha / tag

import / export

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

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