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>
This commit is contained in:
1 parent
f986a41ccc
commit
1e51bdec7c
12 files changed
+840
-17
No files matched your search
@@ -0,0 +1,182 @@
|
||||
<?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;
|
||||
}
|
||||
Reference in new issue
Block a user