This commit is contained in:
th committed 2026-04-09 19:14:52 +02:00
1 parent 5aa3abc860
commit 5f9db690a4
24 files changed
+1447 -597

No files matched your search

+439 -159
View File
@@ -3,7 +3,6 @@
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/CalDavClient.php';
require_once __DIR__ . '/../src/CalDavSync.php';
require_once __DIR__ . '/../src/WebDavNotes.php';
$db = Database::connect();
@@ -105,6 +104,49 @@ if (str_starts_with($route, '/api/auth')) {
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);
@@ -121,49 +163,42 @@ if (str_starts_with($route, '/api/auth')) {
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);
$hasVaultKey = !empty($db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn());
$credKeys = ['caldav_url', 'caldav_username', 'caldav_password'];
$reEncrypted = [];
$rekeyOk = true;
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;
$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));
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();
$db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'");
echo json_encode(['ok' => true, 'rekey' => true]);
}
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;
}
@@ -239,7 +274,7 @@ if (str_starts_with($route, '/api/')) {
}
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());
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;
}
@@ -595,6 +630,132 @@ if (str_starts_with($route, '/api/')) {
exit;
}
// Import Logseq vault
if ($route === '/api/import-logseq' && $method === 'POST') {
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400); echo json_encode(['error' => 'no file']); exit;
}
$zip = new ZipArchive();
if ($zip->open($_FILES['file']['tmp_name']) !== true) {
http_response_code(400); echo json_encode(['error' => 'invalid zip']); exit;
}
$uploadDir = __DIR__ . '/uploads';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
// Find the vault root (directory containing journals/ or pages/)
$prefix = '';
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (preg_match('#^(.*/)?journals/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
if (preg_match('#^(.*/)?pages/#', $name, $m)) { $prefix = $m[1] ?? ''; break; }
}
// 1. Copy assets → uploads with name mapping
$assetMap = []; // original filename → new hex filename
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_starts_with($name, $prefix . 'assets/')) continue;
if (str_ends_with($name, '/')) continue;
$content = $zip->getFromIndex($i);
if ($content === false) continue;
$origName = basename($name);
$ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin';
$newName = bin2hex(random_bytes(16)) . '.' . strtolower($ext);
file_put_contents($uploadDir . '/' . $newName, $content);
$assetMap[$origName] = $newName;
}
// Helper: rewrite image references in markdown body
$rewriteImages = function($body) use ($assetMap) {
// Logseq patterns: ![desc](../assets/file.png), ![desc](/assets/file.png), ![desc](assets/file.png)
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?(?:assets/)?([^)]+)\)#', function($m) use ($assetMap) {
$file = basename($m[2]);
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
return $m[0];
}, $body);
// Logseq block-level image embed: {{embed ((image))}} or just bare refs
// Also handle: ![image.png](../assets/image.png) with URL-encoded filenames
$body = preg_replace_callback('#!\[([^\]]*)\]\((?:\.\./)?assets/([^)]+)\)#', function($m) use ($assetMap) {
$file = urldecode(basename($m[2]));
if (isset($assetMap[$file])) return '![' . $m[1] . '](uploads/' . $assetMap[$file] . ')';
return $m[0];
}, $body);
return $body;
};
// Helper: clean Logseq markdown (remove bullet prefixes, properties)
$cleanBody = function($body) {
$lines = explode("\n", $body);
$cleaned = [];
foreach ($lines as $line) {
// Skip Logseq properties (key:: value at start)
if (preg_match('/^[a-z_-]+::\s/', $line)) continue;
// Remove leading "- " bullet outline format (Logseq default)
$line = preg_replace('/^(\t*)- /', '$1', $line);
$cleaned[] = $line;
}
return implode("\n", $cleaned);
};
// Get or create "import" bucket
$importBucket = $db->query("SELECT id FROM buckets WHERE name = 'import'")->fetchColumn();
if (!$importBucket) {
$db->prepare("INSERT INTO buckets (name, position) VALUES ('import', 999)")->execute();
$importBucket = $db->lastInsertId();
}
$imported = 0;
// 2. Import journals → cards with date title
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_starts_with($name, $prefix . 'journals/')) continue;
if (!str_ends_with($name, '.md')) continue;
$content = $zip->getFromIndex($i);
if ($content === false || !trim($content)) continue;
$basename = pathinfo(basename($name), PATHINFO_FILENAME);
// 2025_06_18 → 2025-06-18
$title = str_replace('_', '-', $basename);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $title)) continue;
$body = $rewriteImages($cleanBody($content));
// Extract tags
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id, created_at) VALUES (?, ?, ?, ?, -1, ?, ?)')
->execute([$title, trim($body), '#38bdf8', $tags, $importBucket, $title . ' 00:00:00']);
$imported++;
}
// 3. Import pages → cards with page name as title
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (!str_starts_with($name, $prefix . 'pages/')) continue;
if (!str_ends_with($name, '.md')) continue;
$content = $zip->getFromIndex($i);
if ($content === false || !trim($content)) continue;
$title = pathinfo(basename($name), PATHINFO_FILENAME);
// Skip Logseq internal pages
if (in_array(strtolower($title), ['contents', 'favorites'])) continue;
$body = $rewriteImages($cleanBody($content));
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$tags = implode(',', array_unique(array_map('strtolower', $tm[1] ?? [])));
$db->prepare('INSERT INTO cards (title, body, color, tags, position, bucket_id) VALUES (?, ?, ?, ?, -1, ?)')
->execute([$title, trim($body), '#8b5cf6', $tags, $importBucket]);
$imported++;
}
$zip->close();
echo json_encode(['ok' => true, 'imported' => $imported, 'assets' => count($assetMap)]);
exit;
}
// CalDAV endpoints
$caldav = new CalDavSync($db);
@@ -616,131 +777,223 @@ if (str_starts_with($route, '/api/')) {
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;
// ── 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;
}
// Re-key notes: decrypt with old key, re-encrypt with current key
if ($route === '/api/notes/rekey' && $method === 'POST') {
if ($route === '/api/relay/settings' && $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; }
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;
}
$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();
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; }
// 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));
$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);
// 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)) {
// Clean up tracking row since relay will delete it after 24h anyway
$db->prepare('DELETE FROM relay_pushed WHERE relay_msg_id = ?')->execute([$msg['id']]);
continue;
}
}
// 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; }
// Decrypt
$title = $msg['title'] ?? '';
$body = $msg['body'] ?? '';
if (str_starts_with($body, 'HYPRCRD-RELAY-V1')) {
$enc = trim(explode("\n", $body, 2)[1] ?? '');
$raw = base64_decode($enc);
if (strlen($raw) >= 17) {
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($plain !== false) $body = $plain;
}
}
if (str_starts_with($title, 'HYPRCRD-RELAY-V1')) {
$enc = trim(explode("\n", $title, 2)[1] ?? '');
$raw = base64_decode($enc);
if (strlen($raw) >= 17) {
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($plain !== false) $title = $plain;
}
}
$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 (!$title) $title = substr(strip_tags($body), 0, 60) ?: 'Relay message';
$body = trim($body);
$relayUid = $msg['relay_uid'] ?? null;
if (!$pass) { echo json_encode(['ok' => false, 'error' => 'cannot decrypt CalDAV password']); exit; }
// 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();
}
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; }
preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-]+)/m', $body, $tm);
$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; }
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++;
// Find working key by testing first encrypted note
$parsed = parse_url($notesUrl);
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$workingKey = null;
// 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);
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 ($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);
}
}
}
}
}
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;
echo json_encode(['ok' => true, 'pulled' => $pulled]); 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;
// 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;
@@ -835,7 +1088,7 @@ if (str_starts_with($route, '/api/')) {
</header>
<div id="upcoming-banner" class="hidden shrink-0"></div>
<div id="board-wrapper" class="flex flex-1 min-h-0 overflow-hidden">
<div id="board" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-6 flex-1 overflow-y-auto content-start"></div>
<div id="board" class="relative grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 px-6 pt-5 pb-6 flex-1 overflow-y-auto content-start"></div>
<div id="board-preview" class="hidden border-l border-t-border bg-t-surface flex flex-col" style="width:40%;min-width:300px;max-width:500px;">
<div id="board-preview-header" class="flex items-center gap-2 px-4 py-2.5 border-b border-t-border shrink-0 relative">
<div class="card-bar w-8 rounded-sm" id="board-preview-color"></div>
@@ -859,17 +1112,21 @@ if (str_starts_with($route, '/api/')) {
<footer class="shrink-0 z-40 border-t border-t-border bg-t-bg shadow-[0_-4px_20px_-4px_rgba(0,0,0,0.3)] px-6 py-2 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 flex-wrap">
<input type="file" id="import-file" accept=".zip,application/zip" class="hidden">
<input type="file" id="import-logseq-file" accept=".zip,application/zip" class="hidden">
<button id="logout-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
</button>
<button id="import-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-upload-simple text-sm"></i><span class="font-pixel text-[10px]">import</span>
</button>
<button id="export-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>
<button id="export-md-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-vault text-sm"></i><span class="font-pixel text-[10px]">vault</span>
<button id="import-logseq-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-graph text-sm"></i><span class="font-pixel text-[10px]">logseq import</span>
</button>
<button id="logout-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
<i class="ph ph-sign-out text-sm"></i><span class="font-pixel text-[10px]">logout</span>
<button id="export-md-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-vault text-sm"></i><span class="font-pixel text-[10px]">export vault</span>
</button>
<button id="settings-btn" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
<i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">settings</span>
@@ -1080,7 +1337,8 @@ if (str_starts_with($route, '/api/')) {
<button class="settings-tab active" data-tab="branding"><i class="ph ph-terminal-window text-sm"></i><span class="font-pixel text-[10px]">hyprcrd</span></button>
<button class="settings-tab" data-tab="account"><i class="ph ph-user text-sm"></i><span class="font-pixel text-[10px]">account</span></button>
<button class="settings-tab" data-tab="theme"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">themes</span></button>
<button class="settings-tab" data-tab="sync"><i class="ph ph-cloud-arrow-up text-sm"></i><span class="font-pixel text-[10px]">sync</span></button>
<button class="settings-tab" data-tab="caldav"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">calendar</span></button>
<button class="settings-tab" data-tab="relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">relay</span></button>
<button class="settings-tab" data-tab="behavior"><i class="ph ph-sliders text-sm"></i><span class="font-pixel text-[10px]">behavior</span></button>
<button class="settings-tab" data-tab="notifications"><i class="ph ph-bell text-sm"></i><span class="font-pixel text-[10px]">notifications</span></button>
</div>
@@ -1112,8 +1370,8 @@ if (str_starts_with($route, '/api/')) {
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3">color theme</div>
<div id="settings-theme-list" class="grid grid-cols-2 gap-2"></div>
</div>
<div id="settings-panel-sync" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2">CalDAV / WebDAV</div>
<div id="settings-panel-caldav" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-calendar-dots text-xs"></i> CalDAV sync</div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">server url</label><input id="caldav-url" type="text" name="caldav_url_x" placeholder="https://cloud.example.com/remote.php/dav/calendars/user/" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div class="flex gap-3">
<div class="flex-1"><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="caldav-user" type="text" name="caldav_user_x" autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
@@ -1130,14 +1388,22 @@ if (str_starts_with($route, '/api/')) {
<div class="flex flex-wrap gap-2 pt-2 border-t border-t-border">
<button id="caldav-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="caldav-sync-now" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-arrows-clockwise text-sm"></i><span class="font-pixel text-[10px]">sync now</span></button>
<button id="notes-reset" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-arrow-counter-clockwise text-sm"></i><span class="font-pixel text-[10px]">reset notes</span></button>
<button id="notes-rekey" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-key text-sm"></i><span class="font-pixel text-[10px]">re-key notes</span></button>
</div>
<div class="flex items-center gap-2">
<span id="caldav-sync-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="caldav-log" class="text-[10px] text-t-dim font-mono max-h-32 overflow-y-auto hidden border-t border-t-border pt-2"></div>
</div>
<div id="settings-panel-relay" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-broadcast text-xs"></i> relay</div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">relay url</label><input id="relay-url" type="text" placeholder="https://relay.example.com" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">shared secret</label><input id="relay-key" type="text" placeholder="shared secret" autocomplete="off" style="-webkit-text-security:disc;" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
<div class="flex gap-2">
<button id="relay-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="relay-status" class="font-pixel text-[9px] text-t-dim self-center"></span>
</div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-lightning text-xs"></i> current view</div>
@@ -1247,7 +1513,17 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<div id="settings-panel-account" class="hidden space-y-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3">change password</div>
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-key text-xs"></i> vault key</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-2">Shared encryption key for Notes and CalDAV credentials. Must be identical on all connected hyprcrd instances.</p>
<div class="flex gap-2">
<input id="settings-vault-key" type="text" placeholder="enter vault key" autocomplete="off" style="-webkit-text-security:disc;" class="flex-1 bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
<button id="settings-vault-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
</div>
<span id="settings-vault-status" class="font-pixel text-[9px] text-t-dim mt-1 block"></span>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><i class="ph ph-lock text-xs"></i> change password</div>
<div>
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">current password</label>
<input id="settings-pw-current" type="password" autocomplete="off" data-1p-ignore data-lpignore="true" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono">
@@ -1264,6 +1540,7 @@ if (str_starts_with($route, '/api/')) {
<button id="settings-pw-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">change password</span></button>
<span id="settings-pw-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
</div>
</div>
</div>
</div>
@@ -1447,7 +1724,7 @@ if (str_starts_with($route, '/api/')) {
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">close</span>
</button>
</div>
<div id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start"></div>
<div id="gallery-grid" class="flex-1 overflow-y-auto p-4 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 content-start select-none"></div>
</div>
</div>
@@ -1466,6 +1743,9 @@ if (str_starts_with($route, '/api/')) {
<button id="select-tag" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">tag</span>
</button>
<button id="select-unbucket" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-folder-minus text-sm"></i><span class="font-pixel text-[10px]">unsort</span>
</button>
<button id="select-export" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-download-simple text-sm"></i><span class="font-pixel text-[10px]">export</span>
</button>