botschat 0.1.10 → 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/package.json +14 -1
- package/packages/api/src/do/connection-do.ts +52 -23
- 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 +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/{index-Ev5M8VmV.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 +76 -0
- package/packages/web/src/api.ts +11 -1
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- 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 +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- 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 +20 -8
- 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-DpW6VzZK.js +0 -1497
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
|
|
|
@@ -639,16 +639,11 @@ export class ConnectionDO implements DurableObject {
|
|
|
639
639
|
return null;
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
-
const contentType = response.headers.get("Content-Type") ?? "
|
|
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";
|
|
648
643
|
|
|
649
|
-
// Reject SVG (can contain scripts — XSS vector)
|
|
650
|
-
if (contentType.includes("svg")) {
|
|
651
|
-
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)}`);
|
|
652
647
|
return null;
|
|
653
648
|
}
|
|
654
649
|
|
|
@@ -670,14 +665,18 @@ export class ConnectionDO implements DurableObject {
|
|
|
670
665
|
return null;
|
|
671
666
|
}
|
|
672
667
|
|
|
673
|
-
// Determine extension from Content-Type
|
|
668
|
+
// Determine extension from Content-Type
|
|
674
669
|
const extMap: Record<string, string> = {
|
|
675
670
|
"image/png": "png",
|
|
676
671
|
"image/jpeg": "jpg",
|
|
677
672
|
"image/gif": "gif",
|
|
678
673
|
"image/webp": "webp",
|
|
674
|
+
"application/pdf": "pdf",
|
|
675
|
+
"audio/mpeg": "mp3",
|
|
676
|
+
"audio/wav": "wav",
|
|
677
|
+
"video/mp4": "mp4",
|
|
679
678
|
};
|
|
680
|
-
const ext = extMap[contentType] ?? "png";
|
|
679
|
+
const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
|
|
681
680
|
const key = `media/${userId}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
|
682
681
|
|
|
683
682
|
// Upload to R2
|
|
@@ -685,7 +684,11 @@ export class ConnectionDO implements DurableObject {
|
|
|
685
684
|
httpMetadata: { contentType },
|
|
686
685
|
});
|
|
687
686
|
|
|
688
|
-
|
|
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);
|
|
689
692
|
console.log(`[DO] cacheExternalMedia: OK ${url.slice(0, 80)} → ${localUrl} (${body.byteLength} bytes)`);
|
|
690
693
|
return localUrl;
|
|
691
694
|
} catch (err) {
|
|
@@ -694,6 +697,21 @@ export class ConnectionDO implements DurableObject {
|
|
|
694
697
|
}
|
|
695
698
|
}
|
|
696
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
|
+
|
|
697
715
|
// ---- Message persistence ----
|
|
698
716
|
|
|
699
717
|
private async persistMessage(opts: {
|
|
@@ -785,16 +803,27 @@ export class ConnectionDO implements DurableObject {
|
|
|
785
803
|
}
|
|
786
804
|
}
|
|
787
805
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
+
);
|
|
798
827
|
|
|
799
828
|
return Response.json({ messages, replyCounts });
|
|
800
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
|
});
|
|
@@ -121,6 +121,136 @@ export async function verifyFirebaseIdToken(
|
|
|
121
121
|
return verifyWithKey(matchingKey, signedContent, signatureBytes, payload, projectId);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Google ID Token verification (for native iOS/Android sign-in)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const GOOGLE_OAUTH_JWKS_URL =
|
|
129
|
+
"https://www.googleapis.com/oauth2/v3/certs";
|
|
130
|
+
|
|
131
|
+
let cachedGoogleOAuthKeys: JsonWebKey[] | null = null;
|
|
132
|
+
let cachedGoogleOAuthAt = 0;
|
|
133
|
+
|
|
134
|
+
async function getGoogleOAuthPublicKeys(): Promise<JsonWebKey[]> {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
if (cachedGoogleOAuthKeys && now - cachedGoogleOAuthAt < CACHE_TTL_MS) {
|
|
137
|
+
return cachedGoogleOAuthKeys;
|
|
138
|
+
}
|
|
139
|
+
const resp = await fetch(GOOGLE_OAUTH_JWKS_URL);
|
|
140
|
+
if (!resp.ok) {
|
|
141
|
+
throw new Error(`Failed to fetch Google OAuth JWKS: ${resp.status}`);
|
|
142
|
+
}
|
|
143
|
+
const jwks = (await resp.json()) as { keys: JsonWebKey[] };
|
|
144
|
+
cachedGoogleOAuthKeys = jwks.keys;
|
|
145
|
+
cachedGoogleOAuthAt = now;
|
|
146
|
+
return jwks.keys;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Verify a Google ID token (from native Google Sign-In) and return the payload.
|
|
151
|
+
* Google ID tokens have iss=accounts.google.com and aud=<web-client-id>.
|
|
152
|
+
*/
|
|
153
|
+
export async function verifyGoogleIdToken(
|
|
154
|
+
idToken: string,
|
|
155
|
+
allowedClientIds: string[],
|
|
156
|
+
): Promise<FirebaseTokenPayload> {
|
|
157
|
+
const { header, payload, signatureBytes, signedContent } =
|
|
158
|
+
parseJwtUnverified(idToken);
|
|
159
|
+
|
|
160
|
+
if (header.alg !== "RS256") {
|
|
161
|
+
throw new Error(`Unsupported algorithm: ${header.alg}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Find matching key from Google's OAuth JWKS
|
|
165
|
+
let keys = await getGoogleOAuthPublicKeys();
|
|
166
|
+
let matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
|
|
167
|
+
if (!matchingKey) {
|
|
168
|
+
cachedGoogleOAuthKeys = null;
|
|
169
|
+
keys = await getGoogleOAuthPublicKeys();
|
|
170
|
+
matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
|
|
171
|
+
if (!matchingKey) {
|
|
172
|
+
throw new Error(`No matching Google OAuth key for kid: ${header.kid}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return verifyGoogleTokenWithKey(matchingKey, signedContent, signatureBytes, payload, allowedClientIds);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function verifyGoogleTokenWithKey(
|
|
180
|
+
jwk: JsonWebKey,
|
|
181
|
+
signedContent: string,
|
|
182
|
+
signatureBytes: Uint8Array,
|
|
183
|
+
payload: FirebaseTokenPayload,
|
|
184
|
+
allowedClientIds: string[],
|
|
185
|
+
): Promise<FirebaseTokenPayload> {
|
|
186
|
+
const key = await crypto.subtle.importKey(
|
|
187
|
+
"jwk", jwk,
|
|
188
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
189
|
+
false, ["verify"],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const valid = await crypto.subtle.verify(
|
|
193
|
+
"RSASSA-PKCS1-v1_5", key, signatureBytes,
|
|
194
|
+
new TextEncoder().encode(signedContent),
|
|
195
|
+
);
|
|
196
|
+
if (!valid) throw new Error("Invalid Google token signature");
|
|
197
|
+
|
|
198
|
+
const now = Math.floor(Date.now() / 1000);
|
|
199
|
+
if (payload.exp < now) throw new Error("Google token has expired");
|
|
200
|
+
if (payload.iat > now + 300) throw new Error("Google token issued in the future");
|
|
201
|
+
|
|
202
|
+
// Google ID tokens have iss = "accounts.google.com" or "https://accounts.google.com"
|
|
203
|
+
if (payload.iss !== "accounts.google.com" && payload.iss !== "https://accounts.google.com") {
|
|
204
|
+
throw new Error(`Invalid Google token issuer: ${payload.iss}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Audience must be one of our allowed client IDs
|
|
208
|
+
if (!allowedClientIds.includes(payload.aud)) {
|
|
209
|
+
throw new Error(`Invalid Google token audience: ${payload.aud}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!payload.sub) throw new Error("Missing subject in Google token");
|
|
213
|
+
|
|
214
|
+
// Synthesize firebase-like fields so the rest of the auth flow works
|
|
215
|
+
if (!payload.firebase) {
|
|
216
|
+
payload.firebase = {
|
|
217
|
+
sign_in_provider: "google.com",
|
|
218
|
+
identities: { "google.com": [payload.sub] },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return payload;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Detect whether a token is a Firebase ID token or a Google ID token,
|
|
227
|
+
* and verify accordingly.
|
|
228
|
+
*/
|
|
229
|
+
export async function verifyAnyGoogleToken(
|
|
230
|
+
idToken: string,
|
|
231
|
+
firebaseProjectId: string,
|
|
232
|
+
allowedGoogleClientIds: string[],
|
|
233
|
+
): Promise<FirebaseTokenPayload> {
|
|
234
|
+
// Peek at the issuer to decide which verification path to use
|
|
235
|
+
const { payload: peek } = parseJwtUnverified(idToken);
|
|
236
|
+
|
|
237
|
+
if (peek.iss === `${FIREBASE_TOKEN_ISSUER_PREFIX}${firebaseProjectId}`) {
|
|
238
|
+
// Standard Firebase ID token
|
|
239
|
+
return verifyFirebaseIdToken(idToken, firebaseProjectId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (peek.iss === "accounts.google.com" || peek.iss === "https://accounts.google.com") {
|
|
243
|
+
// Native Google ID token (from iOS/Android)
|
|
244
|
+
return verifyGoogleIdToken(idToken, allowedGoogleClientIds);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
throw new Error(`Unrecognized token issuer: ${peek.iss}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Shared verification helpers
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
124
254
|
async function verifyWithKey(
|
|
125
255
|
jwk: JsonWebKey,
|
|
126
256
|
signedContent: string,
|
|
@@ -22,6 +22,12 @@ export declare const botschatPlugin: {
|
|
|
22
22
|
readonly agentPrompt: {
|
|
23
23
|
readonly messageToolHints: () => string[];
|
|
24
24
|
};
|
|
25
|
+
readonly messaging: {
|
|
26
|
+
readonly targetResolver: {
|
|
27
|
+
readonly hint: "Use the session key (e.g. agent:botschat:botschat:u_xxx:ses:ses_xxx)";
|
|
28
|
+
readonly looksLikeId: (raw: string, _normalized: string) => boolean;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
25
31
|
readonly reload: {
|
|
26
32
|
readonly configPrefixes: readonly ["channels.botschat"];
|
|
27
33
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAuC,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC/F,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAqDrD,eAAO,MAAM,cAAc;;;;;;;;;;;;;4BAeqB,MAAM,EAAE
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
|