agent-loft 0.2.21 → 0.2.23
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 +24 -13
- package/bin/cli.js +4 -2
- package/package.json +1 -1
- package/src/agents/claude/session.ts +1 -1
- package/src/agents/codex/session.ts +1 -0
- package/src/channels/feishu/bot.ts +22 -8
- package/src/channels/web/access.ts +26 -0
- package/src/channels/web/api.ts +176 -42
- package/src/channels/web/auth.ts +92 -38
- package/src/channels/web/push.ts +30 -8
- package/src/channels/web/server.ts +50 -17
- package/src/cli/doctor.ts +2 -1
- package/src/cli/passwd.ts +24 -7
- package/src/cli/user.ts +99 -0
- package/src/core/command-catalog.ts +11 -7
- package/src/core/commands.ts +1 -1
- package/src/core/hub.ts +49 -8
- package/src/core/store.ts +33 -0
- package/src/core/types.ts +5 -0
- package/src/core/uploads.ts +12 -0
- package/src/core/users.ts +261 -0
- package/web/app.js +238 -43
package/src/channels/web/api.ts
CHANGED
|
@@ -9,7 +9,19 @@ import { hub } from "../../core/hub.js";
|
|
|
9
9
|
import { interactions } from "../../core/interactions.js";
|
|
10
10
|
import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
|
|
11
11
|
import { getConversation, updateConversation } from "../../core/store.js";
|
|
12
|
-
import { resolveUploadFile, saveBuffer, sniff } from "../../core/uploads.js";
|
|
12
|
+
import { resolveUploadFile, saveBuffer, sniff, uploadKeyOf } from "../../core/uploads.js";
|
|
13
|
+
import {
|
|
14
|
+
createUser,
|
|
15
|
+
getUser,
|
|
16
|
+
isAdmin,
|
|
17
|
+
legacyOwnerId,
|
|
18
|
+
listUsers,
|
|
19
|
+
removeUser,
|
|
20
|
+
verifyLogin,
|
|
21
|
+
verifyPassword,
|
|
22
|
+
type Role,
|
|
23
|
+
type User,
|
|
24
|
+
} from "../../core/users.js";
|
|
13
25
|
import type { SavedAttachment } from "../../core/types.js";
|
|
14
26
|
import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
|
|
15
27
|
import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
|
|
@@ -23,16 +35,19 @@ import {
|
|
|
23
35
|
clearAttempts,
|
|
24
36
|
createSession,
|
|
25
37
|
destroySession,
|
|
26
|
-
hasPassword,
|
|
27
38
|
listSessions as listWebSessions,
|
|
28
|
-
|
|
39
|
+
needsSetup,
|
|
29
40
|
revokeSession,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
revokeUserSessions,
|
|
42
|
+
sessionUser,
|
|
43
|
+
patchUser,
|
|
44
|
+
setUserPassword,
|
|
45
|
+
setupFirstAdmin,
|
|
46
|
+
takeAttempt,
|
|
33
47
|
} from "./auth.js";
|
|
48
|
+
import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
|
|
34
49
|
import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
35
|
-
import { addSubscription, listSubscriptions, pushToAll, removeSubscription, vapidPublicKey } from "./push.js";
|
|
50
|
+
import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
|
|
36
51
|
|
|
37
52
|
export const COOKIE = "aim_session";
|
|
38
53
|
const startedAt = Date.now();
|
|
@@ -67,9 +82,13 @@ setInterval(() => {
|
|
|
67
82
|
*/
|
|
68
83
|
const INLINE_IMAGE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
69
84
|
|
|
70
|
-
function serveUpload(res: ServerResponse, requested: string, download: boolean): void {
|
|
85
|
+
function serveUpload(res: ServerResponse, user: User, requested: string, download: boolean): void {
|
|
71
86
|
const file = requested ? resolveUploadFile(requested) : undefined;
|
|
72
|
-
|
|
87
|
+
// 只能看自己会话里的附件。会话已经没了的,只有管理员能看
|
|
88
|
+
const key = file ? uploadKeyOf(file) : undefined;
|
|
89
|
+
const conv = key ? getConversation(key) : undefined;
|
|
90
|
+
const allowed = conv ? canSeeConv(user, conv) : isAdmin(user);
|
|
91
|
+
if (!file || !allowed) throw new HttpError(404, "文件不存在(可能会话重置时已清理)");
|
|
73
92
|
const head = Buffer.alloc(12);
|
|
74
93
|
const fd = fs.openSync(file, "r");
|
|
75
94
|
try {
|
|
@@ -153,7 +172,7 @@ function publicConfig(): unknown {
|
|
|
153
172
|
const cfg = structuredClone(config()) as AppConfig & { web: { passwordHash?: string } };
|
|
154
173
|
return {
|
|
155
174
|
...cfg,
|
|
156
|
-
web: { ...cfg.web, passwordHash: undefined
|
|
175
|
+
web: { ...cfg.web, passwordHash: undefined },
|
|
157
176
|
feishu: { ...cfg.feishu, bots: cfg.feishu.bots.map((b) => ({ ...b, appSecret: maskSecret(b.appSecret) })) },
|
|
158
177
|
};
|
|
159
178
|
}
|
|
@@ -268,7 +287,46 @@ function setCookie(req: IncomingMessage, res: ServerResponse, token: string, max
|
|
|
268
287
|
}
|
|
269
288
|
|
|
270
289
|
export function authed(req: IncomingMessage): boolean {
|
|
271
|
-
return !!
|
|
290
|
+
return !!currentUser(req);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 这个请求是哪个用户的(没登录 / 登录态失效返回 undefined) */
|
|
294
|
+
export function currentUser(req: IncomingMessage): User | undefined {
|
|
295
|
+
return sessionUser(cookies(req)[COOKIE]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function newSession(req: IncomingMessage, res: ServerResponse, userId: string): void {
|
|
299
|
+
setCookie(req, res, createSession(userId, clientIp(req), String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 只有管理员能用的接口:所有设置(Agent、飞书、配对、总是允许名单、网络、升级、重启)和用户管理。
|
|
304
|
+
* 写在一处,方便核对。普通成员只能用自己的会话、通知、密码和登录设备。
|
|
305
|
+
*/
|
|
306
|
+
function adminOnly(p: string): boolean {
|
|
307
|
+
return (
|
|
308
|
+
p === "/api/config" ||
|
|
309
|
+
p === "/api/server/upgrade" ||
|
|
310
|
+
p === "/api/agents/codex/install" ||
|
|
311
|
+
p.startsWith("/api/pairing") ||
|
|
312
|
+
p.startsWith("/api/claude/") ||
|
|
313
|
+
p.startsWith("/api/feishu/") ||
|
|
314
|
+
p.startsWith("/api/system") ||
|
|
315
|
+
p.startsWith("/api/users")
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function publicMe(user: User) {
|
|
320
|
+
return { id: user.id, name: user.name, role: user.role };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** users.ts 抛的是给人看的错误,转成 400 */
|
|
324
|
+
function userOp<T>(fn: () => T): T {
|
|
325
|
+
try {
|
|
326
|
+
return fn();
|
|
327
|
+
} catch (err) {
|
|
328
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
329
|
+
}
|
|
272
330
|
}
|
|
273
331
|
|
|
274
332
|
// ---------- 路由 ----------
|
|
@@ -297,32 +355,38 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
297
355
|
|
|
298
356
|
// ----- 无需登录 -----
|
|
299
357
|
if (p === "/api/me" && method === "GET") {
|
|
300
|
-
|
|
358
|
+
const user = currentUser(req);
|
|
359
|
+
sendJson(res, 200, {
|
|
360
|
+
authed: !!user,
|
|
361
|
+
needsSetup: needsSetup(),
|
|
362
|
+
// 不止一个人能登录:登录页要填用户名
|
|
363
|
+
multiUser: listUsers().filter((u) => !u.disabled).length > 1,
|
|
364
|
+
...(user ? { user: publicMe(user) } : {}),
|
|
365
|
+
});
|
|
301
366
|
return;
|
|
302
367
|
}
|
|
303
368
|
if (p === "/api/setup" && method === "POST") {
|
|
304
|
-
if (
|
|
305
|
-
const body = await readJsonBody<{ token?: string; password?: string }>(req);
|
|
369
|
+
if (!needsSetup()) throw new HttpError(400, "已经设置过了,直接登录");
|
|
370
|
+
const body = await readJsonBody<{ token?: string; name?: string; password?: string }>(req);
|
|
306
371
|
const left = takeAttempt(ip);
|
|
307
372
|
if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
|
|
308
373
|
if (!checkSetupToken(String(body.token ?? ""))) {
|
|
309
374
|
throw new HttpError(403, "设置码不对。它打印在 agent-loft 的启动日志里");
|
|
310
375
|
}
|
|
311
|
-
|
|
376
|
+
const user = userOp(() => setupFirstAdmin(String(body.name ?? ""), String(body.password ?? "")));
|
|
312
377
|
clearAttempts(ip);
|
|
313
|
-
|
|
378
|
+
newSession(req, res, user.id);
|
|
314
379
|
sendJson(res, 200, { ok: true });
|
|
315
380
|
return;
|
|
316
381
|
}
|
|
317
382
|
if (p === "/api/login" && method === "POST") {
|
|
318
|
-
const body = await readJsonBody<{ password?: string }>(req);
|
|
383
|
+
const body = await readJsonBody<{ name?: string; password?: string }>(req);
|
|
319
384
|
const left = takeAttempt(ip);
|
|
320
385
|
if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
386
|
+
const user = verifyLogin(typeof body.name === "string" ? body.name : undefined, String(body.password ?? ""));
|
|
387
|
+
if (!user) throw new HttpError(403, listUsers().filter((u) => !u.disabled).length > 1 ? "用户名或密码不对" : "密码不对");
|
|
324
388
|
clearAttempts(ip);
|
|
325
|
-
|
|
389
|
+
newSession(req, res, user.id);
|
|
326
390
|
sendJson(res, 200, { ok: true });
|
|
327
391
|
return;
|
|
328
392
|
}
|
|
@@ -333,11 +397,15 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
333
397
|
return;
|
|
334
398
|
}
|
|
335
399
|
|
|
336
|
-
|
|
400
|
+
const user = currentUser(req);
|
|
401
|
+
if (!user) throw new HttpError(401, "请先登录");
|
|
402
|
+
if (adminOnly(p) && !isAdmin(user)) throw new HttpError(403, "只有管理员能做这个操作");
|
|
403
|
+
const admin = isAdmin(user);
|
|
404
|
+
const visible = (c: Parameters<typeof canSeeConv>[1]) => canSeeConv(user, c);
|
|
337
405
|
|
|
338
406
|
// ----- 附件预览 / 下载:只给上传目录里的文件 -----
|
|
339
407
|
if (p === "/api/file" && method === "GET") {
|
|
340
|
-
serveUpload(res, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
|
|
408
|
+
serveUpload(res, user, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
|
|
341
409
|
return;
|
|
342
410
|
}
|
|
343
411
|
|
|
@@ -357,11 +425,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
357
425
|
...(agentAvailability(id) ?? {}),
|
|
358
426
|
};
|
|
359
427
|
}),
|
|
360
|
-
|
|
428
|
+
// 飞书机器人是管理员配的,飞书会话也只给管理员看
|
|
429
|
+
bots: admin ? config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })) : [],
|
|
361
430
|
workspaceRoot: config().workspaceRoot,
|
|
362
431
|
quotaRefreshMin: config().web.quotaRefreshMin,
|
|
363
|
-
conversations: hub.list(),
|
|
364
|
-
pending: interactions.listPending(),
|
|
432
|
+
conversations: hub.list(visible),
|
|
433
|
+
pending: interactions.listPending().filter((i) => visible(getConversation(i.convKey))),
|
|
434
|
+
user: publicMe(user),
|
|
365
435
|
serverTime: Date.now(),
|
|
366
436
|
});
|
|
367
437
|
return;
|
|
@@ -369,13 +439,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
369
439
|
|
|
370
440
|
// ----- 系统通知(Web Push) -----
|
|
371
441
|
if (p === "/api/push" && method === "GET") {
|
|
372
|
-
sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions() });
|
|
442
|
+
sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions(pushOwnedBy(user.id)) });
|
|
373
443
|
return;
|
|
374
444
|
}
|
|
375
445
|
if (p === "/api/push/subscribe" && method === "POST") {
|
|
376
446
|
const body = await readJsonBody<{ subscription?: unknown }>(req);
|
|
377
447
|
try {
|
|
378
|
-
const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""));
|
|
448
|
+
const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""), user.id);
|
|
379
449
|
sendJson(res, 200, { ok: true, label: record.label });
|
|
380
450
|
} catch (err) {
|
|
381
451
|
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
@@ -384,11 +454,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
384
454
|
}
|
|
385
455
|
if (p === "/api/push/unsubscribe" && method === "POST") {
|
|
386
456
|
const body = await readJsonBody<{ endpoint?: string }>(req);
|
|
387
|
-
sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? "")) });
|
|
457
|
+
sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? ""), pushOwnedBy(user.id)) });
|
|
388
458
|
return;
|
|
389
459
|
}
|
|
390
460
|
if (p === "/api/push/test" && method === "POST") {
|
|
391
|
-
const sent = await pushToAll(
|
|
461
|
+
const sent = await pushToAll(
|
|
462
|
+
{ title: "Agent Loft", body: "测试通知:能看到这条,说明通知已经通了。", tag: "test", url: "/" },
|
|
463
|
+
pushOwnedBy(user.id),
|
|
464
|
+
);
|
|
392
465
|
if (sent === 0) throw new HttpError(502, "没有推送成功:还没有设备开启通知,或推送服务拒绝了(看服务日志)");
|
|
393
466
|
sendJson(res, 200, { sent });
|
|
394
467
|
return;
|
|
@@ -404,7 +477,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
404
477
|
|
|
405
478
|
// ----- 会话 -----
|
|
406
479
|
if (p === "/api/conversations" && method === "GET") {
|
|
407
|
-
sendJson(res, 200, hub.list());
|
|
480
|
+
sendJson(res, 200, hub.list(visible));
|
|
408
481
|
return;
|
|
409
482
|
}
|
|
410
483
|
if (p === "/api/conversations" && method === "POST") {
|
|
@@ -419,7 +492,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
419
492
|
if (!cwd) throw new HttpError(400, `目录必须存在,且在 ${config().workspaceRoot} 之内`);
|
|
420
493
|
}
|
|
421
494
|
const key = `web:${crypto.randomBytes(6).toString("hex")}`;
|
|
422
|
-
hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent });
|
|
495
|
+
hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent, owner: user.id });
|
|
423
496
|
if (cwd && cwd !== config().workspaceRoot) updateConversation(key, { cwd, cwdExplicit: true });
|
|
424
497
|
sendJson(res, 200, hub.info(key));
|
|
425
498
|
return;
|
|
@@ -430,6 +503,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
430
503
|
const key = decodeURIComponent(convMatch[1]!);
|
|
431
504
|
const sub = convMatch[2];
|
|
432
505
|
const conv = conversationOr404(key);
|
|
506
|
+
// 别人的会话:当它不存在
|
|
507
|
+
if (!visible(conv)) throw new HttpError(404, "会话不存在");
|
|
433
508
|
|
|
434
509
|
if (!sub && method === "PATCH") {
|
|
435
510
|
const body = await readJsonBody<{ title?: string; hidden?: boolean }>(req);
|
|
@@ -514,12 +589,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
514
589
|
if (sub === "fork" && method === "POST") {
|
|
515
590
|
const body = await readJsonBody<{ anchor?: string }>(req);
|
|
516
591
|
const anchor = typeof body.anchor === "string" && body.anchor ? body.anchor : undefined;
|
|
517
|
-
const newKey = await hub.forkConversation(key, anchor);
|
|
592
|
+
const newKey = await hub.forkConversation(key, anchor, user.id);
|
|
518
593
|
sendJson(res, 200, { info: hub.info(newKey) });
|
|
519
594
|
return;
|
|
520
595
|
}
|
|
521
596
|
if (sub === "feishu" && method === "POST") {
|
|
522
|
-
// 在飞书继续:把选中的飞书聊天挂到这条 Web
|
|
597
|
+
// 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上。飞书机器人是管理员的
|
|
598
|
+
if (!admin) throw new HttpError(403, "只有管理员能用飞书机器人");
|
|
523
599
|
if (conv.channel !== "web" || conv.hidden) throw new HttpError(400, "只有 Web 会话能到飞书继续");
|
|
524
600
|
const body = await readJsonBody<{ botId?: string; chatId?: string }>(req);
|
|
525
601
|
const bot = feishuBots().find((b) => b.id === body.botId);
|
|
@@ -533,7 +609,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
533
609
|
return;
|
|
534
610
|
}
|
|
535
611
|
if (sub === "sessions" && method === "GET") {
|
|
536
|
-
sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1"));
|
|
612
|
+
sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1", sessionVisibleTo(user)));
|
|
537
613
|
return;
|
|
538
614
|
}
|
|
539
615
|
if (sub === "models" && method === "GET") {
|
|
@@ -556,7 +632,9 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
556
632
|
text = await hub.fork(key);
|
|
557
633
|
break;
|
|
558
634
|
case "resume":
|
|
559
|
-
text =
|
|
635
|
+
text = (sessionVisibleTo(user)?.(value) ?? true)
|
|
636
|
+
? await hub.resume(key, value, body.cwd)
|
|
637
|
+
: `找不到会话 ${value}。\n发 /sessions 看看有哪些可恢复的。`;
|
|
560
638
|
break;
|
|
561
639
|
case "model":
|
|
562
640
|
text = await hub.setModel(key, value);
|
|
@@ -662,7 +740,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
662
740
|
sendJson(res, 404, { error: `没有 agent「${m[1]}」` });
|
|
663
741
|
return;
|
|
664
742
|
}
|
|
665
|
-
const
|
|
743
|
+
const found = getConversation(url.searchParams.get("key") ?? "");
|
|
744
|
+
const conv = found && visible(found) ? found : undefined;
|
|
666
745
|
const commands = await hub.commandCatalog(m[1]!, {
|
|
667
746
|
...(conv ? { cwd: conv.cwd } : {}),
|
|
668
747
|
allowedAgents: hub.enabledAgents().map((d) => d.id),
|
|
@@ -674,25 +753,80 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
674
753
|
}
|
|
675
754
|
}
|
|
676
755
|
if (p === "/api/password" && method === "POST") {
|
|
756
|
+
// 改自己的密码:自己所有设备下线,当前这台换一个新的登录态
|
|
677
757
|
const body = await readJsonBody<{ old?: string; password?: string }>(req);
|
|
678
758
|
const left = takeAttempt(ip);
|
|
679
759
|
if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
|
|
680
|
-
if (!verifyPassword(String(body.old ?? ""),
|
|
760
|
+
if (!verifyPassword(String(body.old ?? ""), user.passwordHash)) throw new HttpError(403, "原密码不对");
|
|
681
761
|
clearAttempts(ip);
|
|
682
|
-
|
|
683
|
-
|
|
762
|
+
userOp(() => setUserPassword(user.id, String(body.password ?? "")));
|
|
763
|
+
newSession(req, res, user.id);
|
|
684
764
|
sendJson(res, 200, { ok: true });
|
|
685
765
|
return;
|
|
686
766
|
}
|
|
687
767
|
if (p === "/api/web-sessions" && method === "GET") {
|
|
688
|
-
|
|
768
|
+
// 管理员看所有人的设备,成员只看自己的
|
|
769
|
+
sendJson(res, 200, listWebSessions(admin ? undefined : user.id));
|
|
689
770
|
return;
|
|
690
771
|
}
|
|
691
772
|
if (p === "/api/web-sessions/revoke" && method === "POST") {
|
|
692
773
|
const body = await readJsonBody<{ id?: string }>(req);
|
|
693
|
-
sendJson(res, 200, { ok: revokeSession(String(body.id ?? "")) });
|
|
774
|
+
sendJson(res, 200, { ok: revokeSession(String(body.id ?? ""), admin ? undefined : user.id) });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ----- 用户管理(管理员) -----
|
|
779
|
+
if (p === "/api/users" && method === "GET") {
|
|
780
|
+
const devices = listWebSessions();
|
|
781
|
+
sendJson(res, 200, {
|
|
782
|
+
users: listUsers().map((u) => ({ ...u, devices: devices.filter((d) => d.userId === u.id).length })),
|
|
783
|
+
me: user.id,
|
|
784
|
+
});
|
|
694
785
|
return;
|
|
695
786
|
}
|
|
787
|
+
if (p === "/api/users" && method === "POST") {
|
|
788
|
+
const body = await readJsonBody<{ name?: string; password?: string; role?: Role }>(req);
|
|
789
|
+
sendJson(
|
|
790
|
+
res,
|
|
791
|
+
200,
|
|
792
|
+
userOp(() => createUser({ name: String(body.name ?? ""), password: String(body.password ?? ""), role: body.role === "admin" ? "admin" : "member" })),
|
|
793
|
+
);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const userMatch = /^\/api\/users\/([A-Za-z0-9_]+)$/.exec(p);
|
|
797
|
+
if (userMatch) {
|
|
798
|
+
const id = userMatch[1]!;
|
|
799
|
+
if (!getUser(id)) throw new HttpError(404, "用户不存在");
|
|
800
|
+
if (method === "PATCH") {
|
|
801
|
+
const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string }>(req);
|
|
802
|
+
if (id === user.id && (body.disabled === true || (body.role !== undefined && body.role !== "admin"))) {
|
|
803
|
+
throw new HttpError(400, "不能停用自己,也不能取消自己的管理员");
|
|
804
|
+
}
|
|
805
|
+
// 一次改完:密码不合格时别的也不改。重置密码 / 停用会让他的设备全部下线
|
|
806
|
+
const updated = userOp(() =>
|
|
807
|
+
patchUser(id, {
|
|
808
|
+
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
809
|
+
...(body.role === "admin" || body.role === "member" ? { role: body.role } : {}),
|
|
810
|
+
...(typeof body.disabled === "boolean" ? { disabled: body.disabled } : {}),
|
|
811
|
+
...(typeof body.password === "string" ? { password: body.password } : {}),
|
|
812
|
+
}),
|
|
813
|
+
);
|
|
814
|
+
// 管理员在用户列表里重置了自己的密码:当前这台换个新登录态,别把自己踢出去
|
|
815
|
+
if (id === user.id && typeof body.password === "string") newSession(req, res, user.id);
|
|
816
|
+
sendJson(res, 200, updated);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (method === "DELETE") {
|
|
820
|
+
if (id === user.id) throw new HttpError(400, "不能删除自己");
|
|
821
|
+
// 升级前没记主人的推送订阅算在最早的管理员名下:删人之前先记下是谁
|
|
822
|
+
const legacy = legacyOwnerId();
|
|
823
|
+
const removed = userOp(() => removeUser(id));
|
|
824
|
+
removeSubscriptionsOf((owner) => (owner ?? legacy) === id);
|
|
825
|
+
revokeUserSessions(id);
|
|
826
|
+
sendJson(res, 200, removed);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
696
830
|
if (p === "/api/pairing" && method === "GET") {
|
|
697
831
|
sendJson(res, 200, { pending: listPending(), approved: listApproved() });
|
|
698
832
|
return;
|
package/src/channels/web/auth.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Web 登录鉴权。
|
|
2
2
|
//
|
|
3
3
|
// 这个页面能让 agent 在这台机器上执行命令,等同于一个远程 shell,所以:
|
|
4
|
-
// - 密码用 scrypt
|
|
5
|
-
// -
|
|
4
|
+
// - 密码用 scrypt 哈希存,不存明文(账号见 core/users.ts);
|
|
5
|
+
// - 一个用户都没有时,必须拿终端 / 日志里打印的一次性设置码才能建第一个管理员
|
|
6
6
|
// (否则谁先打开页面谁就成了主人);
|
|
7
7
|
// - 登录态是随机 token,服务端只存它的哈希,HttpOnly cookie;
|
|
8
8
|
// - 同一 IP 连续输错会被锁一段时间。
|
|
@@ -11,16 +11,20 @@ import fs from "node:fs";
|
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { config, saveConfig } from "../../core/config.js";
|
|
14
|
+
import { createUser, getUser, hasUsers, legacyOwnerId, updateUser, userEvents, type PublicUser, type User } from "../../core/users.js";
|
|
14
15
|
import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
|
|
15
16
|
|
|
16
17
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** 登录态被撤销(退出、改密码、踢设备、用户被停用 / 删除)时通知 WebSocket 那边立刻断开对应连接 */
|
|
19
20
|
export const authEvents = new EventEmitter<{ revoked: [] }>();
|
|
21
|
+
userEvents.on("changed", () => authEvents.emit("revoked"));
|
|
20
22
|
const sessionsPath = path.join(dataDir, "web-sessions.json");
|
|
21
23
|
const setupTokenPath = path.join(dataDir, "web-setup-token");
|
|
22
24
|
|
|
23
25
|
export type WebSession = {
|
|
26
|
+
/** 升级前的登录态没有这个字段:读的时候认给迁移出来的那个管理员(legacyOwnerId) */
|
|
27
|
+
userId?: string;
|
|
24
28
|
createdAt: number;
|
|
25
29
|
expiresAt: number;
|
|
26
30
|
lastSeen: number;
|
|
@@ -47,7 +51,19 @@ function loadSessions(): Record<string, WebSession> {
|
|
|
47
51
|
loadedMtime = mtime;
|
|
48
52
|
}
|
|
49
53
|
const now = Date.now();
|
|
50
|
-
|
|
54
|
+
let stamped = false;
|
|
55
|
+
for (const [id, s] of Object.entries(sessions)) {
|
|
56
|
+
if (s.expiresAt < now) delete sessions[id];
|
|
57
|
+
else if (!s.userId) {
|
|
58
|
+
// 升级前的登录态:认给迁移出来的管理员,并且记下来,之后那个管理员被删了也不会换人
|
|
59
|
+
const owner = legacyOwnerId();
|
|
60
|
+
if (owner) {
|
|
61
|
+
s.userId = owner;
|
|
62
|
+
stamped = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (stamped) saveSessions();
|
|
51
67
|
return sessions;
|
|
52
68
|
}
|
|
53
69
|
|
|
@@ -60,43 +76,34 @@ function sha256(value: string): string {
|
|
|
60
76
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
export function hashPassword(password: string): string {
|
|
66
|
-
const salt = crypto.randomBytes(16);
|
|
67
|
-
const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
68
|
-
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
|
79
|
+
function ownerOf(s: WebSession): string | undefined {
|
|
80
|
+
return s.userId;
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
|
|
72
|
-
const [scheme, saltHex, keyHex] = stored.split("$");
|
|
73
|
-
if (scheme !== "scrypt" || !saltHex || !keyHex) return false;
|
|
74
|
-
const expected = Buffer.from(keyHex, "hex");
|
|
75
|
-
const actual = crypto.scryptSync(password, Buffer.from(saltHex, "hex"), expected.length, { N: 16384, r: 8, p: 1 });
|
|
76
|
-
return crypto.timingSafeEqual(expected, actual);
|
|
77
|
-
}
|
|
83
|
+
// ---------- 首次设置 ----------
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/** 还一个用户都没有:页面上要先建管理员 */
|
|
86
|
+
export function needsSetup(): boolean {
|
|
87
|
+
return !hasUsers();
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
/**
|
|
91
|
+
* 建第一个管理员(页面上用设置码,或终端里 agent-loft passwd)。
|
|
92
|
+
* 之前残留的登录态全部作废:否则它们没记用户,会被当成升级前的登录态认给这个新管理员。
|
|
93
|
+
*/
|
|
94
|
+
export function setupFirstAdmin(name: string, password: string): PublicUser {
|
|
95
|
+
if (hasUsers()) throw new Error("已经设置过了");
|
|
96
|
+
const user = createUser({ name: name.trim() || "admin", password, role: "admin" });
|
|
89
97
|
sessions = {};
|
|
90
98
|
saveSessions();
|
|
91
99
|
fs.rmSync(setupTokenPath, { force: true });
|
|
92
100
|
authEvents.emit("revoked");
|
|
101
|
+
return user;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/** 没有密码时生成(或沿用)一次性设置码,打印到日志里。 */
|
|
104
|
+
/** 一个用户都没有时生成(或沿用)一次性设置码,打印到日志里。 */
|
|
98
105
|
export function ensureSetupToken(): string | null {
|
|
99
|
-
if (
|
|
106
|
+
if (hasUsers()) return null;
|
|
100
107
|
try {
|
|
101
108
|
const existing = fs.readFileSync(setupTokenPath, "utf8").trim();
|
|
102
109
|
if (existing) return existing;
|
|
@@ -117,26 +124,54 @@ export function checkSetupToken(token: string): boolean {
|
|
|
117
124
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
// ---------- 密码 ----------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 改某个用户的密码:他所有已登录的设备下线。
|
|
131
|
+
* 升级前那个全局密码(config.web.passwordHash)也一并清掉,旧密码从此不再有效。
|
|
132
|
+
*/
|
|
133
|
+
export function setUserPassword(userId: string, password: string): void {
|
|
134
|
+
patchUser(userId, { password });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 改用户(一次写盘,要么全改要么全不改),再处理副作用:
|
|
139
|
+
* 改了密码或者停用了,他所有设备下线;改了密码,升级前的全局密码也作废。
|
|
140
|
+
*/
|
|
141
|
+
export function patchUser(userId: string, patch: Parameters<typeof updateUser>[1]): PublicUser {
|
|
142
|
+
const updated = updateUser(userId, patch);
|
|
143
|
+
if (patch.password !== undefined || patch.disabled === true) revokeUserSessions(userId);
|
|
144
|
+
if (patch.password !== undefined && config().web.passwordHash) {
|
|
145
|
+
const cfg = structuredClone(config());
|
|
146
|
+
cfg.web.passwordHash = "";
|
|
147
|
+
saveConfig(cfg);
|
|
148
|
+
}
|
|
149
|
+
return updated;
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
// ---------- 登录态 ----------
|
|
121
153
|
|
|
122
|
-
export function createSession(ip: string, userAgent: string): string {
|
|
154
|
+
export function createSession(userId: string, ip: string, userAgent: string): string {
|
|
123
155
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
124
156
|
const now = Date.now();
|
|
125
|
-
loadSessions()[sha256(token)] = { createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
|
|
157
|
+
loadSessions()[sha256(token)] = { userId, createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
|
|
126
158
|
saveSessions();
|
|
127
159
|
return token;
|
|
128
160
|
}
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
/** 登录态对应的用户。用户被停用、删掉了也算无效 */
|
|
163
|
+
export function sessionUser(token: string | undefined): User | undefined {
|
|
164
|
+
if (!token || !hasUsers()) return undefined;
|
|
132
165
|
const s = loadSessions()[sha256(token)];
|
|
133
166
|
if (!s) return undefined;
|
|
167
|
+
const user = getUser(ownerOf(s));
|
|
168
|
+
if (!user || user.disabled) return undefined;
|
|
134
169
|
const now = Date.now();
|
|
135
170
|
if (now - s.lastSeen > 60_000) {
|
|
136
171
|
s.lastSeen = now;
|
|
137
172
|
saveSessions();
|
|
138
173
|
}
|
|
139
|
-
return
|
|
174
|
+
return user;
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
export function destroySession(token: string | undefined): void {
|
|
@@ -146,13 +181,24 @@ export function destroySession(token: string | undefined): void {
|
|
|
146
181
|
authEvents.emit("revoked");
|
|
147
182
|
}
|
|
148
183
|
|
|
149
|
-
export
|
|
150
|
-
|
|
184
|
+
export type SessionView = WebSession & { id: string; userId?: string; userName?: string };
|
|
185
|
+
|
|
186
|
+
/** 已登录的设备。给了 userId 就只列这个用户的 */
|
|
187
|
+
export function listSessions(userId?: string): SessionView[] {
|
|
188
|
+
return Object.entries(loadSessions())
|
|
189
|
+
.filter(([, s]) => !userId || ownerOf(s) === userId)
|
|
190
|
+
.map(([id, s]) => {
|
|
191
|
+
const owner = ownerOf(s);
|
|
192
|
+
const name = getUser(owner)?.name;
|
|
193
|
+
return { ...s, id: id.slice(0, 12), ...(owner ? { userId: owner } : {}), ...(name ? { userName: name } : {}) };
|
|
194
|
+
});
|
|
151
195
|
}
|
|
152
196
|
|
|
153
|
-
|
|
197
|
+
/** 踢掉一个设备。给了 userId 就只能踢这个用户自己的 */
|
|
198
|
+
export function revokeSession(idPrefix: string, userId?: string): boolean {
|
|
199
|
+
if (!idPrefix) return false;
|
|
154
200
|
const all = loadSessions();
|
|
155
|
-
const hit = Object.keys(all).find((id) => id.startsWith(idPrefix));
|
|
201
|
+
const hit = Object.keys(all).find((id) => id.startsWith(idPrefix) && (!userId || ownerOf(all[id]!) === userId));
|
|
156
202
|
if (!hit) return false;
|
|
157
203
|
delete all[hit];
|
|
158
204
|
saveSessions();
|
|
@@ -160,6 +206,14 @@ export function revokeSession(idPrefix: string): boolean {
|
|
|
160
206
|
return true;
|
|
161
207
|
}
|
|
162
208
|
|
|
209
|
+
/** 某个用户的所有设备下线 */
|
|
210
|
+
export function revokeUserSessions(userId: string): void {
|
|
211
|
+
const all = loadSessions();
|
|
212
|
+
for (const [id, s] of Object.entries(all)) if (ownerOf(s) === userId) delete all[id];
|
|
213
|
+
saveSessions();
|
|
214
|
+
authEvents.emit("revoked");
|
|
215
|
+
}
|
|
216
|
+
|
|
163
217
|
// ---------- 防爆破 ----------
|
|
164
218
|
//
|
|
165
219
|
// 额度在「真要验密码的那一刻」同步扣掉,而不是先查后记:后者中间隔着读请求体的
|