Skip to content

Commit 74c001c

Browse files
feat: enhance media upload and validation process
- Update the media upload endpoint to allow a wider range of file types, including PDFs and audio/video formats. - Implement stricter validation to block SVG, executable, and JavaScript file types to prevent security vulnerabilities. - Improve error handling and response messages for unsupported file types and size limits. - Refactor file preview handling in the ChatWindow component to accommodate non-image files.
1 parent 22a2f1d commit 74c001c

11 files changed

Lines changed: 727 additions & 74 deletions

File tree

packages/api/src/do/connection-do.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -639,16 +639,11 @@ export class ConnectionDO implements DurableObject {
639639
return null;
640640
}
641641

642-
const contentType = response.headers.get("Content-Type") ?? "image/png";
643-
// Validate that the response is actually an image
644-
if (!contentType.startsWith("image/")) {
645-
console.warn(`[DO] cacheExternalMedia: non-image Content-Type "${contentType}", skipping ${url.slice(0, 120)}`);
646-
return null;
647-
}
642+
const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
648643

649-
// Reject SVG (can contain scripts — XSS vector)
650-
if (contentType.includes("svg")) {
651-
console.warn(`[DO] cacheExternalMedia: blocked SVG content from ${url.slice(0, 120)}`);
644+
// Reject SVG (can contain scripts — XSS vector) and executable types
645+
if (contentType.includes("svg") || contentType.includes("javascript") || contentType.includes("executable")) {
646+
console.warn(`[DO] cacheExternalMedia: blocked dangerous Content-Type "${contentType}" from ${url.slice(0, 120)}`);
652647
return null;
653648
}
654649

@@ -670,14 +665,18 @@ export class ConnectionDO implements DurableObject {
670665
return null;
671666
}
672667

673-
// Determine extension from Content-Type (no SVG)
668+
// Determine extension from Content-Type
674669
const extMap: Record<string, string> = {
675670
"image/png": "png",
676671
"image/jpeg": "jpg",
677672
"image/gif": "gif",
678673
"image/webp": "webp",
674+
"application/pdf": "pdf",
675+
"audio/mpeg": "mp3",
676+
"audio/wav": "wav",
677+
"video/mp4": "mp4",
679678
};
680-
const ext = extMap[contentType] ?? "png";
679+
const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
681680
const key = `media/${userId}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
682681

683682
// Upload to R2

packages/api/src/routes/channels.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,11 @@ channels.post("/", async (c) => {
7474
.bind(taskId, id, "Ad Hoc Chat", "adhoc", sessionKey)
7575
.run();
7676

77-
// Auto-create a default session
77+
// Auto-create a default session (INSERT OR IGNORE to handle duplicate session_key
78+
// gracefully — can happen if user re-creates a channel with the same name)
7879
const sessionId = generateId("ses_");
7980
await c.env.DB.prepare(
80-
"INSERT INTO sessions (id, channel_id, user_id, name, session_key) VALUES (?, ?, ?, ?, ?)",
81+
"INSERT OR IGNORE INTO sessions (id, channel_id, user_id, name, session_key) VALUES (?, ?, ?, ?, ?)",
8182
)
8283
.bind(sessionId, id, userId, "Session 1", sessionKey)
8384
.run();

packages/api/src/routes/dev-auth.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { Hono } from "hono";
22
import type { Env } from "../env.js";
33
import { createToken, getJwtSecret } from "../utils/auth.js";
4+
import { generateId } from "../utils/id.js";
45

56
const devAuth = new Hono<{ Bindings: Env }>();
67

78
/**
89
* POST /api/dev-auth/login — secret-gated dev login for automated testing.
910
* Returns 404 when DEV_AUTH_SECRET is not configured (endpoint invisible).
11+
* Auto-creates the user record in D1 if it doesn't exist (upsert).
1012
*/
1113
devAuth.post("/login", async (c) => {
1214
const devSecret = c.env.DEV_AUTH_SECRET;
13-
if (!devSecret) {
15+
if (!devSecret || c.env.ENVIRONMENT !== "development") {
1416
return c.json({ error: "Not found" }, 404);
1517
}
1618

@@ -23,6 +25,20 @@ devAuth.post("/login", async (c) => {
2325
const jwtSecret = getJwtSecret(c.env);
2426
const token = await createToken(userId, jwtSecret);
2527

28+
// Ensure the user exists in D1 (upsert) so foreign key constraints are satisfied.
29+
// Dev-auth users get a placeholder email and no password (login only via dev-auth).
30+
try {
31+
const existing = await c.env.DB.prepare("SELECT id FROM users WHERE id = ?").bind(userId).first();
32+
if (!existing) {
33+
const email = `${userId}@dev.botschat.test`;
34+
await c.env.DB.prepare(
35+
"INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, '', ?)",
36+
).bind(userId, email, userId).run();
37+
}
38+
} catch (err) {
39+
console.error("[dev-auth] Failed to upsert user:", err);
40+
}
41+
2642
return c.json({ token, userId });
2743
});
2844

packages/api/src/routes/upload.ts

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,85 @@ export const upload = new Hono<{
88
Variables: { userId: string };
99
}>();
1010

11+
/** Allowed non-image MIME types for file attachments. */
12+
const ALLOWED_FILE_TYPES = new Set([
13+
"application/pdf",
14+
"text/plain",
15+
"text/csv",
16+
"text/markdown",
17+
"application/json",
18+
"application/zip",
19+
"application/gzip",
20+
"application/x-tar",
21+
"audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/webm", "audio/aac",
22+
"video/mp4", "video/webm", "video/quicktime",
23+
]);
24+
25+
/** Safe file extensions for each category. */
26+
const SAFE_EXTENSIONS = new Set([
27+
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
28+
"pdf", "txt", "csv", "md", "json", "zip", "gz", "tar",
29+
"mp3", "wav", "ogg", "m4a", "aac", "webm",
30+
"mp4", "mov",
31+
]);
32+
1133
/** POST / — Upload a file to R2 and return a signed URL. */
1234
upload.post("/", async (c) => {
13-
const userId = c.get("userId");
14-
const contentType = c.req.header("Content-Type") ?? "";
35+
try {
36+
const userId = c.get("userId");
37+
const contentType = c.req.header("Content-Type") ?? "";
1538

16-
if (!contentType.includes("multipart/form-data")) {
17-
return c.json({ error: "Expected multipart/form-data" }, 400);
18-
}
39+
if (!contentType.includes("multipart/form-data")) {
40+
return c.json({ error: "Expected multipart/form-data" }, 400);
41+
}
1942

20-
const formData = await c.req.formData();
21-
const file = formData.get("file") as File | null;
43+
const formData = await c.req.formData();
44+
const file = formData.get("file") as File | null;
2245

23-
if (!file) {
24-
return c.json({ error: "No file provided" }, 400);
25-
}
46+
if (!file) {
47+
return c.json({ error: "No file provided" }, 400);
48+
}
2649

27-
// Validate file type — only raster images allowed (SVG is an XSS vector)
28-
if (!file.type.startsWith("image/") || file.type.includes("svg")) {
29-
return c.json({ error: "Only image files are allowed (SVG is not permitted)" }, 400);
30-
}
50+
const fileType = file.type || "";
3151

32-
// Limit file size to 10 MB
33-
const MAX_SIZE = 10 * 1024 * 1024;
34-
if (file.size > MAX_SIZE) {
35-
return c.json({ error: "File too large (max 10 MB)" }, 413);
36-
}
52+
// Block SVG (XSS vector) and executables
53+
if (fileType.includes("svg") || fileType.includes("executable") || fileType.includes("javascript")) {
54+
return c.json({ error: "File type not permitted (SVG, executables, scripts are blocked)" }, 400);
55+
}
3756

38-
// Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
39-
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
40-
// SVG is excluded — it can contain <script> tags and is a known XSS vector
41-
const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"].includes(ext) ? ext : "png";
42-
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
43-
const key = `media/${userId}/${filename}`;
44-
45-
// Upload to R2
46-
await c.env.MEDIA.put(key, file.stream(), {
47-
httpMetadata: {
48-
contentType: file.type,
49-
},
50-
});
51-
52-
// Return a signed URL (1 hour expiry)
53-
const secret = getJwtSecret(c.env);
54-
const url = await signMediaUrl(userId, filename, secret, 3600);
55-
56-
return c.json({ url, key });
57+
// Allow images (except SVG) and a curated set of other file types
58+
const isImage = fileType.startsWith("image/");
59+
const isAllowedFile = ALLOWED_FILE_TYPES.has(fileType);
60+
if (!isImage && !isAllowedFile) {
61+
return c.json({ error: `File type '${fileType}' is not supported` }, 400);
62+
}
63+
64+
// Limit file size to 10 MB
65+
const MAX_SIZE = 10 * 1024 * 1024;
66+
if (file.size > MAX_SIZE) {
67+
return c.json({ error: "File too large (max 10 MB)" }, 413);
68+
}
69+
70+
// Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
71+
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
72+
const safeExt = SAFE_EXTENSIONS.has(ext) ? ext : (isImage ? "png" : "bin");
73+
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
74+
const key = `media/${userId}/${filename}`;
75+
76+
// Upload to R2
77+
await c.env.MEDIA.put(key, file.stream(), {
78+
httpMetadata: {
79+
contentType: fileType || "application/octet-stream",
80+
},
81+
});
82+
83+
// Return a signed URL (1 hour expiry)
84+
const secret = getJwtSecret(c.env);
85+
const url = await signMediaUrl(userId, filename, secret, 3600);
86+
87+
return c.json({ url, key });
88+
} catch (err) {
89+
console.error("[upload] Error:", err);
90+
return c.json({ error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }, 500);
91+
}
5792
});

packages/web/src/components/ChatWindow.tsx

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,11 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
265265
inputRef.current?.focus();
266266
}, []);
267267

268-
// Image upload helpers
268+
// File upload helpers
269269
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
270270
const file = e.target.files?.[0];
271-
if (!file || !file.type.startsWith("image/")) return;
272-
const preview = URL.createObjectURL(file);
271+
if (!file) return;
272+
const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : "";
273273
setPendingImage({ file, preview });
274274
e.target.value = "";
275275
inputRef.current?.focus();
@@ -339,8 +339,8 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
339339
e.stopPropagation();
340340
setDragOver(false);
341341
const file = e.dataTransfer.files?.[0];
342-
if (file && file.type.startsWith("image/")) {
343-
const preview = URL.createObjectURL(file);
342+
if (file) {
343+
const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : "";
344344
setPendingImage({ file, preview });
345345
inputRef.current?.focus();
346346
}
@@ -733,21 +733,35 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
733733
background: "var(--bg-surface)",
734734
}}
735735
>
736-
{/* Image preview */}
736+
{/* File/image preview */}
737737
{pendingImage && (
738738
<div className="px-3 pt-2 flex items-start gap-2">
739739
<div className="relative">
740-
<img
741-
src={pendingImage.preview}
742-
alt="Preview"
743-
className="max-w-[120px] max-h-[80px] rounded-md object-contain"
744-
style={{ border: "1px solid var(--border)" }}
745-
/>
740+
{pendingImage.preview ? (
741+
<img
742+
src={pendingImage.preview}
743+
alt="Preview"
744+
className="max-w-[120px] max-h-[80px] rounded-md object-contain"
745+
style={{ border: "1px solid var(--border)" }}
746+
/>
747+
) : (
748+
<div
749+
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-caption"
750+
style={{ border: "1px solid var(--border)", background: "var(--bg-hover)" }}
751+
>
752+
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
753+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
754+
</svg>
755+
<span className="truncate max-w-[100px]" style={{ color: "var(--text-secondary)" }}>
756+
{pendingImage.file.name}
757+
</span>
758+
</div>
759+
)}
746760
<button
747761
onClick={clearPendingImage}
748762
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center text-white opacity-80 hover:opacity-100 transition-opacity"
749763
style={{ background: "#e74c3c", fontSize: 11 }}
750-
title="Remove image"
764+
title="Remove file"
751765
>
752766
753767
</button>
@@ -790,7 +804,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
790804
<input
791805
ref={fileInputRef}
792806
type="file"
793-
accept="image/*"
807+
accept="image/*,application/pdf,.txt,.csv,.md,.json,.zip,.gz,.mp3,.wav,.mp4,.mov"
794808
className="hidden"
795809
onChange={handleFileSelect}
796810
/>

packages/web/src/e2e.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,13 @@ export const E2eService = {
108108

109109
/**
110110
* Encrypt text using the current key.
111-
* Generates a random messageId (UUID) as contextId/nonce source.
111+
* If contextId is provided, uses it as the nonce source (for preserving
112+
* existing messageIds). Otherwise generates a random UUID.
112113
* Returns { ciphertext: base64, messageId: string }
113114
*/
114-
async encrypt(text: string): Promise<{ ciphertext: string; messageId: string }> {
115+
async encrypt(text: string, contextId?: string): Promise<{ ciphertext: string; messageId: string }> {
115116
if (!currentKey) throw new Error("E2E key not set");
116-
const messageId = crypto.randomUUID();
117+
const messageId = contextId || crypto.randomUUID();
117118
const encrypted = await encryptText(currentKey, text, messageId);
118119
return { ciphertext: toBase64(encrypted), messageId };
119120
},

packages/web/src/ws.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,19 @@ export class BotsChatWSClient {
171171
// E2E Encryption for user messages
172172
if (msg.type === "user.message" && E2eService.hasKey() && typeof msg.text === "string") {
173173
try {
174-
const { ciphertext, messageId } = await E2eService.encrypt(msg.text);
174+
// Use the existing messageId as contextId for encryption nonce,
175+
// so decryption on the plugin side uses the same ID.
176+
const existingId = (msg.messageId as string) || undefined;
177+
const { ciphertext, messageId } = await E2eService.encrypt(msg.text, existingId);
175178
msg.text = ciphertext;
176-
msg.messageId = messageId;
179+
// Only set messageId if we didn't have one — preserve the original
180+
// so message IDs stay consistent between local state and server.
181+
if (!existingId) {
182+
msg.messageId = messageId;
183+
}
177184
msg.encrypted = true;
178185
} catch (err) {
179186
dlog.error("E2E", "Encryption failed", err);
180-
// Fail? or send as plaintext?
181-
// Security first: if key exists but encrypt fails, abort.
182187
return;
183188
}
184189
}

scripts/dev.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ do_build_web() {
5454
do_start() {
5555
kill_port 8787
5656
info "Starting wrangler dev on 0.0.0.0:8787…"
57-
exec npx wrangler dev --config wrangler.toml --ip 0.0.0.0 --var ENVIRONMENT:development
57+
exec npx wrangler dev --config wrangler.toml --ip 0.0.0.0 --var ENVIRONMENT:development --var DEV_AUTH_SECRET:botschat-dev-2026
5858
}
5959

6060
do_sync_plugin() {

0 commit comments

Comments
 (0)