diff --git a/config.php b/config.php index 99029b7..4af454d 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); + $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); + $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn(); + if (!$relayUrl || !$relayKey || !$relayUser) return; + + $relayUid = $card['relay_uid'] ?? null; + if (!$relayUid) { + $relayUid = bin2hex(random_bytes(16)); + $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $card['id']]); + } + + $body = (string)($card['body'] ?? ''); + // Hero = first uploads/. reference. All inline images + // get pushed as attachments so the reader can resolve them. + preg_match_all('#uploads/([a-f0-9]+\.[a-z0-9]+)#i', $body, $matches); + $imageFilenames = array_unique(array_map('strtolower', $matches[1] ?? [])); + $heroFilename = $imageFilenames[0] ?? ''; + + $payload = json_encode([ + 'relay_uid' => $relayUid, + 'title' => hyprcrdRelayEncrypt((string)($card['title'] ?? ''), $relayKey), + 'body' => hyprcrdRelayEncrypt($body, $relayKey), + 'hero_filename' => $heroFilename, + ]); + $ch = curl_init(rtrim($relayUrl, '/') . '/api/read'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 10, + ]); + hyprcrdApplySafeCurlOpts($ch); + $resp = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($status < 200 || $status >= 300) { + @error_log('[hyprcrd] read push failed: HTTP ' . $status . ' ' . substr((string)$resp, 0, 200)); + return; // don't bother uploading images if the item didn't land + } + + // Upload referenced images as read-attachments (encrypted blob, + // same envelope format as collab-attachments). + $uploadDir = UPLOAD_DIR; + foreach ($imageFilenames as $fn) { + $filePath = $uploadDir . '/' . $fn; + if (!is_file($filePath)) continue; + $bytes = file_get_contents($filePath); + if ($bytes === false) continue; + $attPayload = json_encode([ + 'relay_uid' => $relayUid, + 'filename' => $fn, + 'data' => hyprcrdRelayEncrypt(base64_encode($bytes), $relayKey), + ]); + $ach = curl_init(rtrim($relayUrl, '/') . '/api/read-attachments'); + curl_setopt_array($ach, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $attPayload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 30, + ]); + hyprcrdApplySafeCurlOpts($ach); + curl_exec($ach); + curl_close($ach); + } + } + + function deleteCardFromRelayRead(string $relayUid, PDO $db): void { + if ($relayUid === '') return; + $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); + $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); + $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn(); + if (!$relayUrl || !$relayKey || !$relayUser) return; + $ch = curl_init(rtrim($relayUrl, '/') . '/api/read/' . urlencode($relayUid)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], + CURLOPT_TIMEOUT => 10, + ]); + hyprcrdApplySafeCurlOpts($ch); + curl_exec($ch); + curl_close($ch); + } + // Plugin route handlers (registered by plugin backend.php files). // Each handler is responsible for calling exit() on a match. PluginManager::instance()->dispatchRoute($route, $method); @@ -294,7 +385,8 @@ if (str_starts_with($route, '/api/')) { if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; } $pos = $input['position'] ?? 0; $body = $input['body'] ?? ''; - $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad)')->execute([ + $readPush = !empty($input['read_push']) ? 1 : 0; + $db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, due_date, scratchpad, read_push) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position, :tags, :due_date, :scratchpad, :read_push)')->execute([ 'title' => $title, 'body' => $body, 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'parent_id' => validInt($input['parent_id'] ?? null, null), @@ -302,8 +394,16 @@ if (str_starts_with($route, '/api/')) { 'tags' => extractTags($body), 'due_date' => $input['due_date'] ?? null, 'scratchpad' => $input['scratchpad'] ?? null, + 'read_push' => $readPush, ]); - echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch()); + $saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch(); + if ($readPush && $saved) { + pushCardToRelayRead($saved, $db); + // relay_uid may have been generated inside the push helper; + // re-read so the client sees it on the response. + $saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . (int)$saved['id'])->fetch(); + } + echo json_encode($saved); exit; } @@ -323,17 +423,21 @@ 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, 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()); + 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, c.read_push, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id ORDER BY c.position ASC, c.created_at DESC')->fetchAll()); exit; } if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'DELETE') { - // CalDAV tombstone for synced cards - $card = $db->prepare('SELECT caldav_uid FROM cards WHERE id = :id'); + // CalDAV tombstone for synced cards, plus a read-list wipe so the + // mobile reader stops showing cards that no longer exist locally. + $card = $db->prepare('SELECT caldav_uid, relay_uid, read_push FROM cards WHERE id = :id'); $card->execute(['id' => $m[1]]); - $uid = $card->fetchColumn(); - if ($uid) { - $db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$uid, $m[1]]); + $cardRow = $card->fetch(); + if ($cardRow && !empty($cardRow['caldav_uid'])) { + $db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]); + } + if ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid'])) { + deleteCardFromRelayRead((string)$cardRow['relay_uid'], $db); } $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]); echo json_encode(['ok' => true]); exit; @@ -367,10 +471,41 @@ if (str_starts_with($route, '/api/')) { foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } } + if (array_key_exists('read_push', $input)) { + $fields[] = 'read_push = :read_push'; + $params['read_push'] = !empty($input['read_push']) ? 1 : 0; + } if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } + // Snapshot the previous read_push / relay_uid before the write so we + // can detect a toggle-off and remove the remote copy afterwards. + // The history-tracking block above only loads $old when a tracked + // field changed; we need it unconditionally when read_push is in + // play, so backfill here if missing. + $prevRead = null; $prevRelayUid = null; + if (array_key_exists('read_push', $input) && !isset($old)) { + $snap = $db->prepare('SELECT read_push, relay_uid FROM cards WHERE id = :id'); + $snap->execute(['id' => $m[1]]); + $old = $snap->fetch() ?: null; + } + if (isset($old) && $old) { $prevRead = (int)($old['read_push'] ?? 0); $prevRelayUid = $old['relay_uid'] ?? null; } if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); } $card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id'); - $card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit; + $card->execute(['id' => $m[1]]); $savedCard = $card->fetch(); + // Relay sync: + // - if card currently wants to be in the read list, push (idempotent + // upsert keeps the remote copy in sync on every edit). + // - if the user just toggled it off, clean up remotely. + if ($savedCard) { + $nowRead = (int)($savedCard['read_push'] ?? 0); + if ($nowRead === 1) { + pushCardToRelayRead($savedCard, $db); + // Re-read to surface any newly-generated relay_uid on the wire. + $card->execute(['id' => $m[1]]); $savedCard = $card->fetch(); + } elseif ($prevRead === 1 && $prevRelayUid) { + deleteCardFromRelayRead($prevRelayUid, $db); + } + } + echo json_encode($savedCard); exit; } // Plugins @@ -2224,6 +2359,9 @@ if (str_starts_with($route, '/api/')) { +
+ @@ -234,6 +235,26 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } + + +