Skip to content

Commit 6b2ae65

Browse files
feat: E2E media encryption, file attachments, UX fixes
## E2E Media Encryption - Upload: client encrypts image binary (AES-256-CTR) before uploading to R2 - Display: MediaPreview fetches ciphertext, decrypts client-side, renders via object URL - Plugin: decrypts downloaded media with e2eKey before passing to OpenClaw vision - Context derivation: uses "{messageId}:media" — no extra DB column needed - Added encryptMedia/decryptMedia to E2eService (wraps encryptBytes/decryptBytes) ## File Attachment Support - Upload endpoint now accepts PDF, TXT, CSV, JSON, ZIP, audio, video (not just images) - Web file picker accepts expanded types; drag-drop accepts all files - Non-image files show filename preview instead of blank thumbnail - cacheExternalMedia in DO supports non-image media types ## UX Fixes - Session naming: fixed stale closure in handleCreate (sessions missing from useCallback deps) - Upload icon: larger paperclip icon (w-5) with better contrast (--text-secondary) - Mobile send button: no longer disabled by transient WS disconnects during file picker - E2E messageId: encrypt() preserves existing messageId instead of overwriting - Channel creation: INSERT OR IGNORE for duplicate session_key edge case
1 parent 6788ffa commit 6b2ae65

5 files changed

Lines changed: 150 additions & 31 deletions

File tree

