botschat 0.1.4 → 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.
- package/README.md +2 -2
- package/package.json +4 -1
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +128 -33
- package/packages/api/src/index.ts +103 -6
- package/packages/api/src/routes/auth.ts +123 -29
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +70 -24
- package/packages/api/src/routes/upload.ts +12 -8
- package/packages/api/src/utils/auth.ts +212 -43
- package/packages/api/src/utils/id.ts +30 -14
- package/packages/api/src/utils/rate-limit.ts +73 -0
- package/packages/plugin/dist/src/channel.js +9 -3
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +2 -2
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-BST9bfvT.css} +1 -1
- package/packages/web/dist/assets/index-Da18EnTa.js +851 -0
- package/packages/web/dist/botschat-icon.svg +4 -0
- package/packages/web/dist/index.html +23 -3
- package/packages/web/dist/manifest.json +24 -0
- package/packages/web/dist/sw.js +40 -0
- package/packages/web/index.html +21 -1
- package/packages/web/src/App.tsx +241 -96
- package/packages/web/src/api.ts +63 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +475 -0
- package/packages/web/src/components/CronDetail.tsx +475 -235
- package/packages/web/src/components/CronSidebar.tsx +1 -1
- package/packages/web/src/components/DebugLogPanel.tsx +116 -3
- package/packages/web/src/components/IconRail.tsx +56 -16
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +126 -103
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +7 -16
- package/packages/web/src/components/ResizeHandle.tsx +34 -0
- package/packages/web/src/components/Sidebar.tsx +1 -1
- package/packages/web/src/components/TaskBar.tsx +2 -2
- package/packages/web/src/components/ThreadPanel.tsx +2 -5
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +9 -0
- package/packages/web/src/store.ts +12 -5
- package/packages/web/src/ws.ts +2 -0
- package/scripts/dev.sh +13 -13
- package/wrangler.toml +3 -1
- package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# BotsChat
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/botschat)
|
|
4
|
-
[](https://www.npmjs.com/package/@botschat/botschat)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
7
|
A self-hosted chat interface for [OpenClaw](https://github.com/openclaw/openclaw) AI agents.
|
|
@@ -154,7 +154,7 @@ After the BotsChat server is running, connect your OpenClaw instance to it.
|
|
|
154
154
|
**1. Install the plugin**
|
|
155
155
|
|
|
156
156
|
```bash
|
|
157
|
-
openclaw plugins install @botschat/
|
|
157
|
+
openclaw plugins install @botschat/botschat
|
|
158
158
|
```
|
|
159
159
|
|
|
160
160
|
**2. Create a pairing token**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botschat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "A self-hosted chat interface for OpenClaw AI agents",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"packages/*"
|
|
@@ -49,5 +49,8 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"typescript": "^5.7.0",
|
|
51
51
|
"wrangler": "^3.100.0"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"react-resizable-panels": "^4.6.2"
|
|
52
55
|
}
|
|
53
56
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Env } from "../env.js";
|
|
2
|
+
import { verifyToken, getJwtSecret } from "../utils/auth.js";
|
|
3
|
+
import { generateId as generateIdUtil } from "../utils/id.js";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* ConnectionDO — one Durable Object instance per BotsChat user.
|
|
@@ -283,10 +285,31 @@ export class ConnectionDO implements DurableObject {
|
|
|
283
285
|
): Promise<void> {
|
|
284
286
|
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
|
|
285
287
|
|
|
286
|
-
// Handle browser auth
|
|
288
|
+
// Handle browser auth — verify JWT token
|
|
287
289
|
if (msg.type === "auth") {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
+
const token = msg.token as string | undefined;
|
|
291
|
+
if (!token) {
|
|
292
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Missing token" }));
|
|
293
|
+
ws.close(4001, "Missing auth token");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const secret = getJwtSecret(this.env);
|
|
298
|
+
const payload = await verifyToken(token, secret);
|
|
299
|
+
if (!payload) {
|
|
300
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Invalid or expired token" }));
|
|
301
|
+
ws.close(4001, "Authentication failed");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Verify the token's userId matches this DO's userId
|
|
306
|
+
const doUserId = await this.state.storage.get<string>("userId");
|
|
307
|
+
if (doUserId && payload.sub !== doUserId) {
|
|
308
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "User mismatch" }));
|
|
309
|
+
ws.close(4001, "User mismatch");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
290
313
|
ws.serializeAttachment({ ...attachment, authenticated: true });
|
|
291
314
|
ws.send(JSON.stringify({ type: "auth.ok" }));
|
|
292
315
|
|
|
@@ -472,6 +495,37 @@ export class ConnectionDO implements DurableObject {
|
|
|
472
495
|
|
|
473
496
|
// ---- Media caching ----
|
|
474
497
|
|
|
498
|
+
// ---- SSRF protection ----
|
|
499
|
+
|
|
500
|
+
/** Check if a URL is safe to fetch (not pointing to private/internal networks). */
|
|
501
|
+
private isUrlSafeToFetch(urlStr: string): boolean {
|
|
502
|
+
try {
|
|
503
|
+
const parsed = new URL(urlStr);
|
|
504
|
+
// Only allow https (block http, ftp, file, etc.)
|
|
505
|
+
if (parsed.protocol !== "https:") return false;
|
|
506
|
+
|
|
507
|
+
const hostname = parsed.hostname;
|
|
508
|
+
// Block private/reserved IP ranges and localhost
|
|
509
|
+
if (
|
|
510
|
+
hostname === "localhost" ||
|
|
511
|
+
hostname === "127.0.0.1" ||
|
|
512
|
+
hostname === "[::1]" ||
|
|
513
|
+
hostname.endsWith(".local") ||
|
|
514
|
+
/^10\./.test(hostname) ||
|
|
515
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
|
516
|
+
/^192\.168\./.test(hostname) ||
|
|
517
|
+
/^169\.254\./.test(hostname) || // link-local
|
|
518
|
+
/^0\./.test(hostname) ||
|
|
519
|
+
hostname === "[::ffff:127.0.0.1]"
|
|
520
|
+
) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
475
529
|
/**
|
|
476
530
|
* Download an external image and cache it in R2. Returns the local
|
|
477
531
|
* API URL (e.g. /api/media/...) or null if caching fails.
|
|
@@ -483,17 +537,28 @@ export class ConnectionDO implements DurableObject {
|
|
|
483
537
|
// Also skip URLs that point back to our own media endpoint (absolute form)
|
|
484
538
|
if (/\/api\/media\//.test(url)) return null;
|
|
485
539
|
|
|
540
|
+
// SSRF protection: only allow HTTPS URLs to public hosts
|
|
541
|
+
if (!this.isUrlSafeToFetch(url)) {
|
|
542
|
+
console.warn(`[DO] cacheExternalMedia: blocked unsafe URL ${url.slice(0, 120)}`);
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
486
546
|
console.log(`[DO] cacheExternalMedia: attempting to cache ${url.slice(0, 120)}`);
|
|
487
547
|
|
|
548
|
+
const MAX_MEDIA_SIZE = 20 * 1024 * 1024; // 20 MB max
|
|
549
|
+
|
|
488
550
|
try {
|
|
489
551
|
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
490
552
|
|
|
491
553
|
// Download the external image — use arrayBuffer to avoid stream issues
|
|
492
554
|
const controller = new AbortController();
|
|
493
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
555
|
+
const timeoutId = setTimeout(() => controller.abort(), 15_000); // 15s timeout
|
|
494
556
|
let response: Response;
|
|
495
557
|
try {
|
|
496
|
-
response = await fetch(url, {
|
|
558
|
+
response = await fetch(url, {
|
|
559
|
+
signal: controller.signal,
|
|
560
|
+
redirect: "follow", // follow redirects, but URL was already validated
|
|
561
|
+
});
|
|
497
562
|
} finally {
|
|
498
563
|
clearTimeout(timeoutId);
|
|
499
564
|
}
|
|
@@ -506,8 +571,21 @@ export class ConnectionDO implements DurableObject {
|
|
|
506
571
|
const contentType = response.headers.get("Content-Type") ?? "image/png";
|
|
507
572
|
// Validate that the response is actually an image
|
|
508
573
|
if (!contentType.startsWith("image/")) {
|
|
509
|
-
console.warn(`[DO] cacheExternalMedia:
|
|
510
|
-
|
|
574
|
+
console.warn(`[DO] cacheExternalMedia: non-image Content-Type "${contentType}", skipping ${url.slice(0, 120)}`);
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Reject SVG (can contain scripts — XSS vector)
|
|
579
|
+
if (contentType.includes("svg")) {
|
|
580
|
+
console.warn(`[DO] cacheExternalMedia: blocked SVG content from ${url.slice(0, 120)}`);
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Check Content-Length header early if available
|
|
585
|
+
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
586
|
+
if (contentLength > MAX_MEDIA_SIZE) {
|
|
587
|
+
console.warn(`[DO] cacheExternalMedia: Content-Length ${contentLength} exceeds limit for ${url.slice(0, 120)}`);
|
|
588
|
+
return null;
|
|
511
589
|
}
|
|
512
590
|
|
|
513
591
|
// Read the body as ArrayBuffer for maximum compatibility with R2
|
|
@@ -516,14 +594,17 @@ export class ConnectionDO implements DurableObject {
|
|
|
516
594
|
console.warn(`[DO] cacheExternalMedia: empty body for ${url.slice(0, 120)}`);
|
|
517
595
|
return null;
|
|
518
596
|
}
|
|
597
|
+
if (body.byteLength > MAX_MEDIA_SIZE) {
|
|
598
|
+
console.warn(`[DO] cacheExternalMedia: body size ${body.byteLength} exceeds limit for ${url.slice(0, 120)}`);
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
519
601
|
|
|
520
|
-
// Determine extension from Content-Type
|
|
602
|
+
// Determine extension from Content-Type (no SVG)
|
|
521
603
|
const extMap: Record<string, string> = {
|
|
522
604
|
"image/png": "png",
|
|
523
605
|
"image/jpeg": "jpg",
|
|
524
606
|
"image/gif": "gif",
|
|
525
607
|
"image/webp": "webp",
|
|
526
|
-
"image/svg+xml": "svg",
|
|
527
608
|
};
|
|
528
609
|
const ext = extMap[contentType] ?? "png";
|
|
529
610
|
const key = `media/${userId}/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${ext}`;
|
|
@@ -853,14 +934,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
853
934
|
return channelId;
|
|
854
935
|
}
|
|
855
936
|
|
|
856
|
-
/** Generate a short random ID (URL-safe). */
|
|
937
|
+
/** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
|
|
857
938
|
private generateId(prefix = ""): string {
|
|
858
|
-
|
|
859
|
-
let id = prefix;
|
|
860
|
-
for (let i = 0; i < 16; i++) {
|
|
861
|
-
id += chars[Math.floor(Math.random() * chars.length)];
|
|
862
|
-
}
|
|
863
|
-
return id;
|
|
939
|
+
return generateIdUtil(prefix);
|
|
864
940
|
}
|
|
865
941
|
|
|
866
942
|
/**
|
|
@@ -913,23 +989,42 @@ export class ConnectionDO implements DurableObject {
|
|
|
913
989
|
}
|
|
914
990
|
|
|
915
991
|
private async validatePairingToken(token: string): Promise<boolean> {
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
// we store validated tokens in DO storage after first validation.
|
|
920
|
-
//
|
|
921
|
-
// Check DO-local cache first:
|
|
922
|
-
const cached = await this.state.storage.get<boolean>(`token:${token}`);
|
|
923
|
-
if (cached === true) return true;
|
|
924
|
-
if (cached === false) return false;
|
|
925
|
-
|
|
926
|
-
// If not cached, we accept the token optimistically and let the
|
|
927
|
-
// API worker validate it on the next REST call. In production,
|
|
928
|
-
// the API worker should validate before routing to the DO.
|
|
992
|
+
// The API worker validates pairing tokens against D1 before routing
|
|
993
|
+
// to the DO (and passes ?verified=1). Connections that arrive here
|
|
994
|
+
// pre-verified are fast-tracked in handleOpenClawMessage.
|
|
929
995
|
//
|
|
930
|
-
// For
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
996
|
+
// For tokens that arrive WITHOUT pre-verification (e.g. direct DO
|
|
997
|
+
// access, which shouldn't happen in normal flow), we validate
|
|
998
|
+
// against D1 ourselves and cache the result with a TTL.
|
|
999
|
+
|
|
1000
|
+
if (!token || !token.startsWith("bc_pat_") || token.length < 20) {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Check DO-local cache first (with 30-second TTL — short to ensure
|
|
1005
|
+
// revoked tokens are invalidated quickly)
|
|
1006
|
+
const cacheKey = `token:${token}`;
|
|
1007
|
+
const cached = await this.state.storage.get<{ valid: boolean; cachedAt: number }>(cacheKey);
|
|
1008
|
+
if (cached) {
|
|
1009
|
+
const ageMs = Date.now() - cached.cachedAt;
|
|
1010
|
+
if (ageMs < 30_000) return cached.valid; // 30-second TTL
|
|
1011
|
+
// Expired — fall through to re-validate
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Validate against D1
|
|
1015
|
+
try {
|
|
1016
|
+
const row = await this.env.DB.prepare(
|
|
1017
|
+
"SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
|
|
1018
|
+
)
|
|
1019
|
+
.bind(token)
|
|
1020
|
+
.first<{ user_id: string }>();
|
|
1021
|
+
|
|
1022
|
+
const isValid = !!row;
|
|
1023
|
+
await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
|
|
1024
|
+
return isValid;
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
console.error("[DO] Failed to validate pairing token against D1:", err);
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
934
1029
|
}
|
|
935
1030
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
3
|
import type { Env } from "./env.js";
|
|
4
|
-
import { authMiddleware } from "./utils/auth.js";
|
|
4
|
+
import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
|
|
5
5
|
import { auth } from "./routes/auth.js";
|
|
6
6
|
import { agents } from "./routes/agents.js";
|
|
7
7
|
import { channels } from "./routes/channels.js";
|
|
@@ -18,12 +18,67 @@ export { ConnectionDO } from "./do/connection-do.js";
|
|
|
18
18
|
|
|
19
19
|
const app = new Hono<{ Bindings: Env }>();
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// Production CORS origins
|
|
22
|
+
const PRODUCTION_ORIGINS = [
|
|
23
|
+
"https://console.botschat.app",
|
|
24
|
+
"https://botschat.app",
|
|
25
|
+
"https://botschat-api.auxtenwpc.workers.dev",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// CORS and security headers — skip for WebSocket upgrade requests
|
|
29
|
+
// (101 responses have immutable headers in Cloudflare Workers)
|
|
30
|
+
const corsMiddleware = cors({
|
|
31
|
+
origin: (origin, c) => {
|
|
32
|
+
if (PRODUCTION_ORIGINS.includes(origin)) return origin;
|
|
33
|
+
// Only allow localhost/private IPs in development
|
|
34
|
+
if ((c as unknown as { env: Env }).env?.ENVIRONMENT === "development") {
|
|
35
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return origin;
|
|
36
|
+
if (/^https?:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return origin;
|
|
37
|
+
}
|
|
38
|
+
return ""; // disallow
|
|
39
|
+
},
|
|
40
|
+
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.use("/*", async (c, next) => {
|
|
44
|
+
// WebSocket upgrades return 101 with immutable headers — skip CORS & security headers
|
|
45
|
+
if (c.req.header("Upgrade")?.toLowerCase() === "websocket") {
|
|
46
|
+
await next();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Apply CORS for regular HTTP requests
|
|
51
|
+
return corsMiddleware(c, next);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Security response headers.
|
|
55
|
+
// In Cloudflare Workers, responses from Durable Objects (stub.fetch()) and
|
|
56
|
+
// subrequests have immutable headers. We clone the response first, then set
|
|
57
|
+
// security headers on the mutable clone. This also makes headers mutable for
|
|
58
|
+
// the CORS middleware which runs AFTER this (registered earlier → runs later
|
|
59
|
+
// in the response phase).
|
|
60
|
+
app.use("/*", async (c, next) => {
|
|
61
|
+
await next();
|
|
62
|
+
if (c.res.status === 101) return; // WebSocket 101 — can't clone
|
|
63
|
+
// Clone to ensure mutable headers
|
|
64
|
+
c.res = new Response(c.res.body, c.res);
|
|
65
|
+
c.res.headers.set(
|
|
66
|
+
"Content-Security-Policy",
|
|
67
|
+
"default-src 'self'; script-src 'self' https://apis.google.com https://*.firebaseapp.com; style-src 'self' 'unsafe-inline'; img-src 'self' https://*.r2.dev https://*.cloudflarestorage.com data: blob:; connect-src 'self' wss://*.botschat.app wss://console.botschat.app https://apis.google.com https://*.googleapis.com; frame-src https://accounts.google.com https://*.firebaseapp.com",
|
|
68
|
+
);
|
|
69
|
+
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
|
70
|
+
c.res.headers.set("X-Frame-Options", "DENY");
|
|
71
|
+
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
72
|
+
});
|
|
23
73
|
|
|
24
74
|
// Health check
|
|
25
75
|
app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
|
|
26
76
|
|
|
77
|
+
// Rate limiting is handled by Cloudflare WAF Rate Limiting Rules (Dashboard).
|
|
78
|
+
// See the security audit for recommended rule configuration.
|
|
79
|
+
// No in-memory rate limiter — it cannot survive Worker isolate restarts
|
|
80
|
+
// and is not shared across instances.
|
|
81
|
+
|
|
27
82
|
// ---- Public routes (no auth) ----
|
|
28
83
|
app.route("/api/auth", auth);
|
|
29
84
|
app.route("/api/setup", setup);
|
|
@@ -196,12 +251,29 @@ protectedApp.route("/channels/:channelId/sessions", sessions);
|
|
|
196
251
|
protectedApp.route("/pairing-tokens", pairing);
|
|
197
252
|
protectedApp.route("/upload", upload);
|
|
198
253
|
|
|
199
|
-
// ---- Media serving route (
|
|
254
|
+
// ---- Media serving route (signed URL or Bearer auth) ----
|
|
200
255
|
app.get("/api/media/:userId/:filename", async (c) => {
|
|
201
256
|
const userId = c.req.param("userId");
|
|
202
257
|
const filename = c.req.param("filename");
|
|
203
|
-
const key = `media/${userId}/${filename}`;
|
|
204
258
|
|
|
259
|
+
// Verify access: either a valid signed URL or a valid Bearer token
|
|
260
|
+
const expires = c.req.query("expires");
|
|
261
|
+
const sig = c.req.query("sig");
|
|
262
|
+
const secret = getJwtSecret(c.env);
|
|
263
|
+
|
|
264
|
+
if (expires && sig) {
|
|
265
|
+
// Signed URL verification
|
|
266
|
+
const valid = await verifyMediaSignature(userId, filename, expires, sig, secret);
|
|
267
|
+
if (!valid) {
|
|
268
|
+
return c.json({ error: "Invalid or expired media signature" }, 403);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
// Fall back to Bearer token auth
|
|
272
|
+
const denied = await verifyUserAccess(c, userId);
|
|
273
|
+
if (denied) return denied;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const key = `media/${userId}/${filename}`;
|
|
205
277
|
const object = await c.env.MEDIA.get(key);
|
|
206
278
|
if (!object) {
|
|
207
279
|
return c.json({ error: "Not found" }, 404);
|
|
@@ -209,11 +281,29 @@ app.get("/api/media/:userId/:filename", async (c) => {
|
|
|
209
281
|
|
|
210
282
|
const headers = new Headers();
|
|
211
283
|
headers.set("Content-Type", object.httpMetadata?.contentType ?? "application/octet-stream");
|
|
212
|
-
headers.set("Cache-Control", "public, max-age=
|
|
284
|
+
headers.set("Cache-Control", "public, max-age=3600"); // 1h cache (matches signature expiry)
|
|
213
285
|
|
|
214
286
|
return new Response(object.body, { headers });
|
|
215
287
|
});
|
|
216
288
|
|
|
289
|
+
// ---- Helper: verify JWT and ensure userId matches ----
|
|
290
|
+
async function verifyUserAccess(c: { req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined }; env: Env; json: (data: unknown, status?: number) => Response }, userId: string): Promise<Response | null> {
|
|
291
|
+
const authHeader = c.req.header("Authorization");
|
|
292
|
+
const tokenStr = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : c.req.query("token");
|
|
293
|
+
if (!tokenStr) {
|
|
294
|
+
return c.json({ error: "Missing Authorization header or token query param" }, 401);
|
|
295
|
+
}
|
|
296
|
+
const secret = getJwtSecret(c.env);
|
|
297
|
+
const payload = await verifyToken(tokenStr, secret);
|
|
298
|
+
if (!payload) {
|
|
299
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
300
|
+
}
|
|
301
|
+
if (payload.sub !== userId) {
|
|
302
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
303
|
+
}
|
|
304
|
+
return null; // access granted
|
|
305
|
+
}
|
|
306
|
+
|
|
217
307
|
// ---- WebSocket upgrade routes (BEFORE protected middleware) ----
|
|
218
308
|
|
|
219
309
|
// OpenClaw plugin connects to: /api/gateway/:connId
|
|
@@ -268,6 +358,9 @@ app.all("/api/gateway/:connId", async (c) => {
|
|
|
268
358
|
});
|
|
269
359
|
|
|
270
360
|
// Browser client connects to: /api/ws/:userId/:sessionId
|
|
361
|
+
// Auth is handled entirely inside the DO via the "auth" message after
|
|
362
|
+
// the WebSocket connection is established. This avoids putting the JWT
|
|
363
|
+
// in the URL query string (which would leak it in logs/browser history).
|
|
271
364
|
app.all("/api/ws/:userId/:sessionId", async (c) => {
|
|
272
365
|
const userId = c.req.param("userId");
|
|
273
366
|
const sessionId = c.req.param("sessionId");
|
|
@@ -281,6 +374,8 @@ app.all("/api/ws/:userId/:sessionId", async (c) => {
|
|
|
281
374
|
// Connection status: /api/connection/:userId/status
|
|
282
375
|
app.get("/api/connection/:userId/status", async (c) => {
|
|
283
376
|
const userId = c.req.param("userId");
|
|
377
|
+
const denied = await verifyUserAccess(c, userId);
|
|
378
|
+
if (denied) return denied;
|
|
284
379
|
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
285
380
|
const stub = c.env.CONNECTION_DO.get(doId);
|
|
286
381
|
const url = new URL(c.req.url);
|
|
@@ -291,6 +386,8 @@ app.get("/api/connection/:userId/status", async (c) => {
|
|
|
291
386
|
// Message history: /api/messages/:userId?sessionKey=xxx
|
|
292
387
|
app.get("/api/messages/:userId", async (c) => {
|
|
293
388
|
const userId = c.req.param("userId");
|
|
389
|
+
const denied = await verifyUserAccess(c, userId);
|
|
390
|
+
if (denied) return denied;
|
|
294
391
|
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
295
392
|
const stub = c.env.CONNECTION_DO.get(doId);
|
|
296
393
|
const url = new URL(c.req.url);
|