Files
hyprcrd/relay/inc/routes/read.php
T
thandClaude Opus 4.7 1e51bdec7c v2.0.5 — Read feature (mobile zen reader on the relay)
Per-card opt-in push to the relay for distraction-free mobile reading.
Not tag-based: a real cards.read_push column so the reading state
stays independent of whatever tags the user organises by.

hyprcrd
- cards.read_push column (0/1, default 0) via Database migration.
- POST /api/cards + PATCH /api/cards/{id} accept read_push; save hook
  posts the card (title+body, encrypted) and referenced images
  to the relay's /api/read endpoint. First inline image is flagged
  as the hero. Toggle-off / DELETE wipes the remote copy.
- Detail-bar toggle button ("Read" → "On relay") with filled book
  icon + inverted accent background when active; outline book icon
  in the bottom meta row of the board card when read_push=1.

relay
- read_items + read_attachments tables, encrypted under the
  inter-tenant key like messages (dual-format envelope).
- Client API: POST /api/read, DELETE /api/read/{uid}, POST
  /api/read-attachments. Session API: GET /api/read (list),
  GET /api/read/{uid} (single decrypted), GET /api/read-attachments/
  {uid}/{filename} (serves decrypted image bytes).
- Payload size caps raised to 15MB (attachments) / 2MB (items).
- New nav tab + view: list with thumbnails, click opens a zen reader
  overlay. Reader caps article width at 680px, bumps type to 17px/1.7,
  renders a minimal markdown subset (headings, lists, code fences,
  blockquotes, links, inline images), rewrites uploads/ references
  to the session-authed image endpoint. Escape closes the reader.
- Mobile-first: wider touch targets, tighter padding at ≤640px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:10:04 +02:00

183 lines
9.0 KiB
PHP

