From e4c1ff790084cad85e725f6ae8aa044a3477101e Mon Sep 17 00:00:00 2001 From: hjjjj <1311711287@qq.com> Date: Tue, 24 Mar 2026 16:57:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E9=9D=A2=E6=9D=BF=E5=92=8C=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增管理员面板的静态文件支持,提供用户列表、角色切换和权限配置功能。更新了环境变量示例以包含管理员邮箱,并在auth.js中实现了管理员登录和权限审计日志功能。更新README文档以说明管理员面板的使用和相关接口。 --- .env.example | 1 + .gitea/workflows/deploy.yml | 27 ++ README.md | 8 + admin-web/index.html | 657 ++++++++++++++++++++++++++++++++++++ deploy.bat | 32 +- routes/auth.js | 486 ++++++++++++++++++++++---- routes/chat.js | 8 + server.js | 171 +++++++--- 8 files changed, 1283 insertions(+), 107 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 admin-web/index.html diff --git a/.env.example b/.env.example index 9fc646e..addf9e1 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ DB_NAME=skills_market JWT_SECRET=your-jwt-secret-key-change-in-production JWT_EXPIRES_IN=7d +ADMIN_EMAIL=admin@example.com # 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔 WHITELIST_EMAILS=1311711287@qq.com diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..57e926d --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,27 @@ +name: Deploy skills-market-server + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy on remote server + env: + DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + run: | + set -eu + DEPLOY_PORT="${DEPLOY_PORT:-22}" + mkdir -p ~/.ssh + printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + ssh -i ~/.ssh/id_ed25519 -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "cmd /c \"cd /d ${DEPLOY_PATH} && deploy.bat\"" diff --git a/README.md b/README.md index 9367a05..23ad657 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ npm run dev | `/api/chat/sessions/:id/messages/all` | DELETE | 清空该会话全部消息 | | `/api/chat/sessions/:id/messages/from/:fromSort` | DELETE | 删除 `sort_order >= fromSort` 的消息(截断) | +### 管理员看板 `/admin` + +- 地址:`http://:3001/admin` +- 登录方式:管理员邮箱验证码登录(`/api/auth/send-code` + `/api/admin/login`) +- 管理能力:用户列表、角色切换、权限配置(模式可见性 / 技能页 / 智能体页 / SSH 页 / 开发者模式) +- 相关接口:`/api/admin/login`、`/api/admin/users`、`/api/admin/users/:userId/permissions`、`/api/admin/audit-logs` +- 权限规则:`ADMIN_EMAIL` 为初始管理员(root admin);其他管理员只能编辑非 admin 用户,且不能修改自己的权限 + ## 环境变量 创建 `.env` 文件: diff --git a/admin-web/index.html b/admin-web/index.html new file mode 100644 index 0000000..af49880 --- /dev/null +++ b/admin-web/index.html @@ -0,0 +1,657 @@ + + + + + + 权限管理平台 + + + +
+
+

权限管理平台

+
管理员可在此配置用户模式、页面访问、SSH 与开发者模式权限。
+
Admin Console
+
+ +
+

管理员登录

+
+ + + + +
+

复用邮箱验证码登录;登录后仅 `role=admin` 可访问管理接口

