haufen sachen
This commit is contained in:
1 parent
416ca9e854
commit
e6243701a3
20 files changed
+1562
-116
No files matched your search
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user