bak
This commit is contained in:
1 parent
5aa3abc860
commit
5f9db690a4
24 files changed
+1447
-597
No files matched your search
+439
-159
@@ -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: , , 
|
||||
$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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user