+
+ + + + +
+ +
+ + + + diff --git a/deploy.bat b/deploy.bat index 0546522..586031a 100644 --- a/deploy.bat +++ b/deploy.bat @@ -1,7 +1,31 @@ @echo off +setlocal set "PATH=%PATH%;C:\Program Files\Git\cmd" -git fetch origin -git reset --hard origin/master -npm install --omit=dev -pm2 restart skills-market-server +set "APP_NAME=skills-market-server" + +git fetch origin || goto :error +git reset --hard origin/master || goto :error + +call npm ci --omit=dev +if errorlevel 1 ( + call npm install --omit=dev || goto :error +) + +pm2 describe %APP_NAME% >nul 2>&1 +if errorlevel 1 ( + pm2 start server.js --name %APP_NAME% --update-env || goto :error +) else ( + pm2 restart %APP_NAME% --update-env + if errorlevel 1 ( + pm2 delete %APP_NAME% >nul 2>&1 + pm2 start server.js --name %APP_NAME% --update-env || goto :error + ) +) + +pm2 save >nul 2>&1 echo Deploy done. +exit /b 0 + +:error +echo Deploy failed. +exit /b 1 diff --git a/routes/auth.js b/routes/auth.js index f2ffaff..da56829 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,35 +1,225 @@ const jwt = require('jsonwebtoken') +const { ObjectId } = require('mongodb') const { sendVerificationCode, verifyCode } = require('../services/auth') const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production' const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d' +const ROOT_ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase() const WHITELIST_CODE = process.env.WHITELIST_CODE || '888888' const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '') - .split(',').map(e => e.trim().toLowerCase()).filter(Boolean) + .split(',') + .map((e) => e.trim().toLowerCase()) + .filter(Boolean) + +const ALL_MODES = ['chat', 'clarify', 'cowork', 'video', 'code'] + +function getDefaultPermissions() { + return { + allowedModes: ['chat', 'clarify'], + canViewSkillsPage: false, + canViewAgentsPage: false, + canUseSshPage: false, + canUseDeveloperMode: false + } +} + +function getAdminDefaultPermissions() { + return { + allowedModes: [...ALL_MODES], + canViewSkillsPage: true, + canViewAgentsPage: true, + canUseSshPage: true, + canUseDeveloperMode: true + } +} + +function sanitizePermissions(raw) { + const defaults = getDefaultPermissions() + if (!raw || typeof raw !== 'object') return defaults + const next = { ...defaults } + const allowedModes = Array.isArray(raw.allowedModes) + ? raw.allowedModes.filter((m) => ALL_MODES.includes(m)) + : defaults.allowedModes + next.allowedModes = allowedModes.length > 0 ? allowedModes : ['chat'] + next.canViewSkillsPage = !!raw.canViewSkillsPage + next.canViewAgentsPage = !!raw.canViewAgentsPage + next.canUseSshPage = !!raw.canUseSshPage + next.canUseDeveloperMode = !!raw.canUseDeveloperMode + return next +} + +function normalizeRole(role) { + return role === 'admin' ? 'admin' : 'user' +} + +function isRootAdminEmail(email) { + return !!ROOT_ADMIN_EMAIL && String(email || '').toLowerCase() === ROOT_ADMIN_EMAIL +} + +function sanitizeUserForClient(userDoc) { + const role = normalizeRole(userDoc.role) + const permissions = sanitizePermissions(userDoc.permissions) + const isRootAdmin = role === 'admin' && isRootAdminEmail(userDoc.email) + return { + id: userDoc._id.toString(), + email: userDoc.email, + nickname: userDoc.nickname, + avatar: userDoc.avatar ?? null, + created_at: userDoc.created_at, + role, + permissions, + is_root_admin: isRootAdmin + } +} + +function canAccessFeature(user, permissionKey) { + const perms = sanitizePermissions(user.permissions) + return !!perms[permissionKey] +} + +function canUseMode(user, mode) { + const perms = sanitizePermissions(user.permissions) + return perms.allowedModes.includes(mode) +} + +function isAdmin(user) { + return normalizeRole(user.role) === 'admin' +} + +function isRootAdmin(user) { + return isAdmin(user) && isRootAdminEmail(user.email) +} + +function getGrantablePermissions(actor) { + if (isRootAdmin(actor)) { + return { + allowedModes: [...ALL_MODES], + canViewSkillsPage: true, + canViewAgentsPage: true, + canUseSshPage: true, + canUseDeveloperMode: true + } + } + return sanitizePermissions(actor.permissions) +} + +function clampPermissionsByGrantable(perms, grantable) { + return { + allowedModes: perms.allowedModes.filter((m) => grantable.allowedModes.includes(m)), + canViewSkillsPage: !!perms.canViewSkillsPage && !!grantable.canViewSkillsPage, + canViewAgentsPage: !!perms.canViewAgentsPage && !!grantable.canViewAgentsPage, + canUseSshPage: !!perms.canUseSshPage && !!grantable.canUseSshPage, + canUseDeveloperMode: !!perms.canUseDeveloperMode && !!grantable.canUseDeveloperMode + } +} + +function signUserToken(userDoc) { + return jwt.sign( + { + userId: userDoc._id.toString(), + email: userDoc.email, + role: normalizeRole(userDoc.role) + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ) +} function createAuthRoutes(db) { const usersCollection = db.collection('users') + const permissionAuditCollection = db.collection('permission_audit_logs') + + async function ensureUserDefaults(userDoc) { + const now = new Date() + const role = isRootAdminEmail(userDoc.email) ? 'admin' : normalizeRole(userDoc.role) + const permissionBase = role === 'admin' ? getAdminDefaultPermissions() : getDefaultPermissions() + const permissions = sanitizePermissions({ ...permissionBase, ...(userDoc.permissions || {}) }) + const needRoleFix = userDoc.role !== role + const needPermFix = JSON.stringify(userDoc.permissions || null) !== JSON.stringify(permissions) + if (needRoleFix || needPermFix) { + await usersCollection.updateOne( + { _id: userDoc._id }, + { $set: { role, permissions, updated_at: now } } + ) + } + return { ...userDoc, role, permissions } + } + + async function appendPermissionAuditLog({ actor, targetBefore, targetAfter, req }) { + await permissionAuditCollection.insertOne({ + actor_user_id: actor.id, + actor_email: actor.email, + actor_nickname: actor.nickname || '', + actor_role: actor.role, + actor_is_root_admin: isRootAdmin(actor), + target_user_id: targetBefore._id.toString(), + target_email: targetBefore.email, + target_nickname: targetBefore.nickname || '', + target_role_before: normalizeRole(targetBefore.role), + target_role_after: normalizeRole(targetAfter.role), + target_permissions_before: sanitizePermissions(targetBefore.permissions), + target_permissions_after: sanitizePermissions(targetAfter.permissions), + changed_by_self: actor.id === targetBefore._id.toString(), + ip: req.ip || '', + user_agent: req.headers['user-agent'] || '', + created_at: new Date() + }) + } return { + async ensureAdminBootstrap() { + try { + if (!ROOT_ADMIN_EMAIL) return + const now = new Date() + const existing = await usersCollection.findOne({ email: ROOT_ADMIN_EMAIL }) + if (!existing) { + await usersCollection.insertOne({ + email: ROOT_ADMIN_EMAIL, + nickname: ROOT_ADMIN_EMAIL.split('@')[0], + avatar: null, + role: 'admin', + permissions: getAdminDefaultPermissions(), + status: 'active', + created_at: now, + updated_at: now, + last_login: null + }) + console.log('[Auth] Root admin bootstrap user created') + return + } + await usersCollection.updateOne( + { _id: existing._id }, + { + $set: { + role: 'admin', + permissions: sanitizePermissions({ + ...getAdminDefaultPermissions(), + ...(existing.permissions || {}) + }), + updated_at: now + } + } + ) + console.log('[Auth] Root admin bootstrap user ensured') + } catch (err) { + console.error('[Auth] ensureAdminBootstrap error:', err) + } + }, + async sendCode(req, res) { try { const { email } = req.body - if (!email) { return res.status(400).json({ success: false, error: '邮箱不能为空' }) } - if (WHITELIST_EMAILS.includes(email.toLowerCase())) { return res.json({ success: true, message: '验证码已发送' }) } - const result = await sendVerificationCode(db, email.toLowerCase()) - if (!result.success) { return res.status(400).json(result) } - res.json({ success: true, message: '验证码已发送' }) } catch (err) { console.error('[Auth] Send code error:', err) @@ -40,11 +230,9 @@ function createAuthRoutes(db) { async login(req, res) { try { const { email, code, nickname } = req.body - if (!email || !code) { return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' }) } - const emailLower = email.toLowerCase() if (WHITELIST_EMAILS.includes(emailLower)) { @@ -59,50 +247,36 @@ function createAuthRoutes(db) { } let user = await usersCollection.findOne({ email: emailLower }) - + const now = new Date() if (!user) { + const role = isRootAdminEmail(emailLower) ? 'admin' : 'user' const newUser = { email: emailLower, nickname: nickname || emailLower.split('@')[0], avatar: null, - created_at: new Date(), - updated_at: new Date(), - last_login: new Date(), + role, + permissions: role === 'admin' ? getAdminDefaultPermissions() : getDefaultPermissions(), + created_at: now, + updated_at: now, + last_login: now, status: 'active' } const result = await usersCollection.insertOne(newUser) user = { ...newUser, _id: result.insertedId } } else { + user = await ensureUserDefaults(user) await usersCollection.updateOne( { _id: user._id }, - { - $set: { - last_login: new Date(), - updated_at: new Date() - } - } + { $set: { last_login: now, updated_at: now } } ) + user = { ...user, last_login: now, updated_at: now } } - const token = jwt.sign( - { - userId: user._id.toString(), - email: user.email - }, - JWT_SECRET, - { expiresIn: JWT_EXPIRES_IN } - ) - + const token = signUserToken(user) res.json({ success: true, token, - user: { - id: user._id, - email: user.email, - nickname: user.nickname, - avatar: user.avatar, - created_at: user.created_at - } + user: sanitizeUserForClient(user) }) } catch (err) { console.error('[Auth] Login error:', err) @@ -110,32 +284,68 @@ function createAuthRoutes(db) { } }, + async adminLogin(req, res) { + try { + const { email, code } = req.body + if (!email || !code) { + return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' }) + } + const emailLower = String(email).trim().toLowerCase() + + if (WHITELIST_EMAILS.includes(emailLower)) { + if (code !== WHITELIST_CODE) { + return res.status(400).json({ success: false, error: '验证码错误' }) + } + } else { + const verifyResult = await verifyCode(db, emailLower, code) + if (!verifyResult.success) { + return res.status(400).json(verifyResult) + } + } + + const user = await usersCollection.findOne({ email: emailLower }) + if (!user) { + return res.status(403).json({ success: false, error: '仅管理员可登录管理系统' }) + } + const safeUser = await ensureUserDefaults(user) + if (!isAdmin(safeUser)) { + return res.status(403).json({ success: false, error: '仅管理员可登录管理系统' }) + } + + await usersCollection.updateOne( + { _id: safeUser._id }, + { $set: { last_login: new Date(), updated_at: new Date() } } + ) + + const token = signUserToken(safeUser) + res.json({ + success: true, + token, + user: sanitizeUserForClient(safeUser) + }) + } catch (err) { + console.error('[Auth] Admin login error:', err) + res.status(500).json({ success: false, error: err.message }) + } + }, + async verifyToken(req, res, next) { try { const authHeader = req.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ success: false, error: '未登录' }) } - const token = authHeader.split(' ')[1] - try { const decoded = jwt.verify(token, JWT_SECRET) - const user = await usersCollection.findOne({ _id: new (require('mongodb').ObjectId)(decoded.userId) }) - + const user = await usersCollection.findOne({ _id: new ObjectId(decoded.userId) }) if (!user) { return res.status(401).json({ success: false, error: '用户不存在' }) } - - req.user = { - id: user._id.toString(), - email: user.email, - nickname: user.nickname, - avatar: user.avatar - } + const safeUser = await ensureUserDefaults(user) + req.user = sanitizeUserForClient(safeUser) next() - } catch (jwtErr) { + } catch { return res.status(401).json({ success: false, error: 'Token无效或已过期' }) } } catch (err) { @@ -144,7 +354,24 @@ function createAuthRoutes(db) { } }, - async getProfile(req, res) { + async verifyAdmin(req, res, next) { + await this.verifyToken(req, res, () => { + if (!isAdmin(req.user)) { + return res.status(403).json({ success: false, error: '需要管理员权限' }) + } + next() + }) + }, + + hasPermission(user, permissionKey) { + return canAccessFeature(user, permissionKey) + }, + + isModeAllowed(user, mode) { + return canUseMode(user, mode) + }, + + getProfile(req, res) { try { res.json({ success: true, user: req.user }) } catch (err) { @@ -157,25 +384,164 @@ function createAuthRoutes(db) { try { const { nickname, avatar } = req.body const updateData = { updated_at: new Date() } - if (nickname) updateData.nickname = nickname if (avatar) updateData.avatar = avatar - - await usersCollection.updateOne( - { _id: new (require('mongodb').ObjectId)(req.user.id) }, - { $set: updateData } - ) - - res.json({ - success: true, - user: { ...req.user, ...updateData } - }) + await usersCollection.updateOne({ _id: new ObjectId(req.user.id) }, { $set: updateData }) + const updatedUser = await usersCollection.findOne({ _id: new ObjectId(req.user.id) }) + const safeUser = updatedUser + ? sanitizeUserForClient(updatedUser) + : { ...req.user, ...updateData } + res.json({ success: true, user: safeUser }) } catch (err) { console.error('[Auth] Update profile error:', err) res.status(500).json({ success: false, error: err.message }) } + }, + + async listUsers(req, res) { + try { + const keyword = String(req.query.query || '').trim() + const filter = keyword + ? { + $or: [ + { email: { $regex: keyword, $options: 'i' } }, + { nickname: { $regex: keyword, $options: 'i' } } + ] + } + : {} + const list = await usersCollection + .find(filter, { + projection: { + email: 1, + nickname: 1, + avatar: 1, + role: 1, + permissions: 1, + created_at: 1, + updated_at: 1, + last_login: 1, + status: 1 + } + }) + .sort({ created_at: -1 }) + .toArray() + const actorIsRoot = isRootAdmin(req.user) + const grantable = getGrantablePermissions(req.user) + const users = list.map((u) => { + const safe = sanitizeUserForClient(u) + const targetIsAdmin = safe.role === 'admin' + const targetIsSelf = safe.id === req.user.id + const editable = actorIsRoot + ? true + : !targetIsAdmin && !targetIsSelf + return { ...safe, editable } + }) + res.json({ + success: true, + total: users.length, + users, + admin_capabilities: { + grantable_permissions: grantable, + canEditAdmins: actorIsRoot, + canEditSelf: actorIsRoot + } + }) + } catch (err) { + console.error('[Auth] listUsers error:', err) + res.status(500).json({ success: false, error: err.message }) + } + }, + + async updateUserPermissions(req, res) { + try { + const userId = req.params.userId + if (!userId) { + return res.status(400).json({ success: false, error: 'userId required' }) + } + const target = await usersCollection.findOne({ _id: new ObjectId(userId) }) + if (!target) { + return res.status(404).json({ success: false, error: '用户不存在' }) + } + + const actor = req.user + const actorIsRoot = isRootAdmin(actor) + const grantable = getGrantablePermissions(actor) + const targetRole = normalizeRole(target.role) + const isSelf = target._id.toString() === actor.id + const patch = req.body || {} + const requestedRole = + patch.role !== undefined ? normalizeRole(patch.role) : targetRole + + if (!actorIsRoot) { + if (isSelf) { + return res.status(403).json({ success: false, error: '不能修改自己的权限' }) + } + if (targetRole === 'admin') { + return res.status(403).json({ success: false, error: '不能修改管理员权限' }) + } + if (requestedRole === 'admin') { + return res.status(403).json({ success: false, error: '仅初始管理员可授予管理员权限' }) + } + } + + const nextRole = requestedRole + const mergedRawPerms = patch.permissions + ? { ...(target.permissions || {}), ...patch.permissions } + : (target.permissions || {}) + const nextPermissionsBase = sanitizePermissions(mergedRawPerms) + const nextPermissions = actorIsRoot + ? nextPermissionsBase + : clampPermissionsByGrantable(nextPermissionsBase, grantable) + const now = new Date() + + await usersCollection.updateOne( + { _id: target._id }, + { + $set: { + role: nextRole, + permissions: nextPermissions, + updated_at: now + } + } + ) + + const updated = await usersCollection.findOne({ _id: target._id }) + await appendPermissionAuditLog({ + actor, + targetBefore: target, + targetAfter: updated, + req + }) + res.json({ success: true, user: sanitizeUserForClient(updated) }) + } catch (err) { + console.error('[Auth] updateUserPermissions error:', err) + res.status(500).json({ success: false, error: err.message }) + } + }, + + async listPermissionAuditLogs(req, res) { + try { + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200) + const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0) + const logs = await permissionAuditCollection + .find({}, { projection: { _id: 0 } }) + .sort({ created_at: -1 }) + .skip(offset) + .limit(limit) + .toArray() + const total = await permissionAuditCollection.countDocuments({}) + res.json({ success: true, total, logs }) + } catch (err) { + console.error('[Auth] listPermissionAuditLogs error:', err) + res.status(500).json({ success: false, error: err.message }) + } } } } -module.exports = { createAuthRoutes } +module.exports = { + createAuthRoutes, + ALL_MODES, + getDefaultPermissions, + sanitizePermissions +} diff --git a/routes/chat.js b/routes/chat.js index 6128aa9..a2d0485 100644 --- a/routes/chat.js +++ b/routes/chat.js @@ -56,6 +56,12 @@ function registerChatRoutes(app, db, authRoutes) { } const userObjectId = (req) => new ObjectId(req.user.id) + const assertModeAllowed = (req, res, mode) => { + if (!mode) return true + if (authRoutes.isModeAllowed(req.user, mode)) return true + res.status(403).json({ success: false, error: `无权使用模式: ${mode}` }) + return false + } // --- Sessions --- @@ -113,6 +119,7 @@ function registerChatRoutes(app, db, authRoutes) { return res.status(400).json({ success: false, error: 'id required' }) } const now = Date.now() + if (!assertModeAllowed(req, res, b.mode ?? 'chat')) return const doc = { _id: id, user_id: uid, @@ -164,6 +171,7 @@ function registerChatRoutes(app, db, authRoutes) { else $set[k] = patch[k] } } + if (patch.mode !== undefined && !assertModeAllowed(req, res, patch.mode)) return if (Object.keys($set).length === 0) { return res.json({ success: true }) } diff --git a/server.js b/server.js index 33b2db1..b785647 100644 --- a/server.js +++ b/server.js @@ -14,12 +14,36 @@ const UPDATES_DIR = process.env.UPDATES_DIR || 'C:\\apps\\skills-market-server\\ app.use(cors()) app.use(express.json({ limit: '50mb' })) app.use('/updates', express.static(UPDATES_DIR)) +app.use('/admin', express.static(path.join(__dirname, 'admin-web'))) let db let skillsCollection let agentsCollection let authRoutes +function requireAuth(handler) { + return (req, res) => { + authRoutes.verifyToken(req, res, () => handler(req, res)) + } +} + +function requireAdmin(handler) { + return (req, res) => { + authRoutes.verifyAdmin(req, res, () => handler(req, res)) + } +} + +function requirePermission(permissionKey, featureName) { + return (req, res, next) => { + authRoutes.verifyToken(req, res, () => { + if (!authRoutes.hasPermission(req.user, permissionKey)) { + return res.status(403).json({ success: false, error: `无权访问${featureName}` }) + } + next() + }) + } +} + async function connectDB() { const client = new MongoClient(MONGO_URL) await client.connect() @@ -28,6 +52,7 @@ async function connectDB() { agentsCollection = db.collection('agents') authRoutes = createAuthRoutes(db) console.log(`[MongoDB] Connected to ${DB_NAME}`) + await authRoutes.ensureAdminBootstrap() await skillsCollection.createIndex({ name: 1 }, { unique: true }) await skillsCollection.createIndex({ owner: 1 }) @@ -44,6 +69,9 @@ async function connectDB() { await codesCollection.createIndex({ email: 1 }) await codesCollection.createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }) + const permissionAuditCollection = db.collection('permission_audit_logs') + await permissionAuditCollection.createIndex({ created_at: -1 }) + const { ensureChatIndexes, registerChatRoutes } = require('./routes/chat') await ensureChatIndexes(db) registerChatRoutes(app, db, authRoutes) @@ -68,6 +96,7 @@ function injectConfidentialityInstruction(files) { } const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes +const PUBLIC_VISIBILITY_FILTER = { $ne: false } function getActiveLock(doc) { if (!doc?.lock?.userId || !doc?.lock?.at) return null @@ -112,6 +141,22 @@ app.post('/api/auth/login', async (req, res) => { await authRoutes.login(req, res) }) +app.post('/api/admin/login', async (req, res) => { + await authRoutes.adminLogin(req, res) +}) + +app.get('/api/admin/users', requireAdmin(async (req, res) => { + await authRoutes.listUsers(req, res) +})) + +app.patch('/api/admin/users/:userId/permissions', requireAdmin(async (req, res) => { + await authRoutes.updateUserPermissions(req, res) +})) + +app.get('/api/admin/audit-logs', requireAdmin(async (req, res) => { + await authRoutes.listPermissionAuditLogs(req, res) +})) + app.get('/api/auth/profile', async (req, res, next) => { authRoutes.verifyToken(req, res, async () => { await authRoutes.getProfile(req, res) @@ -124,11 +169,14 @@ app.put('/api/auth/profile', async (req, res, next) => { }) }) +app.use('/api/skills', requirePermission('canViewSkillsPage', '技能页面')) +app.use('/api/agents', requirePermission('canViewAgentsPage', '智能体页面')) + app.get('/api/skills', async (req, res) => { try { const { query, offset = 0, limit = 50 } = req.query - let filter = { is_public: true } + let filter = { is_public: PUBLIC_VISIBILITY_FILTER } if (query && query.trim()) { const q = query.trim().toLowerCase() filter.$or = [ @@ -180,7 +228,7 @@ app.get('/api/skills/:name', async (req, res) => { try { const skill = await skillsCollection.findOne({ name: req.params.name, - is_public: true + is_public: PUBLIC_VISIBILITY_FILTER }) if (!skill) { @@ -212,7 +260,7 @@ app.get('/api/skills/:name/download', async (req, res) => { try { const skill = await skillsCollection.findOne({ name: req.params.name, - is_public: true + is_public: PUBLIC_VISIBILITY_FILTER }) if (!skill) { @@ -236,13 +284,16 @@ app.get('/api/skills/:name/download', async (req, res) => { } }) -app.post('/api/skills/:name/lock', async (req, res) => { +app.post('/api/skills/:name/execute', requireAuth(async (req, res) => { try { - const { scriptPath, args = [] } = req.body + const { scriptPath, args = [] } = req.body || {} + if (!scriptPath || typeof scriptPath !== 'string') { + return res.status(400).json({ success: false, error: 'scriptPath is required' }) + } const skill = await skillsCollection.findOne({ name: req.params.name, - is_public: true + is_public: PUBLIC_VISIBILITY_FILTER }) if (!skill) { @@ -302,7 +353,7 @@ app.post('/api/skills/:name/lock', async (req, res) => { console.error('[API] Execute skill script error:', err) res.status(500).json({ success: false, error: err.message }) } -}) +})) // 获取技能的单个文件内容(用于 agent 按需读取) app.get('/api/skills/:name/files/*', async (req, res) => { @@ -310,7 +361,7 @@ app.get('/api/skills/:name/files/*', async (req, res) => { const filePath = req.params[0] // 捕获通配符部分 const skill = await skillsCollection.findOne({ name: req.params.name, - is_public: true + is_public: PUBLIC_VISIBILITY_FILTER }) if (!skill) { @@ -337,9 +388,17 @@ app.get('/api/skills/:name/files/*', async (req, res) => { app.post('/api/skills/:name/lock', async (req, res) => { authRoutes.verifyToken(req, res, async () => { try { + const skill = await skillsCollection.findOne({ name: req.params.name }) + if (!skill) { + return res.status(404).json({ success: false, error: 'Skill not found' }) + } + const activeLock = getActiveLock(skill) + if (activeLock && activeLock.userId !== req.user.id) { + return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by }) + } await skillsCollection.updateOne( - { name: req.params.name }, - { $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date() } } } + { _id: skill._id }, + { $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date().toISOString() } } } ) res.json({ success: true }) } catch (err) { @@ -351,8 +410,17 @@ app.post('/api/skills/:name/lock', async (req, res) => { app.delete('/api/skills/:name/lock', async (req, res) => { authRoutes.verifyToken(req, res, async () => { try { + const skill = await skillsCollection.findOne({ name: req.params.name }) + if (!skill) { + return res.status(404).json({ success: false, error: 'Skill not found' }) + } + const activeLock = getActiveLock(skill) + const isAdmin = req.user.role === 'admin' + if (activeLock && activeLock.userId !== req.user.id && !isAdmin) { + return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' }) + } await skillsCollection.updateOne( - { name: req.params.name, 'lock.userId': req.user.id }, + { _id: skill._id }, { $unset: { lock: '' } } ) res.json({ success: true }) @@ -423,6 +491,14 @@ app.post('/api/skills/:name/publish', async (req, res) => { const existingSkill = await skillsCollection.findOne({ name: skillName }) if (existingSkill) { + const activeLock = getActiveLock(existingSkill) + if (activeLock && activeLock.userId !== userId) { + return res.status(423).json({ + success: false, + error: `${activeLock.by} 正在编辑,暂时不能发布`, + locked_by: activeLock.by + }) + } const remoteModifiedTime = new Date(existingSkill.updated_at).getTime() const localModifiedTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0 const { force } = req.body @@ -584,24 +660,22 @@ app.get('/api/skills/:name/versions/:version', async (req, res) => { } }) -app.delete('/api/skills/:name', async (req, res) => { - authRoutes.verifyToken(req, res, async () => { - try { - const skill = await skillsCollection.findOne({ name: req.params.name }) +app.delete('/api/skills/:name', requireAdmin(async (req, res) => { + try { + const skill = await skillsCollection.findOne({ name: req.params.name }) - if (!skill) { - return res.status(404).json({ success: false, error: 'Skill not found' }) - } - - await skillsCollection.deleteOne({ _id: skill._id }) - - res.json({ success: true }) - } catch (err) { - console.error('[API] Delete skill error:', err) - res.status(500).json({ success: false, error: err.message }) + if (!skill) { + return res.status(404).json({ success: false, error: 'Skill not found' }) } - }) -}) + + await skillsCollection.deleteOne({ _id: skill._id }) + + res.json({ success: true }) + } catch (err) { + console.error('[API] Delete skill error:', err) + res.status(500).json({ success: false, error: err.message }) + } +})) app.get('/api/health', (req, res) => { res.json({ @@ -613,9 +687,9 @@ app.get('/api/health', (req, res) => { app.get('/api/stats', async (req, res) => { try { - const totalSkills = await skillsCollection.countDocuments({ is_public: true }) + const totalSkills = await skillsCollection.countDocuments({ is_public: PUBLIC_VISIBILITY_FILTER }) const totalDownloads = await skillsCollection.aggregate([ - { $match: { is_public: true } }, + { $match: { is_public: PUBLIC_VISIBILITY_FILTER } }, { $group: { _id: null, total: { $sum: '$downloads' } } } ]).toArray() @@ -637,7 +711,7 @@ app.get('/api/stats', async (req, res) => { app.get('/api/agents', async (req, res) => { try { const { query, offset = 0, limit = 100 } = req.query - let filter = { is_public: true } + let filter = { is_public: PUBLIC_VISIBILITY_FILTER } if (query && query.trim()) { const q = query.trim() filter.$or = [ @@ -690,7 +764,7 @@ app.get('/api/agents/mine', async (req, res, next) => { app.get('/api/agents/:name', async (req, res) => { try { - const agent = await agentsCollection.findOne({ name: req.params.name, is_public: true }) + const agent = await agentsCollection.findOne({ name: req.params.name, is_public: PUBLIC_VISIBILITY_FILTER }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) res.json({ success: true, agent: { id: agent._id, name: agent.name, description: agent.description, owner: agent.owner, content: agent.content, updated_at: agent.updated_at } }) } catch (err) { @@ -711,6 +785,14 @@ app.post('/api/agents/:name/publish', async (req, res) => { const existing = await agentsCollection.findOne({ name: agentName }) if (existing) { + const activeLock = getActiveLock(existing) + if (activeLock && activeLock.userId !== userId) { + return res.status(423).json({ + success: false, + error: `${activeLock.by} 正在编辑,暂时不能发布`, + locked_by: activeLock.by + }) + } const remoteTime = new Date(existing.updated_at).getTime() const localTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0 const { force } = req.body @@ -754,18 +836,16 @@ app.post('/api/agents/:name/publish', async (req, res) => { }) }) -app.delete('/api/agents/:name', async (req, res) => { - authRoutes.verifyToken(req, res, async () => { - try { - const agent = await agentsCollection.findOne({ name: req.params.name }) - if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) - await agentsCollection.deleteOne({ _id: agent._id }) - res.json({ success: true }) - } catch (err) { - res.status(500).json({ success: false, error: err.message }) - } - }) -}) +app.delete('/api/agents/:name', requireAdmin(async (req, res) => { + try { + const agent = await agentsCollection.findOne({ name: req.params.name }) + if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) + await agentsCollection.deleteOne({ _id: agent._id }) + res.json({ success: true }) + } catch (err) { + res.status(500).json({ success: false, error: err.message }) + } +})) app.post('/api/agents/:name/lock', async (req, res, next) => { authRoutes.verifyToken(req, res, async () => { @@ -789,6 +869,11 @@ app.delete('/api/agents/:name/lock', async (req, res, next) => { try { const agent = await agentsCollection.findOne({ name: req.params.name }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) + const activeLock = getActiveLock(agent) + const isAdmin = req.user.role === 'admin' + if (activeLock && activeLock.userId !== req.user.id && !isAdmin) { + return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' }) + } await agentsCollection.updateOne({ _id: agent._id }, { $unset: { lock: '' } }) res.json({ success: true }) } catch (err) {