'body required'], 400); $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $storedTitle = clientEncrypt($title, $interKey); $storedBody = clientEncrypt($body, $interKey); $relayUid = $input['relay_uid'] ?? bin2hex(random_bytes(16)); $db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)') ->execute([ 'r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 't' => $storedTitle, 'b' => $storedBody, 'c' => $input['color'] ?? '#38bdf8', 'tg' => $input['tags'] ?? '', 'uid' => $relayUid, ]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName, 'relay_uid' => $relayUid]); } // ── UI attachment upload: session stores files encrypted at rest ── if ($route === '/api/attachments/upload' && $method === 'POST' && $sessionTenant) { if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { jsonResponse(['error' => 'no file'], 400); } $relayUid = $_POST['relay_uid'] ?? ''; $recipientName = trim($_POST['recipient'] ?? $sessionTenant['username']); if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400); $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); // Generate hex filename so hyprcrd's poll handler accepts it $origName = basename($_FILES['file']['name']); $ext = pathinfo($origName, PATHINFO_EXTENSION) ?: 'bin'; $hexName = bin2hex(random_bytes(16)) . '.' . strtolower($ext); $plainBytes = file_get_contents($_FILES['file']['tmp_name']); // Store as base64(raw) encrypted — matches client API format. // The GET endpoint re-encrypts for recipient; hyprcrd decrypts // and base64_decodes to get the original file bytes. $storedData = clientEncrypt(base64_encode($plainBytes), $interKey); $db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn') ->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName]); $db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)') ->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $hexName, 'data' => $storedData]); jsonResponse(['ok' => true, 'filename' => $hexName, 'original' => $origName]); } // ── UI inbox view: session reads decrypted messages ── // // The relay already sees plaintext during the routing hop (decrypt with // sender secret → re-encrypt with inter-tenant key for at-rest storage), // so reading them back to the session user is no extra leakage. Lets the // user check their inbox from the relay web UI on a phone without // needing the full hyprcrd client. Does NOT mark messages as fetched — // the canonical receiver is still the hyprcrd client. if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) { $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200'); $stmt->execute(['r' => $sessionTenant['id']]); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $out[] = [ 'id' => (int)$row['id'], 'sender' => $row['sender_username'] ?? '?', 'title' => clientDecrypt($row['title'] ?? '', $interKey), 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'fetched' => (int)$row['fetched'], ]; } jsonResponse(['messages' => $out]); } // ── UI sent view: messages this user sent to others ── if ($route === '/api/messages/sent' && $method === 'GET' && $sessionTenant) { $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $stmt = $db->prepare('SELECT m.id, m.title, m.body, m.created_at, m.fetched, t.username AS recipient FROM messages m JOIN tenants t ON t.id = m.recipient_id WHERE m.sender_id = :s AND m.recipient_id != :s2 ORDER BY m.created_at DESC LIMIT 200'); $stmt->execute(['s' => $sessionTenant['id'], 's2' => $sessionTenant['id']]); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $out[] = [ 'id' => (int)$row['id'], 'recipient' => $row['recipient'] ?? '?', 'title' => clientDecrypt($row['title'] ?? '', $interKey), 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'fetched' => (int)$row['fetched'], ]; } jsonResponse(['messages' => $out]); } // ── Client API auth (X-Relay-User + X-Relay-Key) ── $clientTenant = authClient($db); if ($route === '/api/messages' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $encryptedTitle = (string)($input['title'] ?? ''); $encryptedBody = (string)($input['body'] ?? ''); $recipientName = trim((string)($input['recipient'] ?? $clientTenant['username'])); if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400); $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); // Decrypt with sender's secret if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400); } $senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500); $plainTitle = clientDecrypt($encryptedTitle, $senderSecret); $plainBody = clientDecrypt($encryptedBody, $senderSecret); // Re-encrypt with the inter-tenant (super-shared) key for at-rest storage $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500); $storedTitle = clientEncrypt($plainTitle, $interKey); $storedBody = clientEncrypt($plainBody, $interKey); $db->prepare('INSERT INTO messages (recipient_id, sender_id, sender_username, title, body, color, tags, relay_uid) VALUES (:r, :s, :sn, :t, :b, :c, :tg, :uid)') ->execute([ 'r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 't' => $storedTitle, 'b' => $storedBody, 'c' => $input['color'] ?? '#38bdf8', 'tg' => $input['tags'] ?? '', 'uid' => $input['relay_uid'] ?? null, ]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]); } if ($route === '/api/messages' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); } $since = $_GET['since'] ?? null; if ($since) { $stmt = $db->prepare("SELECT * FROM messages WHERE recipient_id = :r AND created_at > :s ORDER BY created_at ASC"); $stmt->execute(['r' => $clientTenant['id'], 's' => $since]); } else { $stmt = $db->prepare("SELECT * FROM messages WHERE recipient_id = :r ORDER BY created_at ASC"); $stmt->execute(['r' => $clientTenant['id']]); } $msgs = $stmt->fetchAll(); // Decrypt with inter-tenant key, re-encrypt with recipient's secret $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $out = []; foreach ($msgs as $msg) { $plainTitle = clientDecrypt($msg['title'] ?? '', $interKey); $plainBody = clientDecrypt($msg['body'] ?? '', $interKey); $msg['title'] = clientEncrypt($plainTitle, $recipientSecret); $msg['body'] = clientEncrypt($plainBody, $recipientSecret); $out[] = $msg; } $db->prepare("UPDATE messages SET fetched = 1 WHERE recipient_id = :r AND fetched = 0") ->execute(['r' => $clientTenant['id']]); jsonResponse($out); } if (preg_match('#^/api/messages/(\d+)$#', $route, $m) && $method === 'DELETE') { $tenant = $clientTenant ?: $sessionTenant; if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401); $id = (int)$m[1]; $db->prepare('DELETE FROM messages WHERE id = :id AND (recipient_id = :r OR sender_id = :s)') ->execute(['id' => $id, 'r' => $tenant['id'], 's' => $tenant['id']]); jsonResponse(['ok' => true]); } if ($route === '/api/attachments' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $relayUid = (string)($input['relay_uid'] ?? ''); $filename = (string)($input['filename'] ?? ''); $data = (string)($input['data'] ?? ''); $recipientName = trim((string)($input['recipient'] ?? $clientTenant['username'])); if (!$relayUid || !$filename || !$data) jsonResponse(['error' => 'relay_uid, filename, and data required'], 400); // Enforce the hyprcrd upload naming scheme server-side so traversal-shaped // names never enter storage or get forwarded back to other clients. if (!preg_match('/^[a-f0-9]{8,64}\.[a-z0-9]{1,8}$/', $filename)) jsonResponse(['error' => 'invalid filename'], 400); $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); // Decrypt-reencrypt hop, same shape as messages: arrives encrypted with // sender's secret, gets stored at-rest under the inter-tenant key. if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 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 — admin must complete setup'], 500); $plainBytes = clientDecrypt($data, $senderSecret); if ($plainBytes === '') jsonResponse(['error' => 'attachment decrypt failed (sender secret mismatch?)'], 400); $storedData = clientEncrypt($plainBytes, $interKey); $db->prepare('DELETE FROM attachments WHERE recipient_id = :r AND relay_uid = :uid AND filename = :fn') ->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $filename]); $db->prepare('INSERT INTO attachments (recipient_id, relay_uid, filename, data) VALUES (:r, :uid, :fn, :data)') ->execute(['r' => $recipient['id'], 'uid' => $relayUid, 'fn' => $filename, 'data' => $storedData]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/attachments' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); } $relayUid = $_GET['relay_uid'] ?? ''; if (!$relayUid) jsonResponse(['error' => 'relay_uid required'], 400); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $stmt = $db->prepare('SELECT id, relay_uid, filename, data, created_at FROM attachments WHERE recipient_id = :r AND relay_uid = :uid'); $stmt->execute(['r' => $clientTenant['id'], 'uid' => $relayUid]); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $plainBytes = clientDecrypt($row['data'] ?? '', $interKey); $row['data'] = clientEncrypt($plainBytes, $recipientSecret); $out[] = $row; } jsonResponse($out); }