'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; }