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']]); 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; } 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') { 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 (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'] 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; } http_response_code(404); echo json_encode(['error' => 'not found']); exit; } // ── HTML Shell ── ?>
click a card to preview