packages/plugin/src/channel.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getBotsChatRuntime } from "./runtime.js";
99
import type { BotsChatChannelConfig, CloudInbound, ResolvedBotsChatAccount } from "./types.js";
1010
import { BotsChatCloudClient } from "./ws-client.js";
1111
import crypto from "crypto";
12-
import { encryptText, decryptText, toBase64, fromBase64 } from "./e2e-crypto.js";
12+
import { encryptText, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
1313

1414
// ---------------------------------------------------------------------------
1515
// A2UI message-tool hints — injected via agentPrompt.messageToolHints so
@@ -496,8 +496,21 @@ async function handleCloudMessage(
496496
ctx.log?.info(`[${ctx.accountId}] Downloading media from ${resolvedUrl}`);
497497
const resp = await fetch(resolvedUrl);
498498
if (resp.ok) {
499-
const buffer = Buffer.from(await resp.arrayBuffer());
499+
let buffer = Buffer.from(await resp.arrayBuffer());
500500
const contentType = resp.headers.get("content-type") || "image/png";
501+
502+
// E2E: decrypt media if the message was encrypted
503+
if ((msg as any).encrypted && client?.e2eKey && msg.messageId) {
504+
try {
505+
const mediaCtx = `${msg.messageId}:media`;
506+
const decrypted = await decryptBytes(client.e2eKey, new Uint8Array(buffer), mediaCtx);
507+
buffer = Buffer.from(decrypted);
508+
ctx.log?.info(`[${ctx.accountId}] E2E decrypted media (${buffer.length} bytes, ctx=${mediaCtx.slice(0, 12)}…)`);
509+
} catch (e2eErr) {
510+
ctx.log?.warn?.(`[${ctx.accountId}] E2E media decryption failed (using raw): ${e2eErr}`);
511+
}
512+
}
513+
501514
const extMap: Record<string, string> = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
502515
const ext = extMap[contentType] || ".png";
503516
const fileName = `botschat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;

packages/web/src/components/ChatWindow.tsx

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SessionTabs } from "./SessionTabs";
77
import { useIsMobile } from "../hooks/useIsMobile";
88
import { dlog } from "../debug-log";
99
import { randomUUID } from "../utils/uuid";
10+
import { E2eService } from "../e2e";
1011

1112
type ChatWindowProps = {
1213
sendMessage: (msg: WSMessage) => void;
@@ -282,11 +283,26 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
282283
}
283284
}, [pendingImage]);
284285

285-
const uploadImage = useCallback(async (file: File): Promise<string | null> => {
286-
const formData = new FormData();
287-
formData.append("file", file);
286+
/**
287+
* Upload a file — if E2E is enabled, encrypts the binary before uploading.
288+
* Returns { url, mediaContextId? } or null on failure.
289+
*/
290+
const uploadFile = useCallback(async (file: File, mediaContextId?: string): Promise<{ url: string } | null> => {
288291
const token = localStorage.getItem("botschat_token");
289292
try {
293+
let uploadBlob: Blob = file;
294+
295+
// E2E: encrypt file content before uploading
296+
if (E2eService.hasKey() && mediaContextId) {
297+
const arrayBuf = await file.arrayBuffer();
298+
const plainBytes = new Uint8Array(arrayBuf);
299+
const { encrypted } = await E2eService.encryptMedia(plainBytes, mediaContextId);
300+
uploadBlob = new Blob([encrypted.buffer.slice(0) as ArrayBuffer], { type: file.type });
301+
dlog.info("E2E", `Encrypted media (${plainBytes.length} bytes, ctx=${mediaContextId.slice(0, 8)}…)`);
302+
}
303+
304+
const formData = new FormData();
305+
formData.append("file", uploadBlob, file.name);
290306
const res = await fetch("/api/upload", {
291307
method: "POST",
292308
headers: token ? { Authorization: `Bearer ${token}` } : {},
@@ -297,13 +313,12 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
297313
throw new Error((err as { error?: string }).error ?? `HTTP ${res.status}`);
298314
}
299315
const data = await res.json() as { url: string };
300-
// Return absolute URL so OpenClaw on mini.local can fetch the image
301316
const absoluteUrl = data.url.startsWith("/")
302317
? `${window.location.origin}${data.url}`
303318
: data.url;
304-
return absoluteUrl;
319+
return { url: absoluteUrl };
305320
} catch (err) {
306-
dlog.error("Upload", `Image upload failed: ${err}`);
321+
dlog.error("Upload", `File upload failed: ${err}`);
307322
return null;
308323
}
309324
}, []);
@@ -366,6 +381,11 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
366381
const handleSend = async () => {
367382
if ((!input.trim() && !pendingImage) || !sessionKey) return;
368383

384+
// Warn if OpenClaw is offline (but don't block — connection may recover)
385+
if (!state.openclawConnected) {
386+
dlog.warn("Chat", "Sending while OpenClaw appears offline — message will be delivered when reconnected");
387+
}
388+
369389
// Prepend quoted message as Markdown blockquote
370390
const rawTrimmed = input.trim();
371391
const trimmed = quotedMessage
@@ -381,19 +401,23 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
381401
setSkillVersion((v) => v + 1);
382402
}
383403

384-
// Upload image if present
404+
// Generate message ID upfront so we can use it as E2E context for both text and media
405+
const msgId = randomUUID();
406+
407+
// Upload file if present
385408
let mediaUrl: string | undefined;
386409
if (pendingImage) {
387410
setImageUploading(true);
388-
const url = await uploadImage(pendingImage.file);
411+
// Use "{msgId}:media" as E2E context for the binary — distinct from text context
412+
const result = await uploadFile(pendingImage.file, `${msgId}:media`);
389413
setImageUploading(false);
390-
if (!url) return; // Upload failed
391-
mediaUrl = url;
414+
if (!result) return; // Upload failed
415+
mediaUrl = result.url;
392416
clearPendingImage();
393417
}
394418

395419
const msg: ChatMessage = {
396-
id: randomUUID(),
420+
id: msgId,
397421
sender: "user",
398422
text: trimmed,
399423
timestamp: Date.now(),
@@ -810,14 +834,14 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
810834
/>
811835
<button
812836
onClick={() => fileInputRef.current?.click()}
813-
className="p-1.5 rounded hover:bg-[--bg-hover] transition-colors"
814-
style={{ color: "var(--text-muted)" }}
815-
title="Upload image"
816-
aria-label="Upload image"
837+
className="p-1.5 rounded hover:bg-[--bg-hover] transition-colors flex items-center gap-1"
838+
style={{ color: "var(--text-secondary)" }}
839+
title="Attach file"
840+
aria-label="Attach file"
817841
disabled={!state.openclawConnected}
818842
>
819-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
820-
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v13.5A1.5 1.5 0 003.75 21zm14.25-15.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
843+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
844+
<path strokeLinecap="round" strokeLinejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
821845
</svg>
822846
</button>
823847
</div>
@@ -842,7 +866,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
842866
) : (
843867
<button
844868
onClick={handleSend}
845-
disabled={(!input.trim() && !pendingImage) || !state.openclawConnected}
869+
disabled={!input.trim() && !pendingImage}
846870
className="px-3 py-1.5 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
847871
style={{ background: "var(--bg-active)" }}
848872
>
@@ -954,6 +978,8 @@ function MessageRow({
954978
<MessageContent
955979
text={msg.text}
956980
mediaUrl={msg.mediaUrl}
981+
messageId={msg.id}
982+
encrypted={!!msg.mediaUrl && E2eService.hasKey()}
957983
a2ui={msg.a2ui}
958984
isStreaming={msg.isStreaming}
959985
onAction={onAction}

packages/web/src/components/MessageContent.tsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useState, useCallback, useMemo } from "react";
1+
import React, { useState, useCallback, useMemo, useEffect } from "react";
2+
import { E2eService } from "../e2e";
23
import ReactMarkdown from "react-markdown";
34
import remarkGfm from "remark-gfm";
45
import rehypeHighlight from "rehype-highlight";
@@ -25,6 +26,10 @@ type ParsedAction = {
2526
type MessageContentProps = {
2627
text: string;
2728
mediaUrl?: string;
29+
/** Message ID — used to derive E2E decryption context as "{messageId}:media" */
30+
messageId?: string;
31+
/** Whether this message was E2E encrypted (media binary may also be encrypted) */
32+
encrypted?: boolean;
2833
a2ui?: string;
2934
className?: string;
3035
isStreaming?: boolean;
@@ -938,6 +943,8 @@ function ActionBlockPlaceholder() {
938943
export function MessageContent({
939944
text,
940945
mediaUrl,
946+
messageId,
947+
encrypted,
941948
a2ui,
942949
className = "",
943950
isStreaming,
@@ -965,7 +972,7 @@ export function MessageContent({
965972
{/* Media preview */}
966973
{mediaUrl && (
967974
<div className="mb-2">
968-
<MediaPreview url={mediaUrl} />
975+
<MediaPreview url={mediaUrl} mediaContextId={encrypted && messageId ? `${messageId}:media` : undefined} />
969976
</div>
970977
)}
971978

@@ -1009,9 +1016,62 @@ export function MessageContent({
10091016
// Media preview — handles images, audio, video, and file downloads
10101017
// ---------------------------------------------------------------------------
10111018

1012-
function MediaPreview({ url }: { url: string }) {
1019+
/**
1020+
* MediaPreview — renders images, audio, video, and files.
1021+
* If mediaContextId is provided and E2E key is available, fetches the media,
1022+
* decrypts it client-side, and renders a local object URL.
1023+
*/
1024+
function MediaPreview({ url, mediaContextId }: { url: string; mediaContextId?: string }) {
1025+
const [decryptedUrl, setDecryptedUrl] = useState<string | null>(null);
1026+
const [decrypting, setDecrypting] = useState(false);
1027+
1028+
useEffect(() => {
1029+
if (!mediaContextId || !E2eService.hasKey()) {
1030+
setDecryptedUrl(null);
1031+
return;
1032+
}
1033+
1034+
let cancelled = false;
1035+
setDecrypting(true);
1036+
1037+
(async () => {
1038+
try {
1039+
const res = await fetch(url);
1040+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
1041+
const encrypted = new Uint8Array(await res.arrayBuffer());
1042+
const decrypted = await E2eService.decryptMedia(encrypted, mediaContextId);
1043+
if (!cancelled) {
1044+
const blob = new Blob([decrypted.buffer.slice(0) as ArrayBuffer]);
1045+
setDecryptedUrl(URL.createObjectURL(blob));
1046+
}
1047+
} catch (err) {
1048+
console.warn("[E2E] Media decryption failed, falling back to direct URL:", err);
1049+
if (!cancelled) setDecryptedUrl(null);
1050+
} finally {
1051+
if (!cancelled) setDecrypting(false);
1052+
}
1053+
})();
1054+
1055+
return () => {
1056+
cancelled = true;
1057+
if (decryptedUrl) URL.revokeObjectURL(decryptedUrl);
1058+
};
1059+
// eslint-disable-next-line react-hooks/exhaustive-deps
1060+
}, [url, mediaContextId]);
1061+
1062+
// Use decrypted URL if available, otherwise fall back to direct URL
1063+
const effectiveUrl = decryptedUrl || url;
10131064
const ext = url.split(".").pop()?.toLowerCase().split("?")[0] ?? "";
10141065

1066+
if (decrypting) {
1067+
return (
1068+
<div className="flex items-center gap-2 px-3 py-2 rounded-md max-w-[360px]"
1069+
style={{ background: "var(--bg-hover)", border: "1px solid var(--border)" }}>
1070+
<span className="text-caption" style={{ color: "var(--text-muted)" }}>Decrypting media...</span>
1071+
</div>
1072+
);
1073+
}
1074+
10151075
// Audio
10161076
if (["mp3", "wav", "ogg", "m4a", "aac", "webm"].includes(ext)) {
10171077
return (
@@ -1023,7 +1083,7 @@ function MediaPreview({ url }: { url: string }) {
10231083
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
10241084
</svg>
10251085
<audio controls className="flex-1 h-8" style={{ maxWidth: 280 }}>
1026-
<source src={url} />
1086+
<source src={effectiveUrl} />
10271087
</audio>
10281088
</div>
10291089
);
@@ -1037,7 +1097,7 @@ function MediaPreview({ url }: { url: string }) {
10371097
className="max-w-[360px] max-h-64 rounded-md"
10381098
style={{ border: "1px solid var(--border)" }}
10391099
>
1040-
<source src={url} />
1100+
<source src={effectiveUrl} />
10411101
</video>
10421102
);
10431103
}
@@ -1047,7 +1107,7 @@ function MediaPreview({ url }: { url: string }) {
10471107
const filename = url.split("/").pop()?.split("?")[0] ?? "file";
10481108
return (
10491109
<a
1050-
href={url}
1110+
href={effectiveUrl}
10511111
target="_blank"
10521112
rel="noopener noreferrer"
10531113
className="flex items-center gap-3 px-3 py-2.5 rounded-md max-w-[360px] hover:opacity-90 transition-opacity"
@@ -1074,11 +1134,11 @@ function MediaPreview({ url }: { url: string }) {
10741134
// Default: image
10751135
return (
10761136
<img
1077-
src={url}
1137+
src={effectiveUrl}
10781138
alt=""
10791139
className="max-w-[360px] max-h-64 rounded-md object-contain cursor-pointer hover:opacity-90 transition-opacity"
10801140
style={{ border: "1px solid var(--border)" }}
1081-
onClick={() => window.open(url, "_blank")}
1141+
onClick={() => window.open(effectiveUrl, "_blank")}
10821142
/>
10831143
);
10841144
}

packages/web/src/components/SessionTabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export function SessionTabs({ channelId }: SessionTabsProps) {
182182
} catch (err) {
183183
dlog.error("Session", `Failed to create session: ${err}`);
184184
}
185-
}, [channelId, dispatch]);
185+
}, [channelId, sessions, dispatch]);
186186

187187
const handleDelete = useCallback(
188188
async (sessionId: string) => {

packages/web/src/e2e.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "e2e-crypto";
1+
import { deriveKey, encryptText, decryptText, encryptBytes, decryptBytes, toBase64, fromBase64 } from "e2e-crypto";
22

33
const STORAGE_KEY = "botschat_e2e_pwd_cache";
44
const KEY_CACHE_KEY = "botschat_e2e_key_cache"; // base64-encoded derived key
@@ -135,10 +135,30 @@ export const E2eService = {
135135
return currentPassword;
136136
},
137137

138+
/**
139+
* Encrypt raw binary data (e.g., an image file).
140+
* Returns { encrypted: Uint8Array, contextId: string }.
141+
* The encrypted data is the same length as the input (AES-CTR, no padding).
142+
*/
143+
async encryptMedia(data: Uint8Array, contextId?: string): Promise<{ encrypted: Uint8Array; contextId: string }> {
144+
if (!currentKey) throw new Error("E2E key not set");
145+
const cid = contextId || crypto.randomUUID();
146+
const encrypted = await encryptBytes(currentKey, data, cid);
147+
return { encrypted, contextId: cid };
148+
},
149+
150+
/**
151+
* Decrypt raw binary data (e.g., an encrypted image).
152+
*/
153+
async decryptMedia(encrypted: Uint8Array, contextId: string): Promise<Uint8Array> {
154+
if (!currentKey) throw new Error("E2E key not set");
155+
return decryptBytes(currentKey, encrypted, contextId);
156+
},
157+
138158
/**
139159
* Decrypt bytes (base64) -> Uint8Array.
140160
*/
141-
async decryptBytes(ciphertextBase64: string, messageId: string): Promise<Uint8Array> {
161+
async decryptBytesLegacy(ciphertextBase64: string, messageId: string): Promise<Uint8Array> {
142162
if (!currentKey) throw new Error("E2E key not set");
143163
const ciphertext = fromBase64(ciphertextBase64);
144164
const plainStr = await decryptText(currentKey, ciphertext, messageId);

0 commit comments

Comments
 (0)