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; } // 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; } $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 (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 $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: , ,  $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:  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 ── ?>
click a card to preview