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; } // 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; } // Re-encrypt CalDAV credentials with new key (atomic — rollback on failure) $oldKey = substr(hash('sha256', $hash), 0, 32); $newHash = password_hash($newPass, PASSWORD_BCRYPT); $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; $iv = substr($raw, 0, 16); $cipher = base64_encode(substr($raw, 16)); $plain = openssl_decrypt($cipher, 'aes-256-cbc', $oldKey, 0, $iv); 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' => 'could not re-encrypt credentials — password NOT changed. Please re-enter CalDAV credentials first.']); exit; } // All re-encryption succeeded — commit everything $db->beginTransaction(); // Save old hash for re-key recovery $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('prev_password_hash', :h)")->execute(['h' => $hash]); 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(); // Mark all notes for re-push (re-encrypt with new key) $db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'"); 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, 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; } // 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; } if ($route === '/api/caldav/log' && $method === 'GET') { echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit; } // WebDAV Notes sync if ($route === '/api/notes/sync' && $method === 'POST') { $notes = new WebDavNotes($db); echo json_encode($notes->sync()); exit; } // Re-key notes: decrypt with old key, re-encrypt with current key if ($route === '/api/notes/rekey' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $oldPass = $input['old_password'] ?? ''; if (!$oldPass) { http_response_code(400); echo json_encode(['error' => 'old password required']); exit; } $currentHash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); $currentKey = substr(hash('sha256', $currentHash), 0, 32); $prevHash = $db->query("SELECT value FROM settings WHERE key = 'prev_password_hash'")->fetchColumn(); // Build candidate old keys: try prev hash first, then verify password against it $candidateKeys = []; if ($prevHash) { $candidateKeys[] = substr(hash('sha256', $prevHash), 0, 32); // Maybe password was changed multiple times — prev_hash might not match oldPass if (password_verify($oldPass, $prevHash)) { // Confirmed: prev hash matches old password array_unshift($candidateKeys, substr(hash('sha256', $prevHash), 0, 32)); } } // Also try current key (notes might already be on current key) $candidateKeys[] = $currentKey; // Get WebDAV client $caldavSync = new CalDavSync($db); $settings = $caldavSync->getSettings(); if (empty($settings['url'])) { echo json_encode(['ok' => false, 'error' => 'no CalDAV credentials']); exit; } $url = $settings['url']; $user = $settings['username']; $passEnc = $db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn(); $passRaw = base64_decode($passEnc); $passIv = substr($passRaw, 0, 16); $passCipher = base64_encode(substr($passRaw, 16)); $pass = openssl_decrypt($passCipher, 'aes-256-cbc', $currentKey, 0, $passIv) ?: ''; if (!$pass) { echo json_encode(['ok' => false, 'error' => 'cannot decrypt CalDAV password']); exit; } if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) { $notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/'; } else { echo json_encode(['ok' => false, 'error' => 'cannot derive notes URL']); exit; } $client = new CalDavClient($notesUrl, $user, $pass); $notes = new WebDavNotes($db); $remoteNotes = $notes->listRemoteNotes(); if (empty($remoteNotes)) { echo json_encode(['ok' => false, 'error' => 'no remote notes']); exit; } // Find working key by testing first encrypted note $parsed = parse_url($notesUrl); $base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''); $workingKey = null; foreach ($remoteNotes as $testNote) { $testRes = $client->request('GET', $base . $testNote['href']); $testBody = $testRes['body'] ?? ''; if (!str_starts_with($testBody, 'HYPRCRD-ENCRYPTED-V1')) continue; $encData = trim(explode("\n", $testBody, 2)[1] ?? ''); $raw = base64_decode($encData); if (strlen($raw) < 17) continue; $iv = substr($raw, 0, 16); $cipher = substr($raw, 16); foreach ($candidateKeys as $tryKey) { $plain = openssl_decrypt($cipher, 'aes-256-cbc', $tryKey, OPENSSL_RAW_DATA, $iv); if ($plain !== false && (str_contains($plain, '#') || str_contains($plain, "\n"))) { $workingKey = $tryKey; break 2; } } } if (!$workingKey) { echo json_encode(['ok' => false, 'error' => 'could not decrypt — old key not found']); exit; } if ($workingKey === $currentKey) { echo json_encode(['ok' => true, 'rekeyed' => 0, 'message' => 'notes already on current key']); exit; } // Re-encrypt all notes $count = 0; foreach ($remoteNotes as $note) { $res = $client->request('GET', $base . $note['href']); if ($res['status'] !== 200) continue; $body = $res['body']; if (!str_starts_with($body, 'HYPRCRD-ENCRYPTED-V1')) continue; $encData = trim(explode("\n", $body, 2)[1] ?? ''); $raw = base64_decode($encData); if (strlen($raw) < 17) continue; $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $workingKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); if ($plain === false) continue; $newIv = random_bytes(16); $newCipher = openssl_encrypt($plain, 'aes-256-cbc', $currentKey, OPENSSL_RAW_DATA, $newIv); $client->request('PUT', $base . $note['href'], "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($newIv . $newCipher), ['Content-Type: text/markdown; charset=utf-8']); $count++; } // Clear and re-pull $db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'"); $db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'"); $db->exec("DELETE FROM settings WHERE key = 'prev_password_hash'"); echo json_encode(['ok' => true, 'rekeyed' => $count]); exit; } // Force re-sync: clear all notes state and re-pull if ($route === '/api/notes/reset' && $method === 'POST') { $deleted = $db->exec("DELETE FROM cards WHERE caldav_uid LIKE 'note:%'"); $db->exec("DELETE FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'"); $notes = new WebDavNotes($db); $result = $notes->sync(); $result['reset'] = $deleted; echo json_encode($result); 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