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 ── ?>
click a card to preview