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