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>
183 lines
9.0 KiB
PHP
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;
|
|
}
|