@yeaft/webchat-agent 1.0.186 → 1.0.189
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/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- package/scripts/prepare-local-runtime.js +25 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import { CONFIG } from '../config.js';
|
|
4
|
+
import { userDb } from '../database.js';
|
|
5
|
+
import { pendingFiles, previewFiles } from '../context.js';
|
|
6
|
+
import { yeaftAssetStore } from '../yeaft-asset-store.js';
|
|
7
|
+
|
|
8
|
+
// 文件上传配置 (存储在内存中)
|
|
9
|
+
const upload = multer({
|
|
10
|
+
storage: multer.memoryStorage(),
|
|
11
|
+
limits: { fileSize: CONFIG.maxFileSize }
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function extensionForMimeType(mimeType) {
|
|
15
|
+
const type = String(mimeType || '').toLowerCase();
|
|
16
|
+
if (type === 'image/png') return '.png';
|
|
17
|
+
if (type === 'image/jpeg') return '.jpg';
|
|
18
|
+
if (type === 'image/gif') return '.gif';
|
|
19
|
+
if (type === 'image/webp') return '.webp';
|
|
20
|
+
if (type === 'image/svg+xml') return '.svg';
|
|
21
|
+
if (type === 'text/plain') return '.txt';
|
|
22
|
+
if (type === 'application/json') return '.json';
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fallbackUploadName(file, index) {
|
|
27
|
+
const original = typeof file?.originalname === 'string' ? file.originalname.trim() : '';
|
|
28
|
+
if (original) return original;
|
|
29
|
+
const isImage = String(file?.mimetype || '').startsWith('image/');
|
|
30
|
+
const prefix = isImage ? 'pasted-image' : 'uploaded-file';
|
|
31
|
+
return `${prefix}-${Date.now()}-${index + 1}${extensionForMimeType(file?.mimetype)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 定期清理超过 10 分钟的文件
|
|
35
|
+
setInterval(() => {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [fileId, file] of pendingFiles) {
|
|
38
|
+
if (now - file.uploadedAt > CONFIG.fileCleanupInterval) {
|
|
39
|
+
pendingFiles.delete(fileId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, 60 * 1000);
|
|
43
|
+
|
|
44
|
+
// Cleanup expired preview files every 60s (10 min TTL)
|
|
45
|
+
setInterval(() => {
|
|
46
|
+
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
47
|
+
for (const [id, f] of previewFiles) {
|
|
48
|
+
if (f.createdAt < cutoff) previewFiles.delete(id);
|
|
49
|
+
}
|
|
50
|
+
}, 60 * 1000);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register file upload and preview routes.
|
|
54
|
+
*/
|
|
55
|
+
export function registerUploadRoutes(app, { requireAuth }) {
|
|
56
|
+
app.post('/api/upload', requireAuth, upload.array('files', 10), (req, res) => {
|
|
57
|
+
if (!req.files || req.files.length === 0) {
|
|
58
|
+
return res.status(400).json({ error: 'No files uploaded' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const user = userDb.getOrCreate(req.user.username);
|
|
62
|
+
const userId = user?.id;
|
|
63
|
+
|
|
64
|
+
const uploaded = req.files.map((file, index) => {
|
|
65
|
+
const fileId = randomUUID();
|
|
66
|
+
const name = fallbackUploadName(file, index);
|
|
67
|
+
pendingFiles.set(fileId, {
|
|
68
|
+
name,
|
|
69
|
+
mimeType: file.mimetype,
|
|
70
|
+
buffer: file.buffer,
|
|
71
|
+
uploadedAt: Date.now(),
|
|
72
|
+
userId
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
fileId,
|
|
76
|
+
name,
|
|
77
|
+
mimeType: file.mimetype,
|
|
78
|
+
size: file.size
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
res.json({ files: uploaded });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.get('/api/preview/:fileId', (req, res) => {
|
|
86
|
+
const file = previewFiles.get(req.params.fileId);
|
|
87
|
+
if (!file) return res.status(404).send('File not found or expired');
|
|
88
|
+
if (file.token && req.query.token !== file.token) {
|
|
89
|
+
return res.status(403).send('Forbidden');
|
|
90
|
+
}
|
|
91
|
+
res.setHeader('Content-Type', file.mimeType);
|
|
92
|
+
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`);
|
|
93
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
94
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
95
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
96
|
+
res.send(file.buffer);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
app.get('/api/yeaft/assets/:scopeId/:assetId', (req, res) => {
|
|
100
|
+
const asset = yeaftAssetStore.read(req.params.scopeId, req.params.assetId, req.query.token);
|
|
101
|
+
if (!asset) return res.status(404).send('Image asset not found');
|
|
102
|
+
res.setHeader('Content-Type', asset.metadata.mimeType);
|
|
103
|
+
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(asset.metadata.filename)}"`);
|
|
104
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
105
|
+
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
106
|
+
res.setHeader('ETag', `"${asset.metadata.assetId}"`);
|
|
107
|
+
res.send(asset.buffer);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { CONFIG } from '../config.js';
|
|
2
|
+
import { hashPassword } from '../auth.js';
|
|
3
|
+
import { userDb, sessionDb } from '../database.js';
|
|
4
|
+
import { activeSessions, revokedTokens } from '../auth/session-store.js';
|
|
5
|
+
|
|
6
|
+
// 过滤用户敏感字段
|
|
7
|
+
function sanitizeUser(user) {
|
|
8
|
+
return {
|
|
9
|
+
id: user.id,
|
|
10
|
+
username: user.username,
|
|
11
|
+
display_name: user.display_name,
|
|
12
|
+
email: user.email,
|
|
13
|
+
role: user.role,
|
|
14
|
+
created_at: user.created_at,
|
|
15
|
+
last_login_at: user.last_login_at
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 转换数据库会话记录为前端期望的格式
|
|
20
|
+
function transformSession(session) {
|
|
21
|
+
return {
|
|
22
|
+
id: session.id,
|
|
23
|
+
agentId: session.agent_id,
|
|
24
|
+
agentName: session.agent_name,
|
|
25
|
+
claudeSessionId: session.claude_session_id,
|
|
26
|
+
workDir: session.work_dir,
|
|
27
|
+
title: session.title,
|
|
28
|
+
createdAt: session.created_at,
|
|
29
|
+
updatedAt: session.updated_at,
|
|
30
|
+
isActive: !!session.is_active,
|
|
31
|
+
userId: session.user_id
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadCurrentUser(req) {
|
|
36
|
+
let user = userDb.getByUsername(req.user.username);
|
|
37
|
+
if (!user && CONFIG.skipAuth) {
|
|
38
|
+
user = userDb.getOrCreate(req.user.username, req.user.username);
|
|
39
|
+
}
|
|
40
|
+
return user;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ensureAgentSecret(user) {
|
|
44
|
+
if (user?.agent_secret) return user.agent_secret;
|
|
45
|
+
return userDb.resetAgentSecret(user.id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register user profile, agent secret, and admin user management routes.
|
|
50
|
+
*/
|
|
51
|
+
export function registerUserRoutes(app, { requireAuth, requireAdmin }) {
|
|
52
|
+
// Get my profile
|
|
53
|
+
app.get('/api/user/profile', requireAuth, (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const user = loadCurrentUser(req);
|
|
56
|
+
if (!user) {
|
|
57
|
+
return res.status(404).json({ error: 'User not found' });
|
|
58
|
+
}
|
|
59
|
+
res.json({
|
|
60
|
+
username: user.username,
|
|
61
|
+
displayName: user.display_name,
|
|
62
|
+
email: user.email,
|
|
63
|
+
role: user.role === 'admin' ? 'admin' : 'pro',
|
|
64
|
+
createdAt: user.created_at,
|
|
65
|
+
// hasPassword lets the UI distinguish "change password" (current pwd
|
|
66
|
+
// required) from "set password for the first time" (SSO-only users).
|
|
67
|
+
hasPassword: !!user.password_hash
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error('Get profile error:', err);
|
|
71
|
+
res.status(500).json({ error: 'Failed to get profile' });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Update my profile (password and/or email)
|
|
76
|
+
app.put('/api/user/profile', requireAuth, async (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const user = userDb.getByUsername(req.user.username);
|
|
79
|
+
if (!user) {
|
|
80
|
+
return res.status(404).json({ error: 'User not found' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { currentPassword, newPassword, email } = req.body;
|
|
84
|
+
|
|
85
|
+
// Two cases:
|
|
86
|
+
// (a) User already has a password → must verify currentPassword.
|
|
87
|
+
// (b) User has no password (e.g. SSO-only) and is setting one for the
|
|
88
|
+
// first time → currentPassword is not required, but they must
|
|
89
|
+
// supply newPassword. This unblocks logout-then-username-login
|
|
90
|
+
// for SSO users.
|
|
91
|
+
const isSettingFirstPassword = !user.password_hash && !!newPassword;
|
|
92
|
+
|
|
93
|
+
if (!isSettingFirstPassword) {
|
|
94
|
+
if (!currentPassword) {
|
|
95
|
+
return res.status(400).json({ error: 'Current password is required' });
|
|
96
|
+
}
|
|
97
|
+
if (!user.password_hash) {
|
|
98
|
+
// No password yet, and no newPassword in this request → nothing valid to do.
|
|
99
|
+
return res.status(400).json({ error: 'No password is set; supply newPassword to set one.' });
|
|
100
|
+
}
|
|
101
|
+
const bcryptModule = await import('bcrypt');
|
|
102
|
+
const passwordValid = await bcryptModule.default.compare(currentPassword, user.password_hash);
|
|
103
|
+
if (!passwordValid) {
|
|
104
|
+
return res.status(403).json({ error: 'Current password is incorrect' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (newPassword) {
|
|
109
|
+
if (newPassword.length < 6) {
|
|
110
|
+
return res.status(400).json({ error: 'New password must be at least 6 characters' });
|
|
111
|
+
}
|
|
112
|
+
const newHash = await hashPassword(newPassword);
|
|
113
|
+
userDb.updatePassword(user.id, newHash);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (email !== undefined) {
|
|
117
|
+
userDb.updateEmail(user.id, email || null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
res.json({ success: true });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error('Update profile error:', err);
|
|
123
|
+
res.status(500).json({ error: 'Failed to update profile' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Permanently delete my account.
|
|
128
|
+
// Requires either currentPassword (for password users) OR confirms !hasPassword
|
|
129
|
+
// for SSO-only users (in which case the body must include `confirm: 'DELETE'`).
|
|
130
|
+
app.delete('/api/user/me', requireAuth, async (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
const user = userDb.getByUsername(req.user.username);
|
|
133
|
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
134
|
+
|
|
135
|
+
const { currentPassword, confirm } = req.body || {};
|
|
136
|
+
|
|
137
|
+
if (user.password_hash) {
|
|
138
|
+
if (!currentPassword) {
|
|
139
|
+
return res.status(400).json({ error: 'Current password is required' });
|
|
140
|
+
}
|
|
141
|
+
const bcryptModule = await import('bcrypt');
|
|
142
|
+
const ok = await bcryptModule.default.compare(currentPassword, user.password_hash);
|
|
143
|
+
if (!ok) return res.status(403).json({ error: 'Current password is incorrect' });
|
|
144
|
+
} else {
|
|
145
|
+
// SSO-only: require explicit confirmation string to prevent accidental deletion.
|
|
146
|
+
if (confirm !== 'DELETE') {
|
|
147
|
+
return res.status(400).json({ error: 'Confirmation required (confirm: "DELETE")' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const removed = userDb.deleteUser(user.id);
|
|
152
|
+
if (!removed) return res.status(404).json({ error: 'User not found or already deleted' });
|
|
153
|
+
|
|
154
|
+
// Best-effort: revoke every active JWT belonging to this user so any
|
|
155
|
+
// open tabs can't keep talking to the API.
|
|
156
|
+
try {
|
|
157
|
+
for (const [token, info] of activeSessions.entries()) {
|
|
158
|
+
if (info && info.username === user.username) {
|
|
159
|
+
activeSessions.delete(token);
|
|
160
|
+
revokedTokens.add(token);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
|
|
165
|
+
res.json({ success: true });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('Delete user error:', err);
|
|
168
|
+
res.status(500).json({ error: 'Failed to delete account' });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Get my agent secret
|
|
173
|
+
app.get('/api/user/agent-secret', requireAuth, (req, res) => {
|
|
174
|
+
try {
|
|
175
|
+
const user = loadCurrentUser(req);
|
|
176
|
+
if (!user) {
|
|
177
|
+
return res.status(404).json({ error: 'User not found' });
|
|
178
|
+
}
|
|
179
|
+
const agentSecret = ensureAgentSecret(user);
|
|
180
|
+
res.json({ agentSecret });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error('Get agent secret error:', err);
|
|
183
|
+
res.status(500).json({ error: 'Failed to get agent secret' });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Reset my agent secret
|
|
188
|
+
app.post('/api/user/agent-secret/reset', requireAuth, (req, res) => {
|
|
189
|
+
try {
|
|
190
|
+
const user = loadCurrentUser(req);
|
|
191
|
+
if (!user) {
|
|
192
|
+
return res.status(404).json({ error: 'User not found' });
|
|
193
|
+
}
|
|
194
|
+
const newSecret = userDb.resetAgentSecret(user.id);
|
|
195
|
+
res.json({ agentSecret: newSecret });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error('Reset agent secret error:', err);
|
|
198
|
+
res.status(500).json({ error: 'Failed to reset agent secret' });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Admin: list all users
|
|
203
|
+
app.get('/api/users', requireAuth, requireAdmin, (req, res) => {
|
|
204
|
+
try {
|
|
205
|
+
const users = userDb.getAll().map(sanitizeUser);
|
|
206
|
+
res.json({ users });
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.error('Failed to get users:', e.message);
|
|
209
|
+
res.status(500).json({ error: 'Failed to get users' });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Admin: get user by id
|
|
214
|
+
app.get('/api/users/:id', requireAuth, requireAdmin, (req, res) => {
|
|
215
|
+
try {
|
|
216
|
+
const user = userDb.get(req.params.id);
|
|
217
|
+
if (!user) {
|
|
218
|
+
return res.status(404).json({ error: 'User not found' });
|
|
219
|
+
}
|
|
220
|
+
res.json({ user: sanitizeUser(user) });
|
|
221
|
+
} catch (e) {
|
|
222
|
+
console.error('Failed to get user:', e.message);
|
|
223
|
+
res.status(500).json({ error: 'Failed to get user' });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Admin: get user's sessions
|
|
228
|
+
app.get('/api/users/:id/sessions', requireAuth, requireAdmin, (req, res) => {
|
|
229
|
+
const limit = parseInt(req.query.limit, 10) || 50;
|
|
230
|
+
const agentId = req.query.agentId;
|
|
231
|
+
try {
|
|
232
|
+
const sessions = agentId
|
|
233
|
+
? sessionDb.getByUserAndAgent(req.params.id, agentId, limit)
|
|
234
|
+
: sessionDb.getByUser(req.params.id, limit);
|
|
235
|
+
res.json({ sessions: sessions.map(transformSession) });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.error('Failed to get user sessions:', e.message);
|
|
238
|
+
res.status(500).json({ error: 'Failed to get user sessions' });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import speakeasy from 'speakeasy';
|
|
2
|
+
import QRCode from 'qrcode';
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a new TOTP secret for a user
|
|
7
|
+
* @returns {string} Base32-encoded secret
|
|
8
|
+
*/
|
|
9
|
+
export function generateTotpSecret() {
|
|
10
|
+
const secret = speakeasy.generateSecret({ length: 20 });
|
|
11
|
+
return secret.base32;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate otpauth URI for QR code
|
|
16
|
+
* @param {string} username - User's username
|
|
17
|
+
* @param {string} secret - Base32-encoded TOTP secret
|
|
18
|
+
* @returns {string} otpauth URI
|
|
19
|
+
*/
|
|
20
|
+
export function generateTotpUri(username, secret) {
|
|
21
|
+
const issuer = CONFIG.totp?.issuer || 'Claude Web Chat';
|
|
22
|
+
return speakeasy.otpauthURL({
|
|
23
|
+
secret: secret,
|
|
24
|
+
label: username,
|
|
25
|
+
issuer: issuer,
|
|
26
|
+
encoding: 'base32'
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate QR code as data URL
|
|
32
|
+
* @param {string} username - User's username
|
|
33
|
+
* @param {string} secret - Base32-encoded TOTP secret
|
|
34
|
+
* @returns {Promise<string>} QR code as data URL
|
|
35
|
+
*/
|
|
36
|
+
export async function generateTotpQRCode(username, secret) {
|
|
37
|
+
const uri = generateTotpUri(username, secret);
|
|
38
|
+
return QRCode.toDataURL(uri, {
|
|
39
|
+
width: 256,
|
|
40
|
+
margin: 2,
|
|
41
|
+
color: {
|
|
42
|
+
dark: '#000000',
|
|
43
|
+
light: '#ffffff'
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Verify a TOTP code
|
|
50
|
+
* @param {string} token - 6-digit TOTP code from user
|
|
51
|
+
* @param {string} secret - Base32-encoded TOTP secret
|
|
52
|
+
* @returns {boolean} Whether the code is valid
|
|
53
|
+
*/
|
|
54
|
+
export function verifyTotpCode(token, secret) {
|
|
55
|
+
try {
|
|
56
|
+
return speakeasy.totp.verify({
|
|
57
|
+
secret: secret,
|
|
58
|
+
encoding: 'base32',
|
|
59
|
+
token: token,
|
|
60
|
+
window: CONFIG.totp?.window || 1
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('TOTP verification error:', err.message);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if TOTP is globally enabled
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isTotpEnabled() {
|
|
73
|
+
return CONFIG.totp?.enabled !== false;
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const MAX_WORK_ITEM_ATTACHMENTS = 10;
|
|
4
|
+
export const MAX_WORK_ITEM_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
5
|
+
export const MAX_WORK_ITEM_INLINE_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
const IMAGE_EXTENSIONS_BY_MIME = new Map([
|
|
8
|
+
['image/png', new Set(['.png'])],
|
|
9
|
+
['image/jpeg', new Set(['.jpg', '.jpeg', '.jfif'])],
|
|
10
|
+
['image/gif', new Set(['.gif'])],
|
|
11
|
+
['image/webp', new Set(['.webp'])],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const TEXT_EXTENSIONS = new Set([
|
|
15
|
+
'.txt', '.md', '.markdown', '.json', '.jsonl', '.csv', '.xml', '.yaml', '.yml',
|
|
16
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.css', '.html', '.htm', '.py',
|
|
17
|
+
'.sh', '.zsh', '.bash', '.sql', '.toml', '.ini', '.conf', '.log',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const TEXT_MIME_TYPES = new Set([
|
|
21
|
+
'application/json',
|
|
22
|
+
'application/ld+json',
|
|
23
|
+
'application/javascript',
|
|
24
|
+
'application/x-javascript',
|
|
25
|
+
'application/xml',
|
|
26
|
+
'application/yaml',
|
|
27
|
+
'application/x-yaml',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function classifyWorkItemAttachment(name, mimeType) {
|
|
31
|
+
const normalizedMime = String(mimeType || 'application/octet-stream').trim().toLowerCase();
|
|
32
|
+
const extension = extname(String(name || '')).toLowerCase();
|
|
33
|
+
if (extension === '.pdf') return normalizedMime === 'application/pdf' ? 'pdf' : null;
|
|
34
|
+
|
|
35
|
+
const imageExtensions = IMAGE_EXTENSIONS_BY_MIME.get(normalizedMime);
|
|
36
|
+
if (imageExtensions) return imageExtensions.has(extension) ? 'image' : null;
|
|
37
|
+
|
|
38
|
+
const textMime = normalizedMime.startsWith('text/') || TEXT_MIME_TYPES.has(normalizedMime);
|
|
39
|
+
if (textMime) return !extension || TEXT_EXTENSIONS.has(extension) ? 'text' : null;
|
|
40
|
+
if (TEXT_EXTENSIONS.has(extension) && normalizedMime === 'application/octet-stream') return 'text';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertSupportedWorkItemAttachment(name, mimeType) {
|
|
45
|
+
const kind = classifyWorkItemAttachment(name, mimeType);
|
|
46
|
+
if (!kind) {
|
|
47
|
+
throw new Error('Unsupported WorkItem attachment type; use an image, PDF, or text-based file');
|
|
48
|
+
}
|
|
49
|
+
return kind;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertWorkItemAttachmentSize(size) {
|
|
53
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_WORK_ITEM_INLINE_BYTES) {
|
|
54
|
+
throw new Error(`WorkItem attachment exceeds ${MAX_WORK_ITEM_INLINE_BYTES} bytes`);
|
|
55
|
+
}
|
|
56
|
+
}
|