botschat 0.1.9 → 0.1.12

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 (45) hide show
  1. package/README.md +10 -0
  2. package/package.json +14 -1
  3. package/packages/api/src/do/connection-do.ts +105 -28
  4. package/packages/api/src/env.ts +4 -0
  5. package/packages/api/src/index.ts +4 -0
  6. package/packages/api/src/routes/auth.ts +46 -3
  7. package/packages/api/src/routes/channels.ts +3 -2
  8. package/packages/api/src/routes/dev-auth.ts +45 -0
  9. package/packages/api/src/routes/upload.ts +73 -38
  10. package/packages/api/src/utils/firebase.ts +130 -0
  11. package/packages/plugin/dist/src/channel.d.ts +6 -0
  12. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  13. package/packages/plugin/dist/src/channel.js +97 -15
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/dist/src/types.d.ts +6 -0
  16. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  17. package/packages/plugin/package.json +1 -1
  18. package/packages/web/dist/assets/{index-B1sFqYiM.css → index-CCBhODDo.css} +1 -1
  19. package/packages/web/dist/assets/index-CCFgKLX_.js +1 -0
  20. package/packages/web/dist/assets/index-D8mBAwjS.js +1516 -0
  21. package/packages/web/dist/assets/index-Dx64BDkP.js +1 -0
  22. package/packages/web/dist/assets/index-E-nzPZl8.js +2 -0
  23. package/packages/web/dist/assets/web-DJQW-VLX.js +1 -0
  24. package/packages/web/dist/index.html +2 -2
  25. package/packages/web/package.json +4 -1
  26. package/packages/web/src/App.tsx +78 -3
  27. package/packages/web/src/api.ts +12 -2
  28. package/packages/web/src/components/ChatWindow.tsx +410 -87
  29. package/packages/web/src/components/CronSidebar.tsx +89 -24
  30. package/packages/web/src/components/LoginPage.tsx +9 -1
  31. package/packages/web/src/components/MessageContent.tsx +71 -9
  32. package/packages/web/src/components/MobileLayout.tsx +28 -118
  33. package/packages/web/src/components/SessionTabs.tsx +179 -23
  34. package/packages/web/src/components/Sidebar.tsx +88 -66
  35. package/packages/web/src/components/ThreadPanel.tsx +153 -12
  36. package/packages/web/src/e2e.ts +26 -5
  37. package/packages/web/src/firebase.ts +127 -2
  38. package/packages/web/src/index.css +10 -2
  39. package/packages/web/src/main.tsx +23 -2
  40. package/packages/web/src/ws.ts +44 -12
  41. package/scripts/dev.sh +28 -22
  42. package/scripts/test-e2e-chat.ts +2 -2
  43. package/scripts/test-e2e-live.ts +1 -1
  44. package/wrangler.toml +3 -0
  45. package/packages/web/dist/assets/index-BcHAQzqW.js +0 -1497
