@ian2018cs/agenthub 0.1.44 → 0.1.46
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/dist/assets/index-BHRvJZSD.js +152 -0
- package/dist/assets/index-BhhJnwtA.css +32 -0
- package/dist/assets/{vendor-icons-B1SOlwgw.js → vendor-icons-KP5LHo3O.js} +91 -71
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/database/db.js +153 -12
- package/server/database/init.sql +1 -1
- package/server/index.js +107 -4
- package/server/routes/admin.js +67 -5
- package/server/routes/auth.js +4 -3
- package/server/routes/usage.js +1 -1
- package/server/services/feishu/sdk-bridge.js +12 -9
- package/server/services/image-cleanup.js +79 -0
- package/server/services/image-storage.js +78 -0
- package/dist/assets/index-C5Vz-HNr.js +0 -152
- package/dist/assets/index-CfJ-xiJl.css +0 -32
package/server/database/db.js
CHANGED
|
@@ -247,6 +247,63 @@ const runMigrations = () => {
|
|
|
247
247
|
)
|
|
248
248
|
`);
|
|
249
249
|
|
|
250
|
+
// Migration: Add super_admin role (recreate users table with updated CHECK constraint)
|
|
251
|
+
const hasAnyUser = db.prepare('SELECT COUNT(*) as count FROM users').get().count > 0;
|
|
252
|
+
const hasSuperAdmin = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'super_admin'").get().count > 0;
|
|
253
|
+
|
|
254
|
+
if (hasAnyUser && !hasSuperAdmin) {
|
|
255
|
+
console.log('Running migration: Adding super_admin role and promoting first admin');
|
|
256
|
+
db.exec('PRAGMA foreign_keys = OFF');
|
|
257
|
+
db.transaction(() => {
|
|
258
|
+
db.exec(`CREATE TABLE users_new (
|
|
259
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
260
|
+
username TEXT UNIQUE,
|
|
261
|
+
password_hash TEXT,
|
|
262
|
+
email TEXT UNIQUE,
|
|
263
|
+
uuid TEXT,
|
|
264
|
+
role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user', 'super_admin')),
|
|
265
|
+
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
|
266
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
267
|
+
last_login DATETIME,
|
|
268
|
+
is_active BOOLEAN DEFAULT 1,
|
|
269
|
+
git_name TEXT,
|
|
270
|
+
git_email TEXT,
|
|
271
|
+
has_completed_onboarding BOOLEAN DEFAULT 0,
|
|
272
|
+
total_limit_usd REAL DEFAULT NULL,
|
|
273
|
+
daily_limit_usd REAL DEFAULT NULL
|
|
274
|
+
)`);
|
|
275
|
+
db.exec('INSERT INTO users_new SELECT id,username,password_hash,email,uuid,role,status,created_at,last_login,is_active,git_name,git_email,has_completed_onboarding,total_limit_usd,daily_limit_usd FROM users');
|
|
276
|
+
db.exec('DROP TABLE users');
|
|
277
|
+
db.exec('ALTER TABLE users_new RENAME TO users');
|
|
278
|
+
db.exec("UPDATE users SET role='super_admin' WHERE id=(SELECT MIN(id) FROM users WHERE role='admin')");
|
|
279
|
+
})();
|
|
280
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
281
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid ON users(uuid)');
|
|
282
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)');
|
|
283
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)');
|
|
284
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 聊天图片持久化关联表
|
|
288
|
+
db.exec(`
|
|
289
|
+
CREATE TABLE IF NOT EXISTS message_images (
|
|
290
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
291
|
+
upload_batch_id TEXT NOT NULL,
|
|
292
|
+
user_uuid TEXT NOT NULL,
|
|
293
|
+
session_id TEXT,
|
|
294
|
+
message_content TEXT,
|
|
295
|
+
original_name TEXT NOT NULL,
|
|
296
|
+
file_hash TEXT NOT NULL,
|
|
297
|
+
file_ext TEXT NOT NULL,
|
|
298
|
+
file_size INTEGER NOT NULL,
|
|
299
|
+
mime_type TEXT NOT NULL,
|
|
300
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
301
|
+
)
|
|
302
|
+
`);
|
|
303
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_msg_images_batch ON message_images(upload_batch_id)');
|
|
304
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_msg_images_session ON message_images(session_id)');
|
|
305
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_msg_images_created ON message_images(created_at)');
|
|
306
|
+
|
|
250
307
|
console.log('Database migrations completed successfully');
|
|
251
308
|
} catch (error) {
|
|
252
309
|
console.error('Error running migrations:', error.message);
|
|
@@ -418,6 +475,15 @@ const userDb = {
|
|
|
418
475
|
}
|
|
419
476
|
},
|
|
420
477
|
|
|
478
|
+
// Update user role
|
|
479
|
+
updateUserRole: (userId, role) => {
|
|
480
|
+
try {
|
|
481
|
+
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, userId);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
|
|
421
487
|
// Update user status
|
|
422
488
|
updateUserStatus: (userId, status) => {
|
|
423
489
|
try {
|
|
@@ -775,13 +841,13 @@ const usageDb = {
|
|
|
775
841
|
try {
|
|
776
842
|
return db.prepare(`
|
|
777
843
|
SELECT
|
|
778
|
-
user_uuid,
|
|
779
|
-
SUM(total_cost_usd) as total_cost,
|
|
780
|
-
SUM(request_count) as total_requests,
|
|
781
|
-
|
|
782
|
-
MAX(date) as last_active
|
|
783
|
-
FROM usage_daily_summary
|
|
784
|
-
GROUP BY user_uuid
|
|
844
|
+
uds.user_uuid,
|
|
845
|
+
SUM(uds.total_cost_usd) as total_cost,
|
|
846
|
+
SUM(uds.request_count) as total_requests,
|
|
847
|
+
(SELECT COUNT(DISTINCT ur.session_id) FROM usage_records ur WHERE ur.user_uuid = uds.user_uuid AND ur.session_id IS NOT NULL) as total_sessions,
|
|
848
|
+
MAX(uds.date) as last_active
|
|
849
|
+
FROM usage_daily_summary uds
|
|
850
|
+
GROUP BY uds.user_uuid
|
|
785
851
|
ORDER BY total_cost DESC
|
|
786
852
|
`).all();
|
|
787
853
|
} catch (err) {
|
|
@@ -819,10 +885,10 @@ const usageDb = {
|
|
|
819
885
|
SUM(total_input_tokens) as total_input_tokens,
|
|
820
886
|
SUM(total_output_tokens) as total_output_tokens,
|
|
821
887
|
SUM(request_count) as total_requests,
|
|
822
|
-
|
|
888
|
+
(SELECT COUNT(DISTINCT session_id) FROM usage_records WHERE user_uuid = ? AND session_id IS NOT NULL) as total_sessions
|
|
823
889
|
FROM usage_daily_summary
|
|
824
890
|
WHERE user_uuid = ?
|
|
825
|
-
`).get(userUuid);
|
|
891
|
+
`).get(userUuid, userUuid);
|
|
826
892
|
} catch (err) {
|
|
827
893
|
throw err;
|
|
828
894
|
}
|
|
@@ -839,6 +905,7 @@ const usageDb = {
|
|
|
839
905
|
COUNT(*) as requests
|
|
840
906
|
FROM usage_records
|
|
841
907
|
WHERE user_uuid = ? AND created_at >= ? AND created_at <= ?
|
|
908
|
+
AND COALESCE(raw_model, model) != '<synthetic>'
|
|
842
909
|
GROUP BY COALESCE(raw_model, model)
|
|
843
910
|
ORDER BY cost DESC
|
|
844
911
|
`).all(userUuid, startDate + 'T00:00:00', endDate + 'T23:59:59');
|
|
@@ -854,11 +921,11 @@ const usageDb = {
|
|
|
854
921
|
SELECT
|
|
855
922
|
SUM(total_cost_usd) as total_cost,
|
|
856
923
|
SUM(request_count) as total_requests,
|
|
857
|
-
|
|
924
|
+
(SELECT COUNT(DISTINCT session_id) FROM usage_records WHERE date(created_at) >= ? AND date(created_at) <= ? AND session_id IS NOT NULL) as total_sessions,
|
|
858
925
|
COUNT(DISTINCT user_uuid) as active_users
|
|
859
926
|
FROM usage_daily_summary
|
|
860
927
|
WHERE date >= ? AND date <= ?
|
|
861
|
-
`).get(startDate, endDate);
|
|
928
|
+
`).get(startDate, endDate, startDate, endDate);
|
|
862
929
|
|
|
863
930
|
const dailyTrend = db.prepare(`
|
|
864
931
|
SELECT
|
|
@@ -879,6 +946,7 @@ const usageDb = {
|
|
|
879
946
|
COUNT(*) as requests
|
|
880
947
|
FROM usage_records
|
|
881
948
|
WHERE created_at >= ? AND created_at <= ?
|
|
949
|
+
AND COALESCE(raw_model, model) != '<synthetic>'
|
|
882
950
|
GROUP BY COALESCE(raw_model, model)
|
|
883
951
|
ORDER BY cost DESC
|
|
884
952
|
`).all(startDate + 'T00:00:00', endDate + 'T23:59:59');
|
|
@@ -1153,6 +1221,78 @@ const feishuDb = {
|
|
|
1153
1221
|
}
|
|
1154
1222
|
};
|
|
1155
1223
|
|
|
1224
|
+
// Image database operations (聊天图片持久化)
|
|
1225
|
+
const imageDb = {
|
|
1226
|
+
insertImage: ({ upload_batch_id, user_uuid, original_name, file_hash, file_ext, file_size, mime_type }) => {
|
|
1227
|
+
const result = db.prepare(`
|
|
1228
|
+
INSERT INTO message_images (upload_batch_id, user_uuid, original_name, file_hash, file_ext, file_size, mime_type)
|
|
1229
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1230
|
+
`).run(upload_batch_id, user_uuid, original_name, file_hash, file_ext, file_size, mime_type);
|
|
1231
|
+
return result.lastInsertRowid;
|
|
1232
|
+
},
|
|
1233
|
+
|
|
1234
|
+
associateBatch: (uploadBatchId, sessionId, messageContent) => {
|
|
1235
|
+
db.prepare(`
|
|
1236
|
+
UPDATE message_images SET session_id = ?, message_content = ? WHERE upload_batch_id = ?
|
|
1237
|
+
`).run(sessionId, messageContent || null, uploadBatchId);
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
getImagesBySession: (sessionId) => {
|
|
1241
|
+
return db.prepare(`
|
|
1242
|
+
SELECT id, upload_batch_id, original_name, file_hash, file_ext, mime_type, message_content, created_at
|
|
1243
|
+
FROM message_images WHERE session_id = ? ORDER BY created_at ASC
|
|
1244
|
+
`).all(sessionId);
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
getImageById: (imageId) => {
|
|
1248
|
+
return db.prepare(`
|
|
1249
|
+
SELECT id, user_uuid, original_name, file_hash, file_ext, mime_type, created_at
|
|
1250
|
+
FROM message_images WHERE id = ?
|
|
1251
|
+
`).get(imageId) || null;
|
|
1252
|
+
},
|
|
1253
|
+
|
|
1254
|
+
getExpiredImages: (days) => {
|
|
1255
|
+
return db.prepare(`
|
|
1256
|
+
SELECT id, user_uuid, file_hash, file_ext
|
|
1257
|
+
FROM message_images WHERE created_at < datetime('now', '-' || ? || ' days')
|
|
1258
|
+
`).all(days);
|
|
1259
|
+
},
|
|
1260
|
+
|
|
1261
|
+
deleteImagesByIds: (ids) => {
|
|
1262
|
+
if (!ids.length) return;
|
|
1263
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
1264
|
+
db.prepare(`DELETE FROM message_images WHERE id IN (${placeholders})`).run(...ids);
|
|
1265
|
+
},
|
|
1266
|
+
|
|
1267
|
+
deleteOrphanedImages: (hours) => {
|
|
1268
|
+
const rows = db.prepare(`
|
|
1269
|
+
SELECT id, user_uuid, file_hash, file_ext
|
|
1270
|
+
FROM message_images WHERE session_id IS NULL AND created_at < datetime('now', '-' || ? || ' hours')
|
|
1271
|
+
`).all(hours);
|
|
1272
|
+
if (rows.length) {
|
|
1273
|
+
const ids = rows.map(r => r.id);
|
|
1274
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
1275
|
+
db.prepare(`DELETE FROM message_images WHERE id IN (${placeholders})`).run(...ids);
|
|
1276
|
+
}
|
|
1277
|
+
return rows;
|
|
1278
|
+
},
|
|
1279
|
+
|
|
1280
|
+
countByHash: (userUuid, fileHash, excludeIds) => {
|
|
1281
|
+
if (!excludeIds.length) {
|
|
1282
|
+
const row = db.prepare(`
|
|
1283
|
+
SELECT COUNT(*) as count FROM message_images WHERE user_uuid = ? AND file_hash = ?
|
|
1284
|
+
`).get(userUuid, fileHash);
|
|
1285
|
+
return row.count;
|
|
1286
|
+
}
|
|
1287
|
+
const placeholders = excludeIds.map(() => '?').join(',');
|
|
1288
|
+
const row = db.prepare(`
|
|
1289
|
+
SELECT COUNT(*) as count FROM message_images
|
|
1290
|
+
WHERE user_uuid = ? AND file_hash = ? AND id NOT IN (${placeholders})
|
|
1291
|
+
`).get(userUuid, fileHash, ...excludeIds);
|
|
1292
|
+
return row.count;
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1156
1296
|
export {
|
|
1157
1297
|
db,
|
|
1158
1298
|
initializeDatabase,
|
|
@@ -1161,5 +1301,6 @@ export {
|
|
|
1161
1301
|
verificationDb,
|
|
1162
1302
|
domainWhitelistDb,
|
|
1163
1303
|
settingsDb,
|
|
1164
|
-
feishuDb
|
|
1304
|
+
feishuDb,
|
|
1305
|
+
imageDb
|
|
1165
1306
|
};
|
package/server/database/init.sql
CHANGED
|
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|
|
11
11
|
password_hash TEXT,
|
|
12
12
|
email TEXT UNIQUE,
|
|
13
13
|
uuid TEXT,
|
|
14
|
-
role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user')),
|
|
14
|
+
role TEXT DEFAULT 'user' CHECK(role IN ('admin', 'user', 'super_admin')),
|
|
15
15
|
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'disabled')),
|
|
16
16
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
17
17
|
last_login DATETIME,
|
package/server/index.js
CHANGED
|
@@ -55,11 +55,13 @@ import usageRoutes from './routes/usage.js';
|
|
|
55
55
|
import skillsRoutes from './routes/skills.js';
|
|
56
56
|
import mcpReposRoutes from './routes/mcp-repos.js';
|
|
57
57
|
import settingsRoutes from './routes/settings.js';
|
|
58
|
-
import { initializeDatabase, userDb } from './database/db.js';
|
|
58
|
+
import { initializeDatabase, userDb, imageDb } from './database/db.js';
|
|
59
59
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
60
60
|
import { getUserPaths, initCodexDirectories, initGeminiDirectories } from './services/user-directories.js';
|
|
61
61
|
import { startUsageScanner } from './services/usage-scanner.js';
|
|
62
62
|
import { startFeishuService, stopFeishuService } from './services/feishu/index.js';
|
|
63
|
+
import { saveImage, getImagePath } from './services/image-storage.js';
|
|
64
|
+
import { startImageCleanup } from './services/image-cleanup.js';
|
|
63
65
|
|
|
64
66
|
// File system watcher for projects folder - per user
|
|
65
67
|
const userWatchers = new Map(); // Map<userUuid, { watcher, clients: Set<ws> }>
|
|
@@ -382,13 +384,40 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
|
|
|
382
384
|
|
|
383
385
|
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset, req.user.uuid);
|
|
384
386
|
|
|
387
|
+
// Query persistent images for this session
|
|
388
|
+
let sessionImages = [];
|
|
389
|
+
try {
|
|
390
|
+
const images = imageDb.getImagesBySession(sessionId);
|
|
391
|
+
if (images.length > 0) {
|
|
392
|
+
// Group by upload_batch_id
|
|
393
|
+
const batchMap = new Map();
|
|
394
|
+
for (const img of images) {
|
|
395
|
+
if (!batchMap.has(img.upload_batch_id)) {
|
|
396
|
+
batchMap.set(img.upload_batch_id, {
|
|
397
|
+
batchId: img.upload_batch_id,
|
|
398
|
+
messageContent: img.message_content,
|
|
399
|
+
createdAt: img.created_at,
|
|
400
|
+
images: [],
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
batchMap.get(img.upload_batch_id).images.push({
|
|
404
|
+
id: img.id,
|
|
405
|
+
name: img.original_name,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
sessionImages = Array.from(batchMap.values());
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.error('[WARN] Failed to load session images:', err.message);
|
|
412
|
+
}
|
|
413
|
+
|
|
385
414
|
// Handle both old and new response formats
|
|
386
415
|
if (Array.isArray(result)) {
|
|
387
416
|
// Backward compatibility: no pagination parameters were provided
|
|
388
|
-
res.json({ messages: result });
|
|
417
|
+
res.json({ messages: result, sessionImages });
|
|
389
418
|
} else {
|
|
390
419
|
// New format with pagination info
|
|
391
|
-
res.json(result);
|
|
420
|
+
res.json({ ...result, sessionImages });
|
|
392
421
|
}
|
|
393
422
|
} catch (error) {
|
|
394
423
|
res.status(500).json({ error: error.message });
|
|
@@ -729,6 +758,22 @@ function handleChatConnection(ws, userData) {
|
|
|
729
758
|
...data.options,
|
|
730
759
|
userUuid,
|
|
731
760
|
}, writer);
|
|
761
|
+
|
|
762
|
+
// Associate uploaded images with the session after query completes
|
|
763
|
+
if (data.options?.uploadBatchId) {
|
|
764
|
+
const sessionId = writer.getSessionId();
|
|
765
|
+
if (sessionId) {
|
|
766
|
+
try {
|
|
767
|
+
imageDb.associateBatch(
|
|
768
|
+
data.options.uploadBatchId,
|
|
769
|
+
sessionId,
|
|
770
|
+
data.command?.substring(0, 500) || ''
|
|
771
|
+
);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error('[WARN] Failed to associate image batch:', err.message);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
732
777
|
} else if (data.type === 'abort-session') {
|
|
733
778
|
console.log('[DEBUG] Abort session request:', data.sessionId);
|
|
734
779
|
// Use Claude Agents SDK
|
|
@@ -1749,6 +1794,40 @@ Agent instructions:`;
|
|
|
1749
1794
|
}
|
|
1750
1795
|
});
|
|
1751
1796
|
|
|
1797
|
+
// Serve persistent chat images
|
|
1798
|
+
app.get('/api/images/:imageId', authenticateToken, (req, res) => {
|
|
1799
|
+
try {
|
|
1800
|
+
const imageId = parseInt(req.params.imageId, 10);
|
|
1801
|
+
if (isNaN(imageId)) {
|
|
1802
|
+
return res.status(400).json({ error: 'Invalid image ID' });
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
const image = imageDb.getImageById(imageId);
|
|
1806
|
+
if (!image) {
|
|
1807
|
+
return res.status(404).json({ error: 'Image not found' });
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Security: users can only access their own images
|
|
1811
|
+
if (image.user_uuid !== req.user.uuid) {
|
|
1812
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const filePath = getImagePath(image.user_uuid, image.file_hash, image.file_ext);
|
|
1816
|
+
|
|
1817
|
+
// Check if file still exists on disk (may have been cleaned up)
|
|
1818
|
+
if (!fs.existsSync(filePath)) {
|
|
1819
|
+
return res.status(410).json({ error: 'Image expired' });
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
res.setHeader('Content-Type', image.mime_type);
|
|
1823
|
+
res.setHeader('Cache-Control', 'private, max-age=86400');
|
|
1824
|
+
res.sendFile(filePath);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
console.error('Error serving image:', error);
|
|
1827
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1752
1831
|
// Image upload endpoint
|
|
1753
1832
|
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
|
|
1754
1833
|
try {
|
|
@@ -1800,6 +1879,11 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1800
1879
|
}
|
|
1801
1880
|
|
|
1802
1881
|
try {
|
|
1882
|
+
// Generate a batch ID for this upload group
|
|
1883
|
+
const { randomUUID } = await import('crypto');
|
|
1884
|
+
const uploadBatchId = randomUUID();
|
|
1885
|
+
const userUuid = req.user.uuid;
|
|
1886
|
+
|
|
1803
1887
|
// Process uploaded images
|
|
1804
1888
|
const processedImages = await Promise.all(
|
|
1805
1889
|
req.files.map(async (file) => {
|
|
@@ -1811,7 +1895,23 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1811
1895
|
// Clean up temp file immediately
|
|
1812
1896
|
await fs.unlink(file.path);
|
|
1813
1897
|
|
|
1898
|
+
// Persist image to file system (hash-deduplicated)
|
|
1899
|
+
const ext = file.originalname.split('.').pop()?.toLowerCase() || 'bin';
|
|
1900
|
+
const { fileHash } = await saveImage(userUuid, buffer, ext);
|
|
1901
|
+
|
|
1902
|
+
// Insert DB record (session_id=NULL, will be associated later)
|
|
1903
|
+
const imageId = imageDb.insertImage({
|
|
1904
|
+
upload_batch_id: uploadBatchId,
|
|
1905
|
+
user_uuid: userUuid,
|
|
1906
|
+
original_name: file.originalname,
|
|
1907
|
+
file_hash: fileHash,
|
|
1908
|
+
file_ext: ext,
|
|
1909
|
+
file_size: file.size,
|
|
1910
|
+
mime_type: mimeType,
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1814
1913
|
return {
|
|
1914
|
+
id: Number(imageId),
|
|
1815
1915
|
name: file.originalname,
|
|
1816
1916
|
data: `data:${mimeType};base64,${base64}`,
|
|
1817
1917
|
size: file.size,
|
|
@@ -1820,7 +1920,7 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
|
|
1820
1920
|
})
|
|
1821
1921
|
);
|
|
1822
1922
|
|
|
1823
|
-
res.json({ images: processedImages });
|
|
1923
|
+
res.json({ images: processedImages, uploadBatchId });
|
|
1824
1924
|
} catch (error) {
|
|
1825
1925
|
console.error('Error processing images:', error);
|
|
1826
1926
|
// Clean up any remaining files
|
|
@@ -2251,6 +2351,9 @@ async function startServer() {
|
|
|
2251
2351
|
// Start usage scanner service
|
|
2252
2352
|
startUsageScanner();
|
|
2253
2353
|
|
|
2354
|
+
// Start image cleanup service (30-day expiry)
|
|
2355
|
+
startImageCleanup();
|
|
2356
|
+
|
|
2254
2357
|
// Start Feishu integration service (if configured)
|
|
2255
2358
|
if (process.env.FEISHU_APP_ID && process.env.FEISHU_APP_SECRET) {
|
|
2256
2359
|
startFeishuService().catch(err =>
|
package/server/routes/admin.js
CHANGED
|
@@ -7,14 +7,22 @@ import { deleteUserDirectories, initUserDirectories } from '../services/user-dir
|
|
|
7
7
|
|
|
8
8
|
const router = express.Router();
|
|
9
9
|
|
|
10
|
-
// Admin middleware
|
|
10
|
+
// Admin middleware - allows both admin and super_admin
|
|
11
11
|
const requireAdmin = (req, res, next) => {
|
|
12
|
-
if (req.user.role !== 'admin') {
|
|
12
|
+
if (req.user.role !== 'admin' && req.user.role !== 'super_admin') {
|
|
13
13
|
return res.status(403).json({ error: 'Admin access required' });
|
|
14
14
|
}
|
|
15
15
|
next();
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// Super admin only middleware
|
|
19
|
+
const requireSuperAdmin = (req, res, next) => {
|
|
20
|
+
if (req.user.role !== 'super_admin') {
|
|
21
|
+
return res.status(403).json({ error: 'Super admin access required' });
|
|
22
|
+
}
|
|
23
|
+
next();
|
|
24
|
+
};
|
|
25
|
+
|
|
18
26
|
// Apply auth and admin middleware to all routes
|
|
19
27
|
router.use(authenticateToken);
|
|
20
28
|
router.use(requireAdmin);
|
|
@@ -108,6 +116,11 @@ router.patch('/users/:id', (req, res) => {
|
|
|
108
116
|
return res.status(404).json({ error: 'User not found' });
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
// admin cannot operate on other admin/super_admin users
|
|
120
|
+
if (req.user.role === 'admin' && ['admin', 'super_admin'].includes(user.role)) {
|
|
121
|
+
return res.status(403).json({ error: '无权操作管理员账户' });
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
userDb.updateUserStatus(id, status);
|
|
112
125
|
res.json({ success: true, message: `User status updated to ${status}` });
|
|
113
126
|
} catch (error) {
|
|
@@ -131,6 +144,11 @@ router.delete('/users/:id', async (req, res) => {
|
|
|
131
144
|
return res.status(404).json({ error: 'User not found' });
|
|
132
145
|
}
|
|
133
146
|
|
|
147
|
+
// admin cannot delete other admin/super_admin users
|
|
148
|
+
if (req.user.role === 'admin' && ['admin', 'super_admin'].includes(user.role)) {
|
|
149
|
+
return res.status(403).json({ error: '无权删除管理员账户' });
|
|
150
|
+
}
|
|
151
|
+
|
|
134
152
|
// Delete user directories
|
|
135
153
|
if (user.uuid) {
|
|
136
154
|
await deleteUserDirectories(user.uuid);
|
|
@@ -167,6 +185,11 @@ router.patch('/users/:id/password', async (req, res) => {
|
|
|
167
185
|
return res.status(404).json({ error: '用户不存在' });
|
|
168
186
|
}
|
|
169
187
|
|
|
188
|
+
// admin cannot reset password of other admin/super_admin users
|
|
189
|
+
if (req.user.role === 'admin' && ['admin', 'super_admin'].includes(user.role)) {
|
|
190
|
+
return res.status(403).json({ error: '无权重置管理员密码' });
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
// Can only reset password for password-login users
|
|
171
194
|
if (!user.password_hash) {
|
|
172
195
|
return res.status(400).json({ error: '该用户使用邮箱验证码登录,无法重置密码' });
|
|
@@ -186,6 +209,37 @@ router.patch('/users/:id/password', async (req, res) => {
|
|
|
186
209
|
}
|
|
187
210
|
});
|
|
188
211
|
|
|
212
|
+
// Update user role (super_admin only)
|
|
213
|
+
router.patch('/users/:id/role', requireSuperAdmin, (req, res) => {
|
|
214
|
+
try {
|
|
215
|
+
const { id } = req.params;
|
|
216
|
+
const { role } = req.body;
|
|
217
|
+
|
|
218
|
+
if (!['admin', 'user'].includes(role)) {
|
|
219
|
+
return res.status(400).json({ error: '只能设置为 admin 或 user' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (parseInt(id) === req.user.id) {
|
|
223
|
+
return res.status(400).json({ error: '不能修改自己的角色' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const user = userDb.getUserById(id);
|
|
227
|
+
if (!user) {
|
|
228
|
+
return res.status(404).json({ error: 'User not found' });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (user.role === 'super_admin') {
|
|
232
|
+
return res.status(403).json({ error: '不能修改超级管理员的角色' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
userDb.updateUserRole(id, role);
|
|
236
|
+
res.json({ success: true, message: '用户角色已更新' });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.error('Error updating user role:', error);
|
|
239
|
+
res.status(500).json({ error: '更新用户角色失败' });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
189
243
|
// ==================== User Spending Limits ====================
|
|
190
244
|
|
|
191
245
|
// Get user spending limits
|
|
@@ -227,9 +281,17 @@ router.patch('/users/:id/limits', (req, res) => {
|
|
|
227
281
|
}
|
|
228
282
|
}
|
|
229
283
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
284
|
+
const isSelf = parseInt(id) === req.user.id;
|
|
285
|
+
|
|
286
|
+
// admin can only set limits for themselves or for user-role accounts
|
|
287
|
+
if (req.user.role === 'admin' && !isSelf) {
|
|
288
|
+
const targetUser = userDb.getUserById(id);
|
|
289
|
+
if (!targetUser) {
|
|
290
|
+
return res.status(404).json({ error: 'User not found' });
|
|
291
|
+
}
|
|
292
|
+
if (['admin', 'super_admin'].includes(targetUser.role)) {
|
|
293
|
+
return res.status(403).json({ error: '无权修改管理员账户的限额' });
|
|
294
|
+
}
|
|
233
295
|
}
|
|
234
296
|
|
|
235
297
|
const user = userDb.getUserById(id);
|
package/server/routes/auth.js
CHANGED
|
@@ -111,13 +111,13 @@ router.post('/verify-code', async (req, res) => {
|
|
|
111
111
|
// If user doesn't exist, create new user (registration)
|
|
112
112
|
if (!user) {
|
|
113
113
|
const userCount = userDb.getUserCount();
|
|
114
|
-
const role = userCount === 0 ? '
|
|
114
|
+
const role = userCount === 0 ? 'super_admin' : 'user';
|
|
115
115
|
const uuid = uuidv4();
|
|
116
116
|
|
|
117
117
|
user = userDb.createUserWithEmail(email, uuid, role);
|
|
118
118
|
|
|
119
|
-
// Apply default total limit for
|
|
120
|
-
if (role
|
|
119
|
+
// Apply default total limit for regular users only
|
|
120
|
+
if (role === 'user') {
|
|
121
121
|
const defaultTotalLimit = settingsDb.get('default_total_limit_usd');
|
|
122
122
|
if (defaultTotalLimit !== null) {
|
|
123
123
|
userDb.updateUserLimits(user.id, parseFloat(defaultTotalLimit), null);
|
|
@@ -276,6 +276,7 @@ router.patch('/change-password', authenticateToken, async (req, res) => {
|
|
|
276
276
|
// Get current user's spending limit status
|
|
277
277
|
router.get('/limit-status', authenticateToken, (req, res) => {
|
|
278
278
|
try {
|
|
279
|
+
if (req.user.role === 'super_admin') return res.json({ allowed: true });
|
|
279
280
|
const status = usageDb.checkUserLimits(req.user.uuid);
|
|
280
281
|
res.json(status);
|
|
281
282
|
} catch (error) {
|
package/server/routes/usage.js
CHANGED
|
@@ -7,7 +7,7 @@ const router = express.Router();
|
|
|
7
7
|
|
|
8
8
|
// Admin middleware
|
|
9
9
|
const requireAdmin = (req, res, next) => {
|
|
10
|
-
if (req.user.role !== 'admin') {
|
|
10
|
+
if (req.user.role !== 'admin' && req.user.role !== 'super_admin') {
|
|
11
11
|
return res.status(403).json({ error: 'Admin access required' });
|
|
12
12
|
}
|
|
13
13
|
next();
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { queryClaudeSDK } from '../../claude-sdk.js';
|
|
19
|
-
import { usageDb } from '../../database/db.js';
|
|
19
|
+
import { usageDb, userDb } from '../../database/db.js';
|
|
20
20
|
import { feishuDb } from './feishu-db.js';
|
|
21
21
|
import fs from 'fs/promises';
|
|
22
22
|
import path from 'path';
|
|
@@ -413,14 +413,17 @@ async function runQuery({
|
|
|
413
413
|
larkClient,
|
|
414
414
|
pendingApprovals,
|
|
415
415
|
}) {
|
|
416
|
-
//
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
416
|
+
// 限额检查(super_admin 豁免)
|
|
417
|
+
const feishuUser = userDb.getUserByUuid(userUuid);
|
|
418
|
+
if (feishuUser?.role !== 'super_admin') {
|
|
419
|
+
const limitStatus = usageDb.checkUserLimits(userUuid);
|
|
420
|
+
if (!limitStatus.allowed) {
|
|
421
|
+
const reason = limitStatus.reason === 'daily_limit_exceeded'
|
|
422
|
+
? `每日用量已达上限 ($${limitStatus.limit.toFixed(2)})`
|
|
423
|
+
: `总用量已达上限 ($${limitStatus.limit.toFixed(2)})`;
|
|
424
|
+
await larkClient.sendText(chatId, `⚠️ ${reason},请联系管理员。`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
424
427
|
}
|
|
425
428
|
|
|
426
429
|
const { claude_session_id, cwd, permission_mode } = state || {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-cleanup.js — 定时清理过期图片
|
|
3
|
+
*
|
|
4
|
+
* - 每小时运行一次
|
|
5
|
+
* - 删除 30 天以上的图片记录和文件
|
|
6
|
+
* - 删除 24 小时以上未关联 session 的孤儿图片
|
|
7
|
+
* - 删文件前检查同 hash 是否还有其他引用(去重安全)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { imageDb } from '../database/db.js';
|
|
11
|
+
import { deleteImageFile } from './image-storage.js';
|
|
12
|
+
|
|
13
|
+
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
14
|
+
const RETENTION_DAYS = 30;
|
|
15
|
+
const ORPHAN_HOURS = 24;
|
|
16
|
+
|
|
17
|
+
let cleanupInterval = null;
|
|
18
|
+
|
|
19
|
+
export function startImageCleanup() {
|
|
20
|
+
console.log('[ImageCleanup] Starting image cleanup service');
|
|
21
|
+
|
|
22
|
+
// Initial cleanup after 30s
|
|
23
|
+
setTimeout(() => runCleanup(), 30000);
|
|
24
|
+
|
|
25
|
+
// Schedule periodic cleanup
|
|
26
|
+
cleanupInterval = setInterval(() => runCleanup(), CLEANUP_INTERVAL_MS);
|
|
27
|
+
console.log(`[ImageCleanup] Scheduled to run every ${CLEANUP_INTERVAL_MS / 1000 / 60} minutes`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function stopImageCleanup() {
|
|
31
|
+
if (cleanupInterval) {
|
|
32
|
+
clearInterval(cleanupInterval);
|
|
33
|
+
cleanupInterval = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function runCleanup() {
|
|
38
|
+
try {
|
|
39
|
+
let deletedFiles = 0;
|
|
40
|
+
let deletedRecords = 0;
|
|
41
|
+
|
|
42
|
+
// 1. Clean expired images (>30 days)
|
|
43
|
+
const expiredImages = imageDb.getExpiredImages(RETENTION_DAYS);
|
|
44
|
+
if (expiredImages.length > 0) {
|
|
45
|
+
const expiredIds = expiredImages.map(img => img.id);
|
|
46
|
+
|
|
47
|
+
// Delete files only when no other records reference the same hash
|
|
48
|
+
for (const img of expiredImages) {
|
|
49
|
+
const otherRefs = imageDb.countByHash(img.user_uuid, img.file_hash, expiredIds);
|
|
50
|
+
if (otherRefs === 0) {
|
|
51
|
+
await deleteImageFile(img.user_uuid, img.file_hash, img.file_ext);
|
|
52
|
+
deletedFiles++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
imageDb.deleteImagesByIds(expiredIds);
|
|
57
|
+
deletedRecords += expiredIds.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Clean orphaned uploads (session_id IS NULL, >24h)
|
|
61
|
+
const orphaned = imageDb.deleteOrphanedImages(ORPHAN_HOURS);
|
|
62
|
+
if (orphaned.length > 0) {
|
|
63
|
+
for (const img of orphaned) {
|
|
64
|
+
const otherRefs = imageDb.countByHash(img.user_uuid, img.file_hash, [img.id]);
|
|
65
|
+
if (otherRefs === 0) {
|
|
66
|
+
await deleteImageFile(img.user_uuid, img.file_hash, img.file_ext);
|
|
67
|
+
deletedFiles++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
deletedRecords += orphaned.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (deletedRecords > 0) {
|
|
74
|
+
console.log(`[ImageCleanup] Cleaned up ${deletedRecords} records, ${deletedFiles} files`);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[ImageCleanup] Error during cleanup:', error);
|
|
78
|
+
}
|
|
79
|
+
}
|