botschat 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -1
  3. package/packages/api/package.json +2 -1
  4. package/packages/api/src/do/connection-do.ts +128 -33
  5. package/packages/api/src/index.ts +103 -6
  6. package/packages/api/src/routes/auth.ts +123 -29
  7. package/packages/api/src/routes/pairing.ts +14 -1
  8. package/packages/api/src/routes/setup.ts +70 -24
  9. package/packages/api/src/routes/upload.ts +12 -8
  10. package/packages/api/src/utils/auth.ts +212 -43
  11. package/packages/api/src/utils/id.ts +30 -14
  12. package/packages/api/src/utils/rate-limit.ts +73 -0
  13. package/packages/plugin/dist/src/channel.js +9 -3
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/package.json +2 -2
  16. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-BST9bfvT.css} +1 -1
  17. package/packages/web/dist/assets/index-Da18EnTa.js +851 -0
  18. package/packages/web/dist/botschat-icon.svg +4 -0
  19. package/packages/web/dist/index.html +23 -3
  20. package/packages/web/dist/manifest.json +24 -0
  21. package/packages/web/dist/sw.js +40 -0
  22. package/packages/web/index.html +21 -1
  23. package/packages/web/src/App.tsx +241 -96
  24. package/packages/web/src/api.ts +63 -3
  25. package/packages/web/src/components/ChatWindow.tsx +11 -11
  26. package/packages/web/src/components/ConnectionSettings.tsx +475 -0
  27. package/packages/web/src/components/CronDetail.tsx +475 -235
  28. package/packages/web/src/components/CronSidebar.tsx +1 -1
  29. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  30. package/packages/web/src/components/IconRail.tsx +56 -16
  31. package/packages/web/src/components/JobList.tsx +2 -6
  32. package/packages/web/src/components/LoginPage.tsx +126 -103
  33. package/packages/web/src/components/MobileLayout.tsx +480 -0
  34. package/packages/web/src/components/OnboardingPage.tsx +7 -16
  35. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  36. package/packages/web/src/components/Sidebar.tsx +1 -1
  37. package/packages/web/src/components/TaskBar.tsx +2 -2
  38. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  39. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  40. package/packages/web/src/index.css +59 -0
  41. package/packages/web/src/main.tsx +9 -0
  42. package/packages/web/src/store.ts +12 -5
  43. package/packages/web/src/ws.ts +2 -0
  44. package/scripts/dev.sh +13 -13
  45. package/wrangler.toml +3 -1
  46. package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
@@ -1,13 +1,17 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
- import { createToken, hashPassword } from "../utils/auth.js";
3
+ import { createToken, createRefreshToken, verifyRefreshToken, hashPassword, verifyPassword, getJwtSecret } from "../utils/auth.js";
4
4
  import { verifyFirebaseIdToken } from "../utils/firebase.js";
5
5
  import { generateId } from "../utils/id.js";
6
6
 
7
7
  const auth = new Hono<{ Bindings: Env }>();
8
8
 
9
- /** POST /api/auth/register */
9
+ /** POST /api/auth/register — disabled in production (OAuth only) */
10
10
  auth.post("/register", async (c) => {
11
+ if (c.env.ENVIRONMENT !== "development") {
12
+ return c.json({ error: "Email registration is disabled. Please sign in with Google or GitHub." }, 403);
13
+ }
14
+
11
15
  const { email, password, displayName } = await c.req.json<{
12
16
  email: string;
13
17
  password: string;
@@ -18,6 +22,23 @@ auth.post("/register", async (c) => {
18
22
  return c.json({ error: "Email and password are required" }, 400);
19
23
  }
20
24
 
25
+ // Basic email format validation
26
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
27
+ return c.json({ error: "Invalid email format" }, 400);
28
+ }
29
+
30
+ // Password strength requirements
31
+ if (password.length < 8) {
32
+ return c.json({ error: "Password must be at least 8 characters long" }, 400);
33
+ }
34
+ // Cap password length to prevent PBKDF2 resource exhaustion (DoS)
35
+ if (password.length > 256) {
36
+ return c.json({ error: "Password must not exceed 256 characters" }, 400);
37
+ }
38
+ if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
39
+ return c.json({ error: "Password must contain both letters and numbers" }, 400);
40
+ }
41
+
21
42
  const id = generateId("u_");
22
43
  const passwordHash = await hashPassword(password);
23
44
 
@@ -29,19 +50,25 @@ auth.post("/register", async (c) => {
29
50
  .run();
30
51
  } catch (err: unknown) {
31
52
  if (err instanceof Error && err.message.includes("UNIQUE")) {
32
- return c.json({ error: "Email already registered" }, 409);
53
+ // Generic error to prevent email enumeration
54
+ return c.json({ error: "Registration failed. Please try a different email or sign in." }, 409);
33
55
  }
34
56
  throw err;
35
57
  }
36
58
 
37
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
59
+ const secret = getJwtSecret(c.env);
38
60
  const token = await createToken(id, secret);
61
+ const refreshToken = await createRefreshToken(id, secret);
39
62
 
40
- return c.json({ id, email, token }, 201);
63
+ return c.json({ id, email, token, refreshToken }, 201);
41
64
  });
42
65
 
43
- /** POST /api/auth/login */
66
+ /** POST /api/auth/login — disabled in production (OAuth only) */
44
67
  auth.post("/login", async (c) => {
68
+ if (c.env.ENVIRONMENT !== "development") {
69
+ return c.json({ error: "Email login is disabled. Please sign in with Google or GitHub." }, 403);
70
+ }
71
+
45
72
  const { email, password } = await c.req.json<{
46
73
  email: string;
47
74
  password: string;
@@ -51,26 +78,45 @@ auth.post("/login", async (c) => {
51
78
  return c.json({ error: "Email and password are required" }, 400);
52
79
  }
53
80
 
54
- const passwordHash = await hashPassword(password);
81
+ // Cap password length to prevent PBKDF2 resource exhaustion (DoS)
82
+ if (password.length > 256) {
83
+ return c.json({ error: "Invalid email or password" }, 401);
84
+ }
55
85
 
86
+ // Fetch user with password hash — we now verify in application code
56
87
  const row = await c.env.DB.prepare(
57
- "SELECT id, email, display_name FROM users WHERE email = ? AND password_hash = ?",
88
+ "SELECT id, email, display_name, password_hash FROM users WHERE email = ?",
58
89
  )
59
- .bind(email.trim().toLowerCase(), passwordHash)
60
- .first<{ id: string; email: string; display_name: string | null }>();
90
+ .bind(email.trim().toLowerCase())
91
+ .first<{ id: string; email: string; display_name: string | null; password_hash: string }>();
61
92
 
62
- if (!row) {
93
+ if (!row || !row.password_hash) {
63
94
  return c.json({ error: "Invalid email or password" }, 401);
64
95
  }
65
96
 
66
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
97
+ const { valid, needsRehash } = await verifyPassword(password, row.password_hash);
98
+ if (!valid) {
99
+ return c.json({ error: "Invalid email or password" }, 401);
100
+ }
101
+
102
+ // Transparently upgrade legacy SHA-256 hashes to PBKDF2
103
+ if (needsRehash) {
104
+ const newHash = await hashPassword(password);
105
+ await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
106
+ .bind(newHash, row.id)
107
+ .run();
108
+ }
109
+
110
+ const secret = getJwtSecret(c.env);
67
111
  const token = await createToken(row.id, secret);
112
+ const refreshToken = await createRefreshToken(row.id, secret);
68
113
 
69
114
  return c.json({
70
115
  id: row.id,
71
116
  email: row.email,
72
117
  displayName: row.display_name,
73
118
  token,
119
+ refreshToken,
74
120
  });
75
121
  });
76
122
 
@@ -120,26 +166,41 @@ async function handleFirebaseAuth(c: {
120
166
 
121
167
  // 2. Look up existing user by firebase_uid first, then by email
122
168
  let row = await c.env.DB.prepare(
123
- "SELECT id, email, display_name, auth_provider FROM users WHERE firebase_uid = ?",
169
+ "SELECT id, email, display_name, auth_provider, password_hash FROM users WHERE firebase_uid = ?",
124
170
  )
125
171
  .bind(firebaseUid)
126
- .first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
172
+ .first<{ id: string; email: string; display_name: string | null; auth_provider: string; password_hash: string }>();
127
173
 
128
174
  if (!row) {
129
- // Check if there's an existing email-based account link it
130
- row = await c.env.DB.prepare(
131
- "SELECT id, email, display_name, auth_provider FROM users WHERE email = ?",
175
+ // Check if there's an existing account with the same email
176
+ const existing = await c.env.DB.prepare(
177
+ "SELECT id, email, display_name, auth_provider, password_hash FROM users WHERE email = ?",
132
178
  )
133
179
  .bind(email)
134
- .first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
135
-
136
- if (row) {
137
- // Link Firebase UID to existing account
180
+ .first<{ id: string; email: string; display_name: string | null; auth_provider: string; password_hash: string }>();
181
+
182
+ if (existing) {
183
+ if (existing.password_hash) {
184
+ // SECURITY: existing account has a password — do NOT auto-link.
185
+ // The user must sign in with their password first, or the admin
186
+ // must link accounts manually. Auto-linking would let an
187
+ // attacker who pre-registered the email access the real owner's
188
+ // data once they sign in with OAuth.
189
+ return c.json(
190
+ {
191
+ error: "An account with this email already exists. Please sign in with your email and password.",
192
+ code: "EMAIL_EXISTS_WITH_PASSWORD",
193
+ },
194
+ 409,
195
+ );
196
+ }
197
+ // Existing account was created via OAuth (no password) — safe to link
138
198
  await c.env.DB.prepare(
139
199
  "UPDATE users SET firebase_uid = ?, auth_provider = ?, updated_at = unixepoch() WHERE id = ?",
140
200
  )
141
- .bind(firebaseUid, authProvider, row.id)
201
+ .bind(firebaseUid, authProvider, existing.id)
142
202
  .run();
203
+ row = existing;
143
204
  }
144
205
  }
145
206
 
@@ -153,18 +214,20 @@ async function handleFirebaseAuth(c: {
153
214
  .bind(id, email, displayName, authProvider, firebaseUid)
154
215
  .run();
155
216
 
156
- row = { id, email, display_name: displayName, auth_provider: authProvider };
217
+ row = { id, email, display_name: displayName, auth_provider: authProvider, password_hash: "" };
157
218
  }
158
219
 
159
- // 4. Issue our own JWT
160
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
161
- const token = await createToken(row.id, secret);
220
+ // 4. Issue our own JWT (access + refresh)
221
+ const secret = getJwtSecret(c.env);
222
+ const token = await createToken(row!.id, secret);
223
+ const refreshToken = await createRefreshToken(row!.id, secret);
162
224
 
163
225
  return c.json({
164
- id: row.id,
165
- email: row.email,
166
- displayName: row.display_name,
226
+ id: row!.id,
227
+ email: row!.email,
228
+ displayName: row!.display_name,
167
229
  token,
230
+ refreshToken,
168
231
  });
169
232
  }
170
233
 
@@ -173,6 +236,37 @@ auth.post("/firebase", (c) => handleFirebaseAuth(c));
173
236
  auth.post("/google", (c) => handleFirebaseAuth(c));
174
237
  auth.post("/github", (c) => handleFirebaseAuth(c));
175
238
 
239
+ /** POST /api/auth/refresh — exchange a refresh token for a new access token */
240
+ auth.post("/refresh", async (c) => {
241
+ const { refreshToken } = await c.req.json<{ refreshToken: string }>();
242
+
243
+ if (!refreshToken?.trim()) {
244
+ return c.json({ error: "refreshToken is required" }, 400);
245
+ }
246
+
247
+ const secret = getJwtSecret(c.env);
248
+ const payload = await verifyRefreshToken(refreshToken, secret);
249
+
250
+ if (!payload) {
251
+ return c.json({ error: "Invalid or expired refresh token" }, 401);
252
+ }
253
+
254
+ // Issue a new short-lived access token
255
+ const token = await createToken(payload.sub, secret);
256
+
257
+ return c.json({ token });
258
+ });
259
+
260
+ /** GET /api/auth/config — public endpoint returning allowed auth methods */
261
+ auth.get("/config", (c) => {
262
+ const isDev = c.env.ENVIRONMENT === "development";
263
+ return c.json({
264
+ emailEnabled: isDev,
265
+ googleEnabled: !!c.env.FIREBASE_PROJECT_ID,
266
+ githubEnabled: !!c.env.FIREBASE_PROJECT_ID,
267
+ });
268
+ });
269
+
176
270
  /** GET /api/auth/me — returns current user info */
177
271
  auth.get("/me", async (c) => {
178
272
  // This route requires auth middleware to be applied upstream
@@ -28,7 +28,8 @@ pairing.get("/", async (c) => {
28
28
  return c.json({
29
29
  tokens: (results ?? []).map((r) => ({
30
30
  id: r.id,
31
- token: r.token,
31
+ // SECURITY: never expose the full token in list responses.
32
+ // Full token is only returned once at creation time (POST).
32
33
  tokenPreview: `bc_pat_...${r.token.slice(-8)}`,
33
34
  label: r.label,
34
35
  lastConnectedAt: r.last_connected_at,
@@ -46,6 +47,18 @@ pairing.post("/", async (c) => {
46
47
  label: undefined,
47
48
  }));
48
49
 
50
+ // Limit active (non-revoked) tokens per user
51
+ const MAX_ACTIVE_TOKENS = 10;
52
+ const countRow = await c.env.DB.prepare(
53
+ "SELECT COUNT(*) as cnt FROM pairing_tokens WHERE user_id = ? AND revoked_at IS NULL",
54
+ )
55
+ .bind(userId)
56
+ .first<{ cnt: number }>();
57
+
58
+ if (countRow && countRow.cnt >= MAX_ACTIVE_TOKENS) {
59
+ return c.json({ error: `Maximum of ${MAX_ACTIVE_TOKENS} active pairing tokens reached. Revoke an existing token first.` }, 400);
60
+ }
61
+
49
62
  const id = generateId("pt_");
50
63
  const token = generatePairingToken();
51
64
 
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
- import { createToken, hashPassword } from "../utils/auth.js";
3
+ import { createToken, hashPassword, verifyPassword, getJwtSecret } from "../utils/auth.js";
4
4
  import { verifyFirebaseIdToken } from "../utils/firebase.js";
5
5
  import { generateId, generatePairingToken } from "../utils/id.js";
6
6
  import { resolveCloudUrlWithHints } from "../utils/resolve-url.js";
@@ -51,18 +51,30 @@ setup.post("/init", async (c) => {
51
51
 
52
52
  // Find or create user
53
53
  let row = await c.env.DB.prepare(
54
- "SELECT id, display_name FROM users WHERE firebase_uid = ?",
55
- ).bind(firebaseUid).first<{ id: string; display_name: string | null }>();
54
+ "SELECT id, display_name, password_hash FROM users WHERE firebase_uid = ?",
55
+ ).bind(firebaseUid).first<{ id: string; display_name: string | null; password_hash: string }>();
56
56
 
57
57
  if (!row) {
58
- row = await c.env.DB.prepare(
59
- "SELECT id, display_name FROM users WHERE email = ?",
60
- ).bind(email).first<{ id: string; display_name: string | null }>();
61
-
62
- if (row) {
58
+ const existing = await c.env.DB.prepare(
59
+ "SELECT id, display_name, password_hash FROM users WHERE email = ?",
60
+ ).bind(email).first<{ id: string; display_name: string | null; password_hash: string }>();
61
+
62
+ if (existing) {
63
+ if (existing.password_hash) {
64
+ // SECURITY: refuse auto-link to password-protected account
65
+ return c.json(
66
+ {
67
+ error: "An account with this email already exists. Use email+password instead.",
68
+ code: "EMAIL_EXISTS_WITH_PASSWORD",
69
+ },
70
+ 409,
71
+ );
72
+ }
73
+ // OAuth-only account — safe to link
63
74
  await c.env.DB.prepare(
64
75
  "UPDATE users SET firebase_uid = ?, updated_at = unixepoch() WHERE id = ?",
65
- ).bind(firebaseUid, row.id).run();
76
+ ).bind(firebaseUid, existing.id).run();
77
+ row = existing;
66
78
  }
67
79
  }
68
80
 
@@ -75,38 +87,72 @@ setup.post("/init", async (c) => {
75
87
  `INSERT INTO users (id, email, password_hash, display_name, auth_provider, firebase_uid)
76
88
  VALUES (?, ?, '', ?, ?, ?)`,
77
89
  ).bind(id, email, displayName ?? email.split("@")[0], authProvider, firebaseUid).run();
78
- row = { id, display_name: displayName };
90
+ row = { id, display_name: displayName, password_hash: "" };
79
91
  }
80
92
 
81
93
  userId = row.id;
82
94
  displayName = row.display_name;
83
95
  } else {
84
- // ---- Email + password path ----
96
+ // ---- Email + password path (local dev only) ----
97
+ if (c.env.ENVIRONMENT !== "development") {
98
+ return c.json({ error: "Email login is disabled. Please use Google or GitHub sign-in." }, 403);
99
+ }
100
+
85
101
  if (!body.email?.trim() || !body.password?.trim()) {
86
102
  return c.json({ error: "email+password or idToken is required" }, 400);
87
103
  }
88
104
 
105
+ // Cap password length to prevent PBKDF2 resource exhaustion (DoS)
106
+ if (body.password.length > 256) {
107
+ return c.json({ error: "Invalid email or password" }, 401);
108
+ }
109
+
89
110
  email = body.email.trim().toLowerCase();
90
- const passwordHash = await hashPassword(body.password);
91
111
 
92
112
  const row = await c.env.DB.prepare(
93
- "SELECT id, display_name FROM users WHERE email = ? AND password_hash = ?",
94
- ).bind(email, passwordHash).first<{ id: string; display_name: string | null }>();
113
+ "SELECT id, display_name, password_hash FROM users WHERE email = ?",
114
+ ).bind(email).first<{ id: string; display_name: string | null; password_hash: string }>();
95
115
 
96
- if (!row) {
116
+ if (!row || !row.password_hash) {
97
117
  return c.json({ error: "Invalid email or password" }, 401);
98
118
  }
99
119
 
120
+ const { valid, needsRehash } = await verifyPassword(body.password, row.password_hash);
121
+ if (!valid) {
122
+ return c.json({ error: "Invalid email or password" }, 401);
123
+ }
124
+
125
+ // Transparently upgrade legacy SHA-256 hashes to PBKDF2
126
+ if (needsRehash) {
127
+ const newHash = await hashPassword(body.password);
128
+ await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
129
+ .bind(newHash, row.id)
130
+ .run();
131
+ }
132
+
100
133
  userId = row.id;
101
134
  displayName = row.display_name;
102
135
  }
103
136
 
104
- // ---- Create a fresh pairing token for this setup ----
105
- const ptId = generateId("pt_");
106
- const pairingToken = generatePairingToken();
107
- await c.env.DB.prepare(
108
- "INSERT INTO pairing_tokens (id, user_id, token, label) VALUES (?, ?, ?, ?)",
109
- ).bind(ptId, userId, pairingToken, "CLI setup").run();
137
+ // ---- Reuse recent token or create a new pairing token ----
138
+ // If a CLI setup token was created in the last 5 minutes, reuse it
139
+ let pairingToken: string;
140
+ const recentToken = await c.env.DB.prepare(
141
+ `SELECT token FROM pairing_tokens
142
+ WHERE user_id = ? AND label = 'CLI setup' AND revoked_at IS NULL
143
+ AND created_at > unixepoch() - 300
144
+ ORDER BY created_at DESC LIMIT 1`,
145
+ ).bind(userId).first<{ token: string }>();
146
+
147
+ if (recentToken) {
148
+ pairingToken = recentToken.token;
149
+ } else {
150
+ const ptId = generateId("pt_");
151
+ pairingToken = generatePairingToken();
152
+ await c.env.DB.prepare(
153
+ "INSERT INTO pairing_tokens (id, user_id, token, label) VALUES (?, ?, ?, ?)",
154
+ ).bind(ptId, userId, pairingToken, "CLI setup").run();
155
+ }
110
156
 
111
157
  // ---- Ensure a default channel exists ----
112
158
  let channel = await c.env.DB.prepare(
@@ -122,7 +168,7 @@ setup.post("/init", async (c) => {
122
168
  }
123
169
 
124
170
  // ---- Issue JWT ----
125
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
171
+ const secret = getJwtSecret(c.env);
126
172
  const token = await createToken(userId, secret);
127
173
 
128
174
  // ---- Resolve the best cloud URL for the plugin to connect back ----
@@ -138,7 +184,7 @@ setup.post("/init", async (c) => {
138
184
  ...(isLoopback ? { cloudUrlWarning: hint } : {}),
139
185
  channel: { id: channel.id, name: channel.name },
140
186
  setupCommands: [
141
- "openclaw plugins install @botschat/openclaw-plugin",
187
+ "openclaw plugins install @botschat/botschat",
142
188
  `openclaw config set channels.botschat.cloudUrl ${cloudUrl}`,
143
189
  `openclaw config set channels.botschat.pairingToken ${pairingToken}`,
144
190
  "openclaw config set channels.botschat.enabled true",
@@ -171,7 +217,7 @@ setup.get("/status", async (c) => {
171
217
  }
172
218
 
173
219
  const { verifyToken } = await import("../utils/auth.js");
174
- const jwtSecret = c.env.JWT_SECRET ?? "botschat-dev-secret";
220
+ const jwtSecret = getJwtSecret(c.env);
175
221
  const payload = await verifyToken(authHeader.slice(7), jwtSecret);
176
222
  if (!payload) {
177
223
  return c.json({ error: "Invalid or expired token" }, 401);
@@ -1,12 +1,13 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
+ import { signMediaUrl, getJwtSecret } from "../utils/auth.js";
3
4
 
4
5
  export const upload = new Hono<{
5
6
  Bindings: Env;
6
7
  Variables: { userId: string };
7
8
  }>();
8
9
 
9
- /** POST / — Upload a file to R2 and return its public URL. */
10
+ /** POST / — Upload a file to R2 and return a signed URL. */
10
11
  upload.post("/", async (c) => {
11
12
  const userId = c.get("userId");
12
13
  const contentType = c.req.header("Content-Type") ?? "";
@@ -22,9 +23,9 @@ upload.post("/", async (c) => {
22
23
  return c.json({ error: "No file provided" }, 400);
23
24
  }
24
25
 
25
- // Validate file type — only images allowed
26
- if (!file.type.startsWith("image/")) {
27
- return c.json({ error: "Only image files are allowed" }, 400);
26
+ // Validate file type — only raster images allowed (SVG is an XSS vector)
27
+ if (!file.type.startsWith("image/") || file.type.includes("svg")) {
28
+ return c.json({ error: "Only image files are allowed (SVG is not permitted)" }, 400);
28
29
  }
29
30
 
30
31
  // Limit file size to 10 MB
@@ -35,8 +36,10 @@ upload.post("/", async (c) => {
35
36
 
36
37
  // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
37
38
  const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
38
- const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"].includes(ext) ? ext : "png";
39
- const key = `media/${userId}/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${safeExt}`;
39
+ // SVG is excluded it can contain <script> tags and is a known XSS vector
40
+ const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"].includes(ext) ? ext : "png";
41
+ const filename = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${safeExt}`;
42
+ const key = `media/${userId}/${filename}`;
40
43
 
41
44
  // Upload to R2
42
45
  await c.env.MEDIA.put(key, file.stream(), {
@@ -45,8 +48,9 @@ upload.post("/", async (c) => {
45
48
  },
46
49
  });
47
50
 
48
- // Return the URL for serving through the API
49
- const url = `/api/media/${key.replace("media/", "")}`;
51
+ // Return a signed URL (1 hour expiry)
52
+ const secret = getJwtSecret(c.env);
53
+ const url = await signMediaUrl(userId, filename, secret, 3600);
50
54
 
51
55
  return c.json({ url, key });
52
56
  });