package/README.md CHANGED
@@ -295,6 +295,16 @@ npm run typecheck
295
295
  ```
296
296
 
297
297
 
298
+ ## Author
299
+
300
+ Made by [auxten](https://github.com/auxten) — author of [chDB](https://github.com/chdb-io/chdb), contributor to [ClickHouse](https://github.com/ClickHouse/ClickHouse), [Jemalloc](https://github.com/jemalloc/jemalloc), [Kubernetes](https://github.com/kubernetes/kubernetes), [Memcached](https://github.com/memcached/memcached), [CockroachDB](https://github.com/cockroachdb/cockroach), and [Superset](https://github.com/apache/superset).
301
+
302
+ Assisted by [Daniel Robbins](https://github.com/Daniel-Robbins) — an [OpenClaw](https://github.com/openclaw/openclaw) AI agent running on a headless Mac Mini (kept alive by [MacMate](https://macmate.app)). It writes code, opens PRs, and lives in a closet. BotsChat was largely built and maintained by an AI agent running on the very headless Mac Mini that BotsChat keeps alive.
303
+
304
+ Website: [botschat.app](https://botschat.app) · Contact: [auxtenwpc@gmail.com](mailto:auxtenwpc@gmail.com)
305
+
298
306
  ## License
299
307
 
300
308
  Apache-2.0
309
+
310
+ © 2026 [Auxten.com](https://auxten.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -16,6 +16,10 @@
16
16
  "db:migrate:remote": "wrangler d1 migrations apply botschat-db --remote",
17
17
  "typecheck": "tsc --noEmit",
18
18
  "build:plugin": "npm run build -w packages/plugin",
19
+ "ios:build": "npm run build -w packages/web && npx cap sync ios",
20
+ "ios:open": "npx cap open ios",
21
+ "ios:run": "npx cap run ios",
22
+ "ios:sync": "npx cap sync ios",
19
23
  "test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
20
24
  "test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
21
25
  },
@@ -49,10 +53,19 @@
49
53
  "url": "git+https://github.com/botschat-app/botsChat.git"
50
54
  },
51
55
  "devDependencies": {
56
+ "@capacitor/cli": "^8.1.0",
52
57
  "typescript": "^5.7.0",
53
58
  "wrangler": "^3.100.0"
54
59
  },
55
60
  "dependencies": {
61
+ "@capacitor/app": "^8.0.1",
62
+ "@capacitor/core": "^8.1.0",
63
+ "@capacitor/haptics": "^8.0.0",
64
+ "@capacitor/ios": "^8.1.0",
65
+ "@capacitor/keyboard": "^8.0.0",
66
+ "@capacitor/splash-screen": "^8.0.1",
67
+ "@capacitor/status-bar": "^8.0.1",
68
+ "@capgo/capacitor-social-login": "^8.3.1",
56
69
  "react-resizable-panels": "^4.6.2"
57
70
  }
58
71
  }
@@ -1,5 +1,5 @@
1
1
  import type { Env } from "../env.js";
2
- import { verifyToken, getJwtSecret } from "../utils/auth.js";
2
+ import { verifyToken, getJwtSecret, signMediaUrl } from "../utils/auth.js";
3
3
  import { generateId as generateIdUtil } from "../utils/id.js";
4
4
  import { randomUUID } from "../utils/uuid.js";
5
5
 
@@ -179,10 +179,28 @@ export class ConnectionDO implements DurableObject {
179
179
  const isValid = attachment?.preVerified || await this.validatePairingToken(token);
180
180
 
181
181
  if (isValid) {
182
+ // Close any existing (potentially stale) openclaw sockets before
183
+ // marking the new one as authenticated. Cloudflare's edge infra
184
+ // terminates WebSocket connections every ~60 min (code 1006). The
185
+ // plugin reconnects immediately, but the DO may not have detected
186
+ // the old socket's death yet (no close frame → no webSocketClose
187
+ // callback yet). Without this cleanup, getOpenClawSocket() could
188
+ // return a stale/dead socket, silently dropping all messages.
189
+ const existingSockets = this.state.getWebSockets("openclaw");
190
+ for (const oldWs of existingSockets) {
191
+ if (oldWs !== ws) {
192
+ try {
193
+ oldWs.close(1000, "replaced by new connection");
194
+ } catch {
195
+ // Socket may already be dead — ignore
196
+ }
197
+ }
198
+ }
199
+
182
200
  ws.serializeAttachment({ ...attachment, authenticated: true });
183
201
  // Include userId so the plugin can derive the E2E key
184
202
  const userId = await this.state.storage.get<string>("userId");
185
- console.log(`[DO] auth.ok → userId=${userId}`);
203
+ console.log(`[DO] auth.ok → userId=${userId}, closedStale=${existingSockets.length - 1}`);
186
204
  ws.send(JSON.stringify({ type: "auth.ok", userId }));
187
205
  // Store gateway default model from plugin auth
188
206
  if (msg.model) {
@@ -373,7 +391,34 @@ export class ConnectionDO implements DurableObject {
373
391
  // Forward user messages to OpenClaw
374
392
  const openclawWs = this.getOpenClawSocket();
375
393
  if (openclawWs) {
376
- openclawWs.send(JSON.stringify(msg));
394
+ // If this is a thread message, look up the parent message and attach it
395
+ // so the plugin can inject the thread-origin context into the AI conversation.
396
+ const sessionKey = msg.sessionKey as string | undefined;
397
+ const threadMatch = sessionKey?.match(/:thread:(.+)$/);
398
+ let enrichedMsg = msg;
399
+ if (threadMatch) {
400
+ const parentId = threadMatch[1];
401
+ try {
402
+ const parentRow = await this.env.DB.prepare(
403
+ `SELECT id, text, sender, encrypted FROM messages WHERE id = ? LIMIT 1`,
404
+ )
405
+ .bind(parentId)
406
+ .first();
407
+ if (parentRow) {
408
+ enrichedMsg = {
409
+ ...msg,
410
+ parentMessageId: parentRow.id as string,
411
+ parentText: (parentRow.text ?? "") as string,
412
+ parentSender: parentRow.sender as string,
413
+ parentEncrypted: (parentRow.encrypted ?? 0) as number,
414
+ };
415
+ console.log(`[DO] Attached parent message for thread: parentId=${parentId}, sender=${parentRow.sender}, encrypted=${parentRow.encrypted}`);
416
+ }
417
+ } catch (err) {
418
+ console.error(`[DO] Failed to fetch parent message for thread ${parentId}:`, err);
419
+ }
420
+ }
421
+ openclawWs.send(JSON.stringify(enrichedMsg));
377
422
  } else {
378
423
  ws.send(
379
424
  JSON.stringify({
@@ -491,12 +536,15 @@ export class ConnectionDO implements DurableObject {
491
536
 
492
537
  private getOpenClawSocket(): WebSocket | null {
493
538
  const sockets = this.state.getWebSockets("openclaw");
494
- // Return the first authenticated OpenClaw socket
539
+ // Return the LAST (newest) authenticated OpenClaw socket.
540
+ // After a reconnection there may briefly be multiple sockets
541
+ // before the stale cleanup in handleOpenClawMessage runs.
542
+ let newest: WebSocket | null = null;
495
543
  for (const s of sockets) {
496
544
  const att = s.deserializeAttachment() as { authenticated: boolean } | null;
497
- if (att?.authenticated) return s;
545
+ if (att?.authenticated) newest = s;
498
546
  }
499
- return sockets[0] ?? null;
547
+ return newest ?? sockets[sockets.length - 1] ?? null;
500
548
  }
501
549
 
502
550
  private broadcastToBrowsers(message: string): void {
@@ -591,16 +639,11 @@ export class ConnectionDO implements DurableObject {
591
639
  return null;
592
640
  }
593
641
 
594
- const contentType = response.headers.get("Content-Type") ?? "image/png";
595
- // Validate that the response is actually an image
596
- if (!contentType.startsWith("image/")) {
597
- console.warn(`[DO] cacheExternalMedia: non-image Content-Type "${contentType}", skipping ${url.slice(0, 120)}`);
598
- return null;
599
- }
642
+ const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
600
643
 
601
- // Reject SVG (can contain scripts — XSS vector)
602
- if (contentType.includes("svg")) {
603
- 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)}`);
604
647
  return null;
605
648
  }
606
649
 
@@ -622,14 +665,18 @@ export class ConnectionDO implements DurableObject {
622
665
  return null;
623
666
  }
624
667
 
625
- // Determine extension from Content-Type (no SVG)
668
+ // Determine extension from Content-Type
626
669
  const extMap: Record<string, string> = {
627
670
  "image/png": "png",
628
671
  "image/jpeg": "jpg",
629
672
  "image/gif": "gif",
630
673
  "image/webp": "webp",
674
+ "application/pdf": "pdf",
675
+ "audio/mpeg": "mp3",
676
+ "audio/wav": "wav",
677
+ "video/mp4": "mp4",
631
678
  };
632
- const ext = extMap[contentType] ?? "png";
679
+ const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
633
680
  const key = `media/${userId}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
634
681
 
635
682
  // Upload to R2
@@ -637,7 +684,11 @@ export class ConnectionDO implements DurableObject {
637
684
  httpMetadata: { contentType },
638
685
  });
639
686
 
640
- const localUrl = `/api/media/${key.replace("media/", "")}`;
687
+ // Sign the cached media URL so the browser can fetch it without Bearer auth.
688
+ // key is "media/{userId}/{filename}" — extract just the filename part.
689
+ const cachedFilename = key.replace(`media/${userId}/`, "");
690
+ const secret = getJwtSecret(this.env);
691
+ const localUrl = await signMediaUrl(userId, cachedFilename, secret, 3600);
641
692
  console.log(`[DO] cacheExternalMedia: OK ${url.slice(0, 80)} → ${localUrl} (${body.byteLength} bytes)`);
642
693
  return localUrl;
643
694
  } catch (err) {
@@ -646,6 +697,21 @@ export class ConnectionDO implements DurableObject {
646
697
  }
647
698
  }
648
699
 
700
+ /**
701
+ * Re-sign a media URL with a fresh signature. Handles both relative
702
+ * (/api/media/...) and absolute (https://host/api/media/...) URLs.
703
+ * Returns a freshly signed relative URL, or the original if not a media URL.
704
+ */
705
+ private async refreshMediaUrl(url: string, secret: string): Promise<string> {
706
+ // Extract userId and filename from /api/media/:userId/:filename patterns
707
+ const match = url.match(/\/api\/media\/([^/?]+)\/([^?]+)/);
708
+ if (!match) return url; // Not a media URL — return as-is
709
+
710
+ const userId = decodeURIComponent(match[1]);
711
+ const filename = decodeURIComponent(match[2]);
712
+ return signMediaUrl(userId, filename, secret, 3600);
713
+ }
714
+
649
715
  // ---- Message persistence ----
650
716
 
651
717
  private async persistMessage(opts: {
@@ -737,16 +803,27 @@ export class ConnectionDO implements DurableObject {
737
803
  }
738
804
  }
739
805
 
740
- const messages = (result.results ?? []).map((row: Record<string, unknown>) => ({
741
- id: row.id,
742
- sender: row.sender,
743
- text: row.text ?? "",
744
- timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
745
- mediaUrl: row.media_url ?? undefined,
746
- a2ui: row.a2ui ?? undefined,
747
- threadId: row.thread_id ?? undefined,
748
- encrypted: row.encrypted ?? 0,
749
- }));
806
+ // Re-sign media URLs so they're always fresh when loading history.
807
+ // Stored URLs may have expired signatures or no signature at all.
808
+ const secret = getJwtSecret(this.env);
809
+ const messages = await Promise.all(
810
+ (result.results ?? []).map(async (row: Record<string, unknown>) => {
811
+ let mediaUrl = (row.media_url as string | null) ?? undefined;
812
+ if (mediaUrl) {
813
+ mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
814
+ }
815
+ return {
816
+ id: row.id,
817
+ sender: row.sender,
818
+ text: row.text ?? "",
819
+ timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
820
+ mediaUrl,
821
+ a2ui: row.a2ui ?? undefined,
822
+ threadId: row.thread_id ?? undefined,
823
+ encrypted: row.encrypted ?? 0,
824
+ };
825
+ }),
826
+ );
750
827
 
751
828
  return Response.json({ messages, replyCounts });
752
829
  } catch (err) {
@@ -6,6 +6,10 @@ export type Env = {
6
6
  ENVIRONMENT: string;
7
7
  JWT_SECRET?: string;
8
8
  FIREBASE_PROJECT_ID?: string;
9
+ GOOGLE_WEB_CLIENT_ID?: string;
10
+ GOOGLE_IOS_CLIENT_ID?: string;
9
11
  /** Canonical public URL override — if set, always use this as cloudUrl. */
10
12
  PUBLIC_URL?: string;
13
+ /** Secret for dev-token auth bypass (automated testing). Endpoint is 404 when unset. */
14
+ DEV_AUTH_SECRET?: string;
11
15
  };
@@ -12,6 +12,7 @@ import { pairing } from "./routes/pairing.js";
12
12
  import { sessions } from "./routes/sessions.js";
13
13
  import { upload } from "./routes/upload.js";
14
14
  import { setup } from "./routes/setup.js";
15
+ import { devAuth } from "./routes/dev-auth.js";
15
16
 
16
17
  // Re-export the Durable Object class so wrangler can find it
17
18
  export { ConnectionDO } from "./do/connection-do.js";
@@ -23,6 +24,8 @@ const PRODUCTION_ORIGINS = [
23
24
  "https://console.botschat.app",
24
25
  "https://botschat.app",
25
26
  "https://botschat-api.auxtenwpc.workers.dev",
27
+ "capacitor://localhost", // iOS Capacitor app
28
+ "http://localhost", // Android Capacitor app
26
29
  ];
27
30
 
28
31
  // CORS and security headers — skip for WebSocket upgrade requests
@@ -81,6 +84,7 @@ app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
81
84
 
82
85
  // ---- Public routes (no auth) ----
83
86
  app.route("/api/auth", auth);
87
+ app.route("/api/dev-auth", devAuth);
84
88
  app.route("/api/setup", setup);
85
89
 
86
90
  // ---- Protected routes (require Bearer token) ----
@@ -1,7 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
3
  import { createToken, createRefreshToken, verifyRefreshToken, hashPassword, verifyPassword, getJwtSecret } from "../utils/auth.js";
4
- import { verifyFirebaseIdToken } from "../utils/firebase.js";
4
+ import { verifyAnyGoogleToken } from "../utils/firebase.js";
5
5
  import { generateId } from "../utils/id.js";
6
6
 
7
7
  const auth = new Hono<{ Bindings: Env }>();
@@ -140,10 +140,16 @@ async function handleFirebaseAuth(c: {
140
140
  return c.json({ error: "Firebase sign-in is not configured" }, 500);
141
141
  }
142
142
 
143
- // 1. Verify the Firebase ID token
143
+ // 1. Verify the ID token (Firebase or native Google)
144
+ // Allowed Google client IDs for native iOS/Android sign-in
145
+ const allowedGoogleClientIds = [
146
+ c.env.GOOGLE_WEB_CLIENT_ID, // Web Client ID (iOSServerClientId)
147
+ c.env.GOOGLE_IOS_CLIENT_ID, // iOS Client ID
148
+ ].filter(Boolean) as string[];
149
+
144
150
  let firebaseUser;
145
151
  try {
146
- firebaseUser = await verifyFirebaseIdToken(idToken, projectId);
152
+ firebaseUser = await verifyAnyGoogleToken(idToken, projectId, allowedGoogleClientIds);
147
153
  } catch (err) {
148
154
  const msg = err instanceof Error ? err.message : "Token verification failed";
149
155
  return c.json({ error: msg }, 401);
@@ -236,6 +242,43 @@ auth.post("/firebase", (c) => handleFirebaseAuth(c));
236
242
  auth.post("/google", (c) => handleFirebaseAuth(c));
237
243
  auth.post("/github", (c) => handleFirebaseAuth(c));
238
244
 
245
+ /**
246
+ * POST /api/auth/dev-login — development-only passwordless login by email.
247
+ * Used for mobile debugging when OAuth is not yet working.
248
+ */
249
+ auth.post("/dev-login", async (c) => {
250
+ if (c.env.ENVIRONMENT !== "development") {
251
+ return c.json({ error: "Dev login is only available in development mode" }, 403);
252
+ }
253
+
254
+ const { email } = await c.req.json<{ email: string }>();
255
+ if (!email?.trim()) {
256
+ return c.json({ error: "email is required" }, 400);
257
+ }
258
+
259
+ const row = await c.env.DB.prepare(
260
+ "SELECT id, email, display_name FROM users WHERE email = ?",
261
+ )
262
+ .bind(email.trim().toLowerCase())
263
+ .first<{ id: string; email: string; display_name: string | null }>();
264
+
265
+ if (!row) {
266
+ return c.json({ error: "User not found" }, 404);
267
+ }
268
+
269
+ const secret = getJwtSecret(c.env);
270
+ const token = await createToken(row.id, secret);
271
+ const refreshToken = await createRefreshToken(row.id, secret);
272
+
273
+ return c.json({
274
+ id: row.id,
275
+ email: row.email,
276
+ displayName: row.display_name,
277
+ token,
278
+ refreshToken,
279
+ });
280
+ });
281
+
239
282
  /** POST /api/auth/refresh — exchange a refresh token for a new access token */
240
283
  auth.post("/refresh", async (c) => {
241
284
  const { refreshToken } = await c.req.json<{ refreshToken: string }>();
@@ -74,10 +74,11 @@ channels.post("/", async (c) => {
74
74
  .bind(taskId, id, "Ad Hoc Chat", "adhoc", sessionKey)
75
75
  .run();
76
76
 
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)
78
79
  const sessionId = generateId("ses_");
79
80
  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 (?, ?, ?, ?, ?)",
81
82
  )
82
83
  .bind(sessionId, id, userId, "Session 1", sessionKey)
83
84
  .run();
@@ -0,0 +1,45 @@
1
+ import { Hono } from "hono";
2
+ import type { Env } from "../env.js";
3
+ import { createToken, getJwtSecret } from "../utils/auth.js";
4
+ import { generateId } from "../utils/id.js";
5
+
6
+ const devAuth = new Hono<{ Bindings: Env }>();
7
+
8
+ /**
9
+ * POST /api/dev-auth/login — secret-gated dev login for automated testing.
10
+ * 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).
12
+ */
13
+ devAuth.post("/login", async (c) => {
14
+ const devSecret = c.env.DEV_AUTH_SECRET;
15
+ if (!devSecret || c.env.ENVIRONMENT !== "development") {
16
+ return c.json({ error: "Not found" }, 404);
17
+ }
18
+
19
+ const { secret, userId: requestedUserId } = await c.req.json<{ secret: string; userId?: string }>();
20
+ if (!secret || secret !== devSecret) {
21
+ return c.json({ error: "Forbidden" }, 403);
22
+ }
23
+
24
+ const userId = requestedUserId || "dev-test-user";
25
+ const jwtSecret = getJwtSecret(c.env);
26
+ const token = await createToken(userId, jwtSecret);
27
+
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
+
42
+ return c.json({ token, userId });
43
+ });
44
+
45
+ export { devAuth };
@@ -8,50 +8,85 @@ export const upload = new Hono<{
8
8
  Variables: { userId: string };
9
9
  }>();
10
10
 
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
+
11
33
  /** POST / — Upload a file to R2 and return a signed URL. */
12
34
  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") ?? "";
15
38
 
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
+ }
19
42
 
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;
22
45
 
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
+ }
26
49
 
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 || "";
31
51
 
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
+ }
37
56
 
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
+ }
57
92
  });