haufen sachen

This commit is contained in:
th committed 2026-04-08 22:46:48 +02:00
1 parent 416ca9e854
commit e6243701a3
20 files changed
+1562 -116

No files matched your search

+3 -3
View File
@@ -403,7 +403,7 @@ class CalDavSync
$this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events");
// 3. Fetch local appointment cards — only #termin tagged or already synced
$stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR caldav_uid IS NOT NULL");
$stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR (caldav_uid IS NOT NULL AND caldav_uid NOT LIKE 'note:%')");
$localCards = $stmt->fetchAll();
$localMap = []; // caldav_uid -> card
$localNew = []; // cards without caldav_uid
@@ -415,9 +415,9 @@ class CalDavSync
}
}
// 4. Tombstones
// 4. Tombstones (only CalDAV, not notes)
$tombstones = [];
foreach ($this->db->query('SELECT * FROM caldav_tombstones')->fetchAll() as $t) {
foreach ($this->db->query("SELECT * FROM caldav_tombstones WHERE caldav_uid NOT LIKE 'note:%'")->fetchAll() as $t) {
$tombstones[$t['caldav_uid']] = $t;
}
+4
View File
@@ -83,6 +83,10 @@ class Database
}
}
if (!in_array('due_date', $colNames)) {
self::$pdo->exec('ALTER TABLE cards ADD COLUMN due_date TEXT DEFAULT NULL');
}
// CalDAV columns
if (!in_array('caldav_uid', $colNames)) {
self::$pdo->exec('ALTER TABLE cards ADD COLUMN caldav_uid TEXT DEFAULT NULL');
+369
View File
@@ -0,0 +1,369 @@
<?php
require_once __DIR__ . '/CalDavClient.php';
class WebDavNotes
{
private PDO $db;
private ?CalDavClient $client = null;
private array $log = [];
private array $stats = ['pushed' => 0, 'pulled' => 0, 'deleted' => 0];
public function __construct(PDO $db)
{
$this->db = $db;
}
private function getEncKey(): string
{
$hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
private function decrypt(string $encrypted): string
{
$key = $this->getEncKey();
$raw = base64_decode($encrypted);
$iv = substr($raw, 0, 16);
$cipher = base64_encode(substr($raw, 16));
return openssl_decrypt($cipher, 'aes-256-cbc', $key, 0, $iv) ?: '';
}
private function encryptContent(string $plain): string
{
$key = $this->getEncKey();
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-ENCRYPTED-V1\n" . base64_encode($iv . $cipher);
}
private function decryptContent(string $data): ?string
{
$lines = explode("\n", $data, 2);
if ($lines[0] !== 'HYPRCRD-ENCRYPTED-V1' || empty($lines[1])) return null;
$key = $this->getEncKey();
$raw = base64_decode(trim($lines[1]));
if (strlen($raw) < 17) return null;
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return $plain !== false ? $plain : null;
}
private function isEncrypted(string $data): bool
{
return str_starts_with($data, 'HYPRCRD-ENCRYPTED-V1');
}
private function getClient(): CalDavClient
{
if (!$this->client) {
// Derive WebDAV files URL from CalDAV URL
$caldavUrl = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_url'")->fetchColumn();
$user = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_username'")->fetchColumn();
$pass = $this->db->query("SELECT value FROM settings WHERE key = 'caldav_password'")->fetchColumn();
if (!$caldavUrl || !$pass) throw new \Exception('No CalDAV credentials configured');
$url = $this->decrypt($caldavUrl);
// Convert /calendars/user/... to /files/user/Notes/
if (preg_match('#(https?://[^/]+)/remote\.php/dav/calendars/([^/]+)#', $url, $m)) {
$notesUrl = $m[1] . '/remote.php/dav/files/' . $m[2] . '/Notes/';
} else {
// Fallback: replace calendars path
$notesUrl = preg_replace('#/calendars/[^/]+/.*$#', '/files/' . urlencode($this->decrypt($user)) . '/Notes/', $url);
}
$this->client = new CalDavClient($notesUrl, $this->decrypt($user), $this->decrypt($pass));
}
return $this->client;
}
private function addLog(string $dir, ?int $cardId, string $action, string $status, string $detail = ''): void
{
$this->log[] = compact('dir', 'cardId', 'action', 'status', 'detail');
}
private function safeFilename(string $title): string
{
$name = preg_replace('/[<>:"\/\\\\|?*]/', '_', $title);
return trim($name) ?: 'untitled';
}
public function listRemoteNotes(): array
{
$client = $this->getClient();
$body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getlastmodified/><d:getetag/><d:getcontentlength/></d:prop></d:propfind>';
$res = $client->request('PROPFIND', $client->getBaseUrl(), $body, ['Depth: 1']);
if ($res['status'] === 404) {
// Notes folder doesn't exist, create it
$client->request('MKCOL', $client->getBaseUrl());
return [];
}
if ($res['status'] !== 207) return [];
$notes = [];
try {
$xml = new SimpleXMLElement($res['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$baseHref = rtrim(parse_url($client->getBaseUrl(), PHP_URL_PATH), '/');
foreach ($xml->xpath('//d:response') as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$href = (string)($resp->xpath('.//d:href')[0] ?? '');
if (rtrim($href, '/') === $baseHref) continue;
if (!preg_match('/\.md$/i', $href)) continue;
$etag = (string)($resp->xpath('.//d:getetag')[0] ?? '');
$lastmod = (string)($resp->xpath('.//d:getlastmodified')[0] ?? '');
$filename = urldecode(basename($href));
$notes[] = ['href' => $href, 'filename' => $filename, 'etag' => $etag, 'lastmod' => $lastmod];
}
} catch (\Exception $e) {
$this->addLog('sync', null, 'list', 'error', $e->getMessage());
}
return $notes;
}
public function sync(): array
{
try {
$client = $this->getClient();
} catch (\Exception $e) {
return ['ok' => false, 'error' => $e->getMessage(), 'log' => $this->log, 'stats' => $this->stats];
}
$baseUrl = $client->getBaseUrl();
$this->addLog('sync', null, 'init', 'ok', "notes: {$baseUrl}");
// Fetch remote notes
$remoteNotes = $this->listRemoteNotes();
$this->addLog('sync', null, 'fetch', 'ok', count($remoteNotes) . ' remote notes');
$remoteMap = []; // filename -> note data
foreach ($remoteNotes as $n) {
$remoteMap[$n['filename']] = $n;
}
// Fetch local #notes cards (including previously synced)
$localCards = $this->db->query("SELECT * FROM cards WHERE tags = 'notes' OR tags LIKE 'notes,%' OR tags LIKE '%,notes,%' OR tags LIKE '%,notes' OR caldav_uid LIKE 'note:%'")->fetchAll();
$this->addLog('sync', null, 'fetch', 'ok', count($localCards) . ' local #notes cards');
// Tombstones for deleted notes
$tombstones = [];
foreach ($this->db->query("SELECT * FROM caldav_tombstones WHERE caldav_uid LIKE 'note:%'")->fetchAll() as $t) {
$tombstones[str_replace('note:', '', $t['caldav_uid'])] = $t;
}
$localByFile = []; // filename -> card
$localNew = [];
foreach ($localCards as $c) {
if ($c['caldav_href']) {
$localByFile[basename($c['caldav_href'])] = $c;
} elseif ($c['caldav_uid'] && str_starts_with($c['caldav_uid'], 'note:')) {
$storedFile = str_replace('note:', '', $c['caldav_uid']);
$localByFile[$storedFile] = $c;
} else {
$localNew[] = $c;
}
}
// A: Existing synced cards
foreach ($localByFile as $filename => $card) {
if (isset($remoteMap[$filename])) {
$remote = $remoteMap[$filename];
$lt = strtotime($card['updated_at'] ?? '2000-01-01');
$st = strtotime($card['caldav_synced_at'] ?? '2000-01-01');
$localChanged = !$card['caldav_synced_at'] || ($lt - $st) > 2;
$remoteChanged = !$card['caldav_etag'] || $card['caldav_etag'] !== $remote['etag'];
if ($remoteChanged && !$localChanged) {
// Pull
$this->pullNote($client, $remote, $card);
} elseif ($localChanged && !$remoteChanged) {
// Push
$this->pushNote($client, $card, $filename);
} elseif ($localChanged && $remoteChanged) {
// Conflict: last modified wins
$localTime = strtotime($card['updated_at']);
$remoteTime = strtotime($remote['lastmod'] ?? '2000-01-01');
if ($localTime >= $remoteTime) {
$this->pushNote($client, $card, $filename);
} else {
$this->pullNote($client, $remote, $card);
}
}
unset($remoteMap[$filename]);
} else {
// Remote deleted — always respect server deletion for notes
$this->db->prepare('DELETE FROM cards WHERE id = ?')->execute([$card['id']]);
$this->addLog('pull', $card['id'], 'delete', 'ok', "remote deleted: {$filename}");
$this->stats['deleted']++;
}
}
// B: New local cards → push (opaque encrypted filename)
foreach ($localNew as $card) {
$filename = $this->encFilename($card['id']);
$this->pushNote($client, $card, $filename);
}
// C: New remote notes → pull (skip tombstoned)
foreach ($remoteMap as $filename => $remote) {
if (isset($tombstones[$filename])) {
// Locally deleted → delete from server
$parsed = parse_url($client->getBaseUrl());
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$client->request('DELETE', $base . $remote['href']);
$this->db->prepare("DELETE FROM caldav_tombstones WHERE caldav_uid = ?")->execute(['note:' . $filename]);
$this->addLog('push', null, 'delete', 'ok', "deleted remote: {$filename}");
$this->stats['deleted']++;
continue;
}
$this->pullNote($client, $remote, null);
}
// Clean up remaining tombstones (already gone from server)
foreach ($tombstones as $filename => $t) {
if (!isset($remoteMap[$filename])) {
$this->db->prepare("DELETE FROM caldav_tombstones WHERE caldav_uid = ?")->execute(['note:' . $filename]);
}
}
return ['ok' => true, 'log' => $this->log, 'stats' => $this->stats];
}
private function encFilename(int $cardId): string
{
// Deterministic opaque filename from card ID + encryption key
return 'enc-' . substr(hash('sha256', $this->getEncKey() . ':' . $cardId), 0, 12) . '.md';
}
private function pushNote(CalDavClient $client, array $card, string $filename): void
{
// Include metadata for cross-client sync
$meta = '';
if (!empty($card['bucket_name'])) {
$meta = "<!--bucket:{$card['bucket_name']}-->\n";
} elseif (!empty($card['bucket_id'])) {
$bname = $this->db->prepare('SELECT name FROM buckets WHERE id = ?');
$bname->execute([$card['bucket_id']]);
$bn = $bname->fetchColumn();
if ($bn) $meta = "<!--bucket:{$bn}-->\n";
}
if (!empty($card['color']) && $card['color'] !== '#38bdf8') {
$meta .= "<!--color:{$card['color']}-->\n";
}
$plain = "{$meta}# {$card['title']}\n\n{$card['body']}";
$content = $this->encryptContent($plain);
// Migrate old plaintext filename to encrypted opaque filename
$oldHref = $card['caldav_href'] ? basename($card['caldav_href']) : null;
if ($oldHref && !str_starts_with($oldHref, 'enc-')) {
// Delete old plaintext file
$oldUrl = $client->getBaseUrl() . rawurlencode($oldHref);
$client->request('DELETE', $oldUrl);
$filename = $this->encFilename($card['id']);
} else {
$filename = $oldHref ?: $this->encFilename($card['id']);
}
$url = $client->getBaseUrl() . rawurlencode($filename);
$res = $client->request('PUT', $url, $content, ['Content-Type: text/markdown; charset=utf-8']);
if ($res['status'] >= 200 && $res['status'] < 300) {
$etag = $res['etag'];
// If no ETag in PUT response, fetch it
if (!$etag) {
$prop = $client->request('PROPFIND', $url, '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>', ['Depth: 0']);
if ($prop['status'] === 207) {
try {
$xml = new \SimpleXMLElement($prop['body']);
$xml->registerXPathNamespace('d', 'DAV:');
$found = $xml->xpath('//d:getetag');
if ($found) $etag = (string)$found[0];
} catch (\Exception $e) {}
}
}
$this->db->prepare('UPDATE cards SET caldav_uid=?, caldav_etag=?, caldav_href=?, caldav_synced_at=datetime("now") WHERE id=?')
->execute(['note:' . $filename, $etag, $filename, $card['id']]);
$this->addLog('push', $card['id'], 'push', 'ok', $filename);
$this->stats['pushed']++;
} else {
$this->addLog('push', $card['id'], 'push', 'error', "HTTP {$res['status']} {$filename}");
}
}
private function pullNote(CalDavClient $client, array $remote, ?array $card): void
{
$parsed = parse_url($client->getBaseUrl());
$base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
$url = $base . $remote['href'];
$res = $client->request('GET', $url);
if ($res['status'] !== 200) {
$this->addLog('pull', $card['id'] ?? null, 'pull', 'error', "HTTP {$res['status']} {$remote['filename']}");
return;
}
$raw = $res['body'];
// Decrypt if encrypted, fall back to plain text
if ($this->isEncrypted($raw)) {
$content = $this->decryptContent($raw);
if ($content === null) {
// Decryption failed — might be from another instance with different password
// Try treating as plain text (skip the header line)
$this->addLog('pull', $card['id'] ?? null, 'pull', 'ok', "decrypt failed, importing as plain: {$remote['filename']}");
$content = $raw;
}
} else {
$content = $raw;
}
// Extract metadata comments
$bucketName = null;
$noteColor = null;
if (preg_match('/<!--bucket:(.+?)-->/', $content, $bm)) { $bucketName = $bm[1]; $content = str_replace($bm[0] . "\n", '', $content); }
if (preg_match('/<!--color:(.+?)-->/', $content, $cm)) { $noteColor = $cm[1]; $content = str_replace($cm[0] . "\n", '', $content); }
// Resolve or create bucket
$bucketId = $card['bucket_id'] ?? null;
if ($bucketName) {
$stmt = $this->db->prepare('SELECT id FROM buckets WHERE name = ?');
$stmt->execute([$bucketName]);
$existingBucket = $stmt->fetchColumn();
if ($existingBucket) {
$bucketId = $existingBucket;
} else {
$this->db->prepare('INSERT INTO buckets (name, position) VALUES (?, (SELECT COALESCE(MAX(position),0)+1 FROM buckets))')->execute([$bucketName]);
$bucketId = $this->db->lastInsertId();
$this->addLog('pull', null, 'bucket', 'ok', "created bucket: {$bucketName}");
}
}
// Parse title from first # heading or filename
$title = preg_replace('/\.md$/i', '', $remote['filename']);
$body = $content;
if (preg_match('/^#\s+(.+)\n\n?([\s\S]*)$/m', $content, $m)) {
$title = trim($m[1]);
$body = trim($m[2]);
}
// Ensure #notes tag
if (strpos($body, '#notes') === false) {
$body = rtrim($body) . "\n\n#notes";
}
$tags = $this->extractTags($body);
$color = $noteColor ?: ($card['color'] ?? '#38bdf8');
if ($card) {
$this->db->prepare('UPDATE cards SET title=?, body=?, tags=?, color=?, bucket_id=?, caldav_etag=?, caldav_synced_at=datetime("now"), updated_at=datetime("now") WHERE id=?')
->execute([$title, $body, $tags, $color, $bucketId, $remote['etag'], $card['id']]);
$this->addLog('pull', $card['id'], 'update', 'ok', $remote['filename']);
} else {
$this->db->prepare('INSERT INTO cards (title, body, color, tags, bucket_id, position, caldav_uid, caldav_etag, caldav_href, caldav_synced_at) VALUES (?, ?, ?, ?, ?, -1, ?, ?, ?, datetime("now"))')
->execute([$title, $body, $color, $tags, $bucketId, 'note:' . $remote['filename'], $remote['etag'], $remote['filename']]);
$this->addLog('pull', (int)$this->db->lastInsertId(), 'create', 'ok', $remote['filename'] . ($bucketName ? " → {$bucketName}" : ''));
}
$this->stats['pulled']++;
}
private function extractTags(string $body): string
{
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] ?? [])));
}
}
+7 -1
View File
@@ -1,6 +1,12 @@
<?php
require_once __DIR__ . '/../config.php';
// Invalidate opcache for config on each request (tiny overhead, ensures version updates)
$configPath = __DIR__ . '/../config.php';
if (function_exists('opcache_invalidate')) {
opcache_invalidate($configPath, true);
}
require_once $configPath;
require_once __DIR__ . '/Database.php';
// Ensure data directory is writable