<?php
// Read-later routes:
// POST /api/read (client API: upsert an item)
// DELETE /api/read/{uid} (client or session: remove)
// GET /api/read (session: list own items)
// GET /api/read/{uid} (session: one item, decrypted)
// POST /api/read-attachments (client API: upload image blob)
// GET /api/read-attachments/{uid}/{fn} (session: serve decrypted bytes)
//
// Encryption mirrors messages / collab-cards: client encrypts with their
// own secret, relay decrypts with senderSecret, re-encrypts under the
// inter-tenant key for at-rest storage, decrypts again for the session
// UI. Hard delete — no tombstones (no offline-sync requirement, the
// hyprcrd client is the source of truth).
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
// Session tenant is set by routes/public.php earlier in the chain;
// client-API auth is lazy so we don't hit authClient() when the session
// alone suffices.
$clientTenant = $clientTenant ?? authClient($db);
// ── Client API: upsert an item ────────────────────────────────────
if ($route === '/api/read' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (empty($clientTenant['secret_encrypted'])) {
jsonResponse(['error' => 'sender has no client secret set'], 400);
}
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$relayUid = trim((string)($input['relay_uid'] ?? ''));
$encTitle = (string)($input['title'] ?? '');
$encBody = (string)($input['body'] ?? '');
$heroFilename = trim((string)($input['hero_filename'] ?? ''));
if ($relayUid === '' || $encBody === '') {
jsonResponse(['error' => 'relay_uid and body required'], 400);
}
// Hero filename follows the hyprcrd upload naming scheme; empty is
// valid (no inline images).
if ($heroFilename !== '' && !preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $heroFilename)) {
jsonResponse(['error' => 'invalid hero_filename'], 400);
}
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$plainTitle = clientDecrypt($encTitle, $senderSecret);
$plainBody = clientDecrypt($encBody, $senderSecret);
if ($plainBody === '') jsonResponse(['error' => 'body decrypt failed'], 400);
$storedTitle = clientEncrypt($plainTitle, $interKey);
$storedBody = clientEncrypt($plainBody, $interKey);
$db->prepare("
INSERT INTO read_items (tenant_id, relay_uid, title, body, hero_filename, updated_at)
VALUES (:t, :u, :ti, :bo, :hf, datetime('now'))
ON CONFLICT(tenant_id, relay_uid) DO UPDATE SET
title = excluded.title,
body = excluded.body,
hero_filename = excluded.hero_filename,
updated_at = excluded.updated_at
")->execute([
't' => $clientTenant['id'],
'u' => $relayUid,
'ti' => $storedTitle,
'bo' => $storedBody,
'hf' => $heroFilename !== '' ? $heroFilename : null,
]);
jsonResponse(['ok' => true, 'relay_uid' => $relayUid]);
}
// ── DELETE /api/read/{uid} ────────────────────────────────────────
if (preg_match('#^/api/read/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
$tenant = $clientTenant ?: $sessionTenant;
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
$uid = $m[1];
$db->prepare('DELETE FROM read_attachments WHERE tenant_id = :t AND relay_uid = :u')
->execute(['t' => $tenant['id'], 'u' => $uid]);
$db->prepare('DELETE FROM read_items WHERE tenant_id = :t AND relay_uid = :u')
->execute(['t' => $tenant['id'], 'u' => $uid]);
jsonResponse(['ok' => true]);
}
// ── Session: list own items (for the relay UI) ────────────────────
if ($route === '/api/read' && $method === 'GET' && $sessionTenant) {
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stmt = $db->prepare('
SELECT relay_uid, title, hero_filename, created_at, updated_at, length(body) AS body_len
FROM read_items WHERE tenant_id = :t
ORDER BY updated_at DESC
LIMIT 200
');
$stmt->execute(['t' => $sessionTenant['id']]);
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[] = [
'relay_uid' => $row['relay_uid'],
'title' => clientDecrypt($row['title'] ?? '', $interKey),
'hero_filename' => $row['hero_filename'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
];
}
jsonResponse(['items' => $out]);
}
// ── Session: one item with body ───────────────────────────────────
if (preg_match('#^/api/read/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'GET' && $sessionTenant) {
$uid = $m[1];
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stmt = $db->prepare('SELECT relay_uid, title, body, hero_filename, created_at, updated_at FROM read_items WHERE tenant_id = :t AND relay_uid = :u');
$stmt->execute(['t' => $sessionTenant['id'], 'u' => $uid]);
$row = $stmt->fetch();
if (!$row) jsonResponse(['error' => 'not found'], 404);
jsonResponse([
'relay_uid' => $row['relay_uid'],
'title' => clientDecrypt($row['title'] ?? '', $interKey),
'body' => clientDecrypt($row['body'] ?? '', $interKey),
'hero_filename' => $row['hero_filename'],
'created_at' => $row['created_at'],
'updated_at' => $row['updated_at'],
]);
}
// ── Client API: upload inline image blob ──────────────────────────
if ($route === '/api/read-attachments' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
if (empty($clientTenant['secret_encrypted'])) {
jsonResponse(['error' => 'sender has no client secret set'], 400);
}
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$relayUid = (string)($input['relay_uid'] ?? '');
$filename = (string)($input['filename'] ?? '');
$data = (string)($input['data'] ?? '');
if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, data required'], 400);
if (!preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $filename)) jsonResponse(['error' => 'invalid filename'], 400);
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$plain = clientDecrypt($data, $senderSecret);
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
$stored = clientEncrypt($plain, $interKey);
$db->prepare('INSERT INTO read_attachments (tenant_id, relay_uid, filename, data) VALUES (:t, :u, :f, :d)
ON CONFLICT(tenant_id, relay_uid, filename) DO UPDATE SET data = excluded.data')
->execute(['t' => $clientTenant['id'], 'u' => $relayUid, 'f' => $filename, 'd' => $stored]);
jsonResponse(['ok' => true]);
}
// ── Session: serve decrypted image bytes ──────────────────────────
if (preg_match('#^/api/read-attachments/([a-zA-Z0-9_-]+)/([a-f0-9]+\.[a-z0-9]{1,8})$#', $route, $m) && $method === 'GET' && $sessionTenant) {
$uid = $m[1];
$filename = $m[2];
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) { http_response_code(500); exit; }
$stmt = $db->prepare('SELECT data FROM read_attachments WHERE tenant_id = :t AND relay_uid = :u AND filename = :f');
$stmt->execute(['t' => $sessionTenant['id'], 'u' => $uid, 'f' => $filename]);
$enc = $stmt->fetchColumn();
if (!$enc) { http_response_code(404); exit; }
$b64 = clientDecrypt($enc, $interKey);
$bytes = base64_decode($b64, true);
if ($bytes === false) { http_response_code(500); exit; }
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$mimes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'];
header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream'));
header('Cache-Control: private, max-age=3600');
echo $bytes;
exit;
}