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.
- package/README.md +10 -0
- package/package.json +14 -1
- package/packages/api/src/do/connection-do.ts +105 -28
- package/packages/api/src/env.ts +4 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/routes/auth.ts +46 -3
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/firebase.ts +130 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +97 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +6 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/{index-B1sFqYiM.css → index-CCBhODDo.css} +1 -1
- package/packages/web/dist/assets/index-CCFgKLX_.js +1 -0
- package/packages/web/dist/assets/index-D8mBAwjS.js +1516 -0
- package/packages/web/dist/assets/index-Dx64BDkP.js +1 -0
- package/packages/web/dist/assets/index-E-nzPZl8.js +2 -0
- package/packages/web/dist/assets/web-DJQW-VLX.js +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/package.json +4 -1
- package/packages/web/src/App.tsx +78 -3
- package/packages/web/src/api.ts +12 -2
- package/packages/web/src/components/ChatWindow.tsx +410 -87
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/LoginPage.tsx +9 -1
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +179 -23
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/components/ThreadPanel.tsx +153 -12
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +127 -2
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +23 -2
- package/packages/web/src/ws.ts +44 -12
- package/scripts/dev.sh +28 -22
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- 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.
|
|
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
|
-
|
|
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
|
|
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)
|
|
545
|
+
if (att?.authenticated) newest = s;
|
|
498
546
|
}
|
|
499
|
-
return sockets[
|
|
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") ?? "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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) {
|
package/packages/api/src/env.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
35
|
+
try {
|
|
36
|
+
const userId = c.get("userId");
|
|
37
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
15
38
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
39
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
40
|
+
return c.json({ error: "Expected multipart/form-data" }, 400);
|
|
41
|
+
}
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
const formData = await c.req.formData();
|
|
44
|
+
const file = formData.get("file") as File | null;
|
|
22
45
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
if (!file) {
|
|
47
|
+
return c.json({ error: "No file provided" }, 400);
|
|
48
|
+
}
|
|
26
49
|
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
});
|