feat(admin): 添加管理员面板和权限管理功能
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled

新增管理员面板的静态文件支持,提供用户列表、角色切换和权限配置功能。更新了环境变量示例以包含管理员邮箱,并在auth.js中实现了管理员登录和权限审计日志功能。更新README文档以说明管理员面板的使用和相关接口。
This commit is contained in:
hjjjj 2026-03-24 16:57:33 +08:00
parent e9e0cf03c5
commit e4c1ff7900
8 changed files with 1283 additions and 107 deletions

View File

@ -4,6 +4,7 @@ DB_NAME=skills_market
JWT_SECRET=your-jwt-secret-key-change-in-production JWT_SECRET=your-jwt-secret-key-change-in-production
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
ADMIN_EMAIL=admin@example.com
# 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔 # 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔
WHITELIST_EMAILS=1311711287@qq.com WHITELIST_EMAILS=1311711287@qq.com

View File

@ -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\""

View File

@ -55,6 +55,14 @@ npm run dev
| `/api/chat/sessions/:id/messages/all` | DELETE | 清空该会话全部消息 | | `/api/chat/sessions/:id/messages/all` | DELETE | 清空该会话全部消息 |
| `/api/chat/sessions/:id/messages/from/:fromSort` | DELETE | 删除 `sort_order >= fromSort` 的消息(截断) | | `/api/chat/sessions/:id/messages/from/:fromSort` | DELETE | 删除 `sort_order >= fromSort` 的消息(截断) |
### 管理员看板 `/admin`
- 地址:`http://<server>: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` 文件: 创建 `.env` 文件:

657
admin-web/index.html Normal file
View File

@ -0,0 +1,657 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>权限管理平台</title>
<style>
:root {
--bg: #f3f6ff;
--bg2: #eefcf6;
--card: #ffffff;
--text: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--secondary: #64748b;
--success: #059669;
--danger: #dc2626;
--warning: #d97706;
--fz-xs: 12px;
--fz-sm: 14px;
--fz-md: 15px;
--fz-lg: 18px;
--fz-xl: 24px;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
background: radial-gradient(circle at 20% 0%, #dbeafe 0%, transparent 35%),
radial-gradient(circle at 100% 20%, #dcfce7 0%, transparent 30%),
linear-gradient(145deg, var(--bg), var(--bg2));
color: var(--text);
min-height: 100vh;
font-size: var(--fz-md);
line-height: 1.55;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 30px;
}
.container.auth-mode {
min-height: calc(100vh - 60px);
display: flex;
flex-direction: column;
justify-content: center;
}
.container.auth-mode .hero {
text-align: center;
}
.container.auth-mode #loginCard {
width: min(820px, 100%);
margin: 0 auto;
animation: login-card-enter 180ms ease-out;
}
.container.auth-mode #loginCard .row {
justify-content: center;
}
.hero {
margin-bottom: 18px;
}
.hero h2 {
margin: 0;
font-size: clamp(22px, 3vw, 28px);
letter-spacing: 0.2px;
}
.hero .sub {
margin-top: 6px;
color: var(--muted);
font-size: var(--fz-md);
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 5px 12px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: var(--primary);
font-size: var(--fz-sm);
font-weight: 600;
}
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), #fff);
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 16px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
padding: 20px 22px;
backdrop-filter: blur(5px);
}
.row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.panel-title {
margin: 0 0 10px;
font-size: var(--fz-lg);
}
input, button, select {
height: 42px;
border-radius: 12px;
border: 1px solid var(--line);
padding: 0 14px;
font-size: var(--fz-md);
outline: none;
transition: all 0.18s ease;
}
input, select {
background: #fff;
color: var(--text);
}
input:focus, select:focus {
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13);
}
button {
cursor: pointer;
border: 1px solid transparent;
background: var(--primary);
color: #fff;
font-weight: 600;
}
button:hover {
background: var(--primary-hover);
}
button.secondary {
background: #fff;
border-color: var(--line);
color: #334155;
}
button.secondary:hover {
background: #f8fafc;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-input {
min-width: 320px;
}
#email { min-width: 300px; }
#code { width: 140px; }
.table-wrap {
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
margin-top: 10px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1050px;
font-size: var(--fz-md);
}
th, td {
border-bottom: 1px solid #f1f5f9;
padding: 12px 14px;
text-align: left;
vertical-align: top;
white-space: nowrap;
}
th {
background: #f8fafc;
color: #334155;
font-size: var(--fz-xs);
letter-spacing: 0.2px;
position: sticky;
top: 0;
z-index: 1;
}
tbody tr:hover { background: #f8fbff; }
.muted {
color: var(--muted);
font-size: var(--fz-xs);
}
.hidden { display: none; }
.mode-list {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mode-list label {
padding: 6px 10px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: var(--fz-xs);
line-height: 1;
display: inline-flex;
align-items: center;
gap: 4px;
}
.danger {
color: var(--danger);
}
.role {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: var(--fz-xs);
font-weight: 700;
margin-bottom: 8px;
}
.role-user {
background: #ecfeff;
color: #0e7490;
border: 1px solid #bae6fd;
}
.role-admin {
background: #fef3c7;
color: #b45309;
border: 1px solid #fde68a;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-top: 12px;
}
.stat {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
padding: 10px 12px;
}
.stat .k {
color: var(--muted);
font-size: var(--fz-sm);
}
.stat .v {
margin-top: 4px;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
min-width: 220px;
max-width: 420px;
padding: 12px 14px;
border-radius: 10px;
color: #fff;
font-size: var(--fz-sm);
box-shadow: 0 10px 24px rgba(2, 6, 23, 0.2);
transform: translateY(10px);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
.toast.info { background: #0f766e; }
.table-tip {
margin-top: 8px;
font-size: var(--fz-xs);
color: var(--muted);
}
.auth-note {
margin: 12px 0 0;
font-size: var(--fz-sm);
}
@keyframes login-card-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.container.auth-mode #loginCard {
animation: none;
}
}
@media (max-width: 960px) {
.container {
padding: 18px;
}
.container.auth-mode {
min-height: calc(100vh - 36px);
justify-content: center;
}
.card {
padding: 16px;
border-radius: 14px;
}
.search-input {
min-width: 240px;
flex: 1 1 auto;
}
#email {
min-width: 240px;
flex: 1 1 auto;
}
.row {
gap: 10px;
}
}
@media (max-width: 640px) {
.container {
padding: 12px;
}
.hero .sub {
font-size: var(--fz-sm);
}
input, button, select {
height: 40px;
font-size: var(--fz-sm);
}
#code {
width: 120px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h2>权限管理平台</h2>
<div class="sub">管理员可在此配置用户模式、页面访问、SSH 与开发者模式权限。</div>
<div class="chip">Admin Console</div>
</div>
<div id="loginCard" class="card">
<h3 class="panel-title">管理员登录</h3>
<div class="row">
<input id="email" type="email" placeholder="管理员邮箱" />
<input id="code" type="text" placeholder="6位验证码" maxlength="6" />
<button id="sendCodeBtn" class="secondary">发送验证码</button>
<button id="loginBtn">登录</button>
</div>
<p class="muted auth-note">复用邮箱验证码登录;登录后仅 `role=admin` 可访问管理接口</p>
</div>
<div id="panel" class="card hidden" style="margin-top: 12px">
<div class="row">
<input id="query" class="search-input" type="text" placeholder="搜索邮箱或昵称" />
<button id="refreshBtn" class="secondary">搜索</button>
<button id="logoutBtn" class="secondary">退出</button>
</div>
<div class="stats">
<div class="stat">
<div class="k">用户数</div>
<div id="userCount" class="v">0</div>
</div>
<div class="stat">
<div class="k">可编辑用户</div>
<div id="editableCount" class="v">0</div>
</div>
<div class="stat">
<div class="k">审计记录</div>
<div id="auditCount" class="v">0</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>用户</th>
<th>角色</th>
<th>可用模式</th>
<th>技能页</th>
<th>智能体页</th>
<th>SSH 页</th>
<th>开发者模式</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="table-tip">提示:只有可编辑用户才可保存;未授权项会显示“未授权”。</div>
</div>
<div id="auditPanel" class="card hidden" style="margin-top: 12px">
<div class="row" style="justify-content: space-between">
<strong>权限变更审计日志</strong>
<button id="refreshAuditBtn" class="secondary">刷新日志</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>目标用户</th>
<th>角色变更</th>
<th>模式变更</th>
</tr>
</thead>
<tbody id="auditTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="toast" class="toast info"></div>
<script>
const ALL_MODES = ['chat', 'clarify', 'cowork', 'video', 'code']
let token = localStorage.getItem('admin_token') || ''
const containerEl = document.querySelector('.container')
const loginCardEl = document.getElementById('loginCard')
const panelEl = document.getElementById('panel')
const auditPanelEl = document.getElementById('auditPanel')
let adminCapabilities = {
grantable_permissions: {
allowedModes: [...ALL_MODES],
canViewSkillsPage: true,
canViewAgentsPage: true,
canUseSshPage: true,
canUseDeveloperMode: true
}
}
function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }
if (token) headers.Authorization = `Bearer ${token}`
return fetch(path, { ...options, headers }).then((r) => r.json())
}
let toastTimer = null
function notify(message, type = 'info') {
const toast = document.getElementById('toast')
if (!toast) return
toast.className = `toast ${type}`
toast.textContent = message
requestAnimationFrame(() => toast.classList.add('show'))
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => toast.classList.remove('show'), 1800)
}
function boolCell(name, checked, enabled) {
if (!enabled) return '<span class="muted">未授权</span>'
return `<input type="checkbox" data-perm="${name}" ${checked ? 'checked' : ''} />`
}
function renderAuditLogs(logs) {
const tbody = document.getElementById('auditTbody')
document.getElementById('auditCount').textContent = String(logs.length || 0)
tbody.innerHTML = logs
.map((log) => {
const beforeModes = (log.target_permissions_before?.allowedModes || []).join(', ')
const afterModes = (log.target_permissions_after?.allowedModes || []).join(', ')
const roleChanged = log.target_role_before !== log.target_role_after
const modeChanged = beforeModes !== afterModes
return `
<tr>
<td>${new Date(log.created_at).toLocaleString()}</td>
<td>
<div>${log.actor_nickname || '-'}</div>
<div class="muted">${log.actor_email || ''}</div>
</td>
<td>
<div>${log.target_nickname || '-'}</div>
<div class="muted">${log.target_email || ''}</div>
</td>
<td class="${roleChanged ? 'danger' : 'muted'}">${log.target_role_before} -> ${log.target_role_after}</td>
<td class="${modeChanged ? 'danger' : 'muted'}">${beforeModes || '-'} -> ${afterModes || '-'}</td>
</tr>
`
})
.join('')
}
function renderUsers(users) {
const tbody = document.getElementById('tbody')
document.getElementById('userCount').textContent = String(users.length || 0)
document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length)
const grantable = adminCapabilities.grantable_permissions || {}
const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : []
tbody.innerHTML = users
.map((u) => {
const p = u.permissions || {}
const allowedModes = Array.isArray(p.allowedModes) ? p.allowedModes : []
const modeChecks = ALL_MODES
.filter((m) => grantableModes.includes(m))
.map(
(m) =>
`<label><input type="checkbox" data-mode="${m}" ${allowedModes.includes(m) ? 'checked' : ''}/> ${m}</label>`
)
.join(' ')
const editable = !!u.editable
return `
<tr data-id="${u.id}">
<td>
<div>${u.nickname || '-'}</div>
<div class="muted">${u.email}</div>
</td>
<td>
<div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div>
<select data-field="role" ${editable ? '' : 'disabled'}>
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
</select>
</td>
<td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td>
<td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td>
<td>${boolCell('canViewAgentsPage', !!p.canViewAgentsPage, !!grantable.canViewAgentsPage)}</td>
<td>${boolCell('canUseSshPage', !!p.canUseSshPage, !!grantable.canUseSshPage)}</td>
<td>${boolCell('canUseDeveloperMode', !!p.canUseDeveloperMode, !!grantable.canUseDeveloperMode)}</td>
<td>
<button data-action="save" ${editable ? '' : 'disabled'}>保存</button>
${editable ? '' : '<div class="muted">不可编辑</div>'}
</td>
</tr>
`
})
.join('')
Array.from(tbody.querySelectorAll('tr')).forEach((tr, idx) => {
const editable = !!users[idx]?.editable
if (!editable) {
tr.querySelectorAll('input[type="checkbox"]').forEach((el) => (el.disabled = true))
}
})
}
async function loadUsers() {
const query = document.getElementById('query').value.trim()
const data = await api(`/api/admin/users${query ? `?query=${encodeURIComponent(query)}` : ''}`)
if (!data.success) {
notify(data.error || '加载失败', 'error')
return
}
adminCapabilities = data.admin_capabilities || adminCapabilities
renderUsers(data.users || [])
}
async function loadAuditLogs() {
const data = await api('/api/admin/audit-logs?limit=50')
if (!data.success) {
notify(data.error || '日志加载失败', 'error')
return
}
renderAuditLogs(data.logs || [])
}
async function sendCode() {
const email = document.getElementById('email').value.trim()
if (!email) {
notify('请输入邮箱', 'error')
return
}
const data = await api('/api/auth/send-code', {
method: 'POST',
body: JSON.stringify({ email })
})
if (!data.success) {
notify(data.error || '发送验证码失败', 'error')
return
}
notify('验证码已发送', 'success')
}
async function login() {
const email = document.getElementById('email').value.trim()
const code = document.getElementById('code').value.trim()
const data = await api('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ email, code })
})
if (!data.success) {
notify(data.error || '登录失败', 'error')
return
}
token = data.token
localStorage.setItem('admin_token', token)
setLoggedInState(true)
await loadUsers()
await loadAuditLogs()
notify('登录成功', 'success')
}
function setLoggedInState(isLoggedIn) {
containerEl?.classList.toggle('auth-mode', !isLoggedIn)
loginCardEl?.classList.toggle('hidden', isLoggedIn)
panelEl?.classList.toggle('hidden', !isLoggedIn)
auditPanelEl?.classList.toggle('hidden', !isLoggedIn)
}
function readRowPayload(tr) {
const modeChecks = tr.querySelectorAll('input[data-mode]')
const allowedModes = Array.from(modeChecks)
.filter((el) => el.checked)
.map((el) => el.dataset.mode)
const getPerm = (name) =>
!!tr.querySelector(`input[data-perm="${name}"]`)?.checked
return {
role: tr.querySelector('select[data-field="role"]').value,
permissions: {
allowedModes,
canViewSkillsPage: getPerm('canViewSkillsPage'),
canViewAgentsPage: getPerm('canViewAgentsPage'),
canUseSshPage: getPerm('canUseSshPage'),
canUseDeveloperMode: getPerm('canUseDeveloperMode')
}
}
}
document.getElementById('sendCodeBtn').addEventListener('click', sendCode)
document.getElementById('loginBtn').addEventListener('click', login)
document.getElementById('refreshBtn').addEventListener('click', loadUsers)
document.getElementById('refreshAuditBtn').addEventListener('click', loadAuditLogs)
document.getElementById('logoutBtn').addEventListener('click', () => {
token = ''
localStorage.removeItem('admin_token')
setLoggedInState(false)
})
document.getElementById('tbody').addEventListener('click', async (e) => {
if (!(e.target instanceof HTMLButtonElement)) return
if (e.target.dataset.action !== 'save') return
const tr = e.target.closest('tr')
const id = tr?.dataset.id
if (!id || !tr) return
const payload = readRowPayload(tr)
const data = await api(`/api/admin/users/${id}/permissions`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
if (!data.success) {
notify(data.error || '保存失败', 'error')
return
}
notify('保存成功', 'success')
await loadUsers()
await loadAuditLogs()
})
document.getElementById('query').addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadUsers()
})
if (token) {
setLoggedInState(true)
loadUsers()
loadAuditLogs()
} else {
setLoggedInState(false)
}
</script>
</body>
</html>

View File

@ -1,7 +1,31 @@
@echo off @echo off
setlocal
set "PATH=%PATH%;C:\Program Files\Git\cmd" set "PATH=%PATH%;C:\Program Files\Git\cmd"
git fetch origin set "APP_NAME=skills-market-server"
git reset --hard origin/master
npm install --omit=dev git fetch origin || goto :error
pm2 restart skills-market-server 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. echo Deploy done.
exit /b 0
:error
echo Deploy failed.
exit /b 1

View File

@ -1,35 +1,225 @@
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const { ObjectId } = require('mongodb')
const { sendVerificationCode, verifyCode } = require('../services/auth') const { sendVerificationCode, verifyCode } = require('../services/auth')
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production' const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d' 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_CODE = process.env.WHITELIST_CODE || '888888'
const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '') 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) { function createAuthRoutes(db) {
const usersCollection = db.collection('users') 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 { 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) { async sendCode(req, res) {
try { try {
const { email } = req.body const { email } = req.body
if (!email) { if (!email) {
return res.status(400).json({ success: false, error: '邮箱不能为空' }) return res.status(400).json({ success: false, error: '邮箱不能为空' })
} }
if (WHITELIST_EMAILS.includes(email.toLowerCase())) { if (WHITELIST_EMAILS.includes(email.toLowerCase())) {
return res.json({ success: true, message: '验证码已发送' }) return res.json({ success: true, message: '验证码已发送' })
} }
const result = await sendVerificationCode(db, email.toLowerCase()) const result = await sendVerificationCode(db, email.toLowerCase())
if (!result.success) { if (!result.success) {
return res.status(400).json(result) return res.status(400).json(result)
} }
res.json({ success: true, message: '验证码已发送' }) res.json({ success: true, message: '验证码已发送' })
} catch (err) { } catch (err) {
console.error('[Auth] Send code error:', err) console.error('[Auth] Send code error:', err)
@ -40,11 +230,9 @@ function createAuthRoutes(db) {
async login(req, res) { async login(req, res) {
try { try {
const { email, code, nickname } = req.body const { email, code, nickname } = req.body
if (!email || !code) { if (!email || !code) {
return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' }) return res.status(400).json({ success: false, error: '邮箱和验证码不能为空' })
} }
const emailLower = email.toLowerCase() const emailLower = email.toLowerCase()
if (WHITELIST_EMAILS.includes(emailLower)) { if (WHITELIST_EMAILS.includes(emailLower)) {
@ -59,50 +247,36 @@ function createAuthRoutes(db) {
} }
let user = await usersCollection.findOne({ email: emailLower }) let user = await usersCollection.findOne({ email: emailLower })
const now = new Date()
if (!user) { if (!user) {
const role = isRootAdminEmail(emailLower) ? 'admin' : 'user'
const newUser = { const newUser = {
email: emailLower, email: emailLower,
nickname: nickname || emailLower.split('@')[0], nickname: nickname || emailLower.split('@')[0],
avatar: null, avatar: null,
created_at: new Date(), role,
updated_at: new Date(), permissions: role === 'admin' ? getAdminDefaultPermissions() : getDefaultPermissions(),
last_login: new Date(), created_at: now,
updated_at: now,
last_login: now,
status: 'active' status: 'active'
} }
const result = await usersCollection.insertOne(newUser) const result = await usersCollection.insertOne(newUser)
user = { ...newUser, _id: result.insertedId } user = { ...newUser, _id: result.insertedId }
} else { } else {
user = await ensureUserDefaults(user)
await usersCollection.updateOne( await usersCollection.updateOne(
{ _id: user._id }, { _id: user._id },
{ { $set: { last_login: now, updated_at: now } }
$set: {
last_login: new Date(),
updated_at: new Date()
}
}
) )
user = { ...user, last_login: now, updated_at: now }
} }
const token = jwt.sign( const token = signUserToken(user)
{
userId: user._id.toString(),
email: user.email
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
res.json({ res.json({
success: true, success: true,
token, token,
user: { user: sanitizeUserForClient(user)
id: user._id,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
created_at: user.created_at
}
}) })
} catch (err) { } catch (err) {
console.error('[Auth] Login error:', 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) { async verifyToken(req, res, next) {
try { try {
const authHeader = req.headers.authorization const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, error: '未登录' }) return res.status(401).json({ success: false, error: '未登录' })
} }
const token = authHeader.split(' ')[1] const token = authHeader.split(' ')[1]
try { try {
const decoded = jwt.verify(token, JWT_SECRET) 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) { if (!user) {
return res.status(401).json({ success: false, error: '用户不存在' }) return res.status(401).json({ success: false, error: '用户不存在' })
} }
const safeUser = await ensureUserDefaults(user)
req.user = { req.user = sanitizeUserForClient(safeUser)
id: user._id.toString(),
email: user.email,
nickname: user.nickname,
avatar: user.avatar
}
next() next()
} catch (jwtErr) { } catch {
return res.status(401).json({ success: false, error: 'Token无效或已过期' }) return res.status(401).json({ success: false, error: 'Token无效或已过期' })
} }
} catch (err) { } 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 { try {
res.json({ success: true, user: req.user }) res.json({ success: true, user: req.user })
} catch (err) { } catch (err) {
@ -157,25 +384,164 @@ function createAuthRoutes(db) {
try { try {
const { nickname, avatar } = req.body const { nickname, avatar } = req.body
const updateData = { updated_at: new Date() } const updateData = { updated_at: new Date() }
if (nickname) updateData.nickname = nickname if (nickname) updateData.nickname = nickname
if (avatar) updateData.avatar = avatar if (avatar) updateData.avatar = avatar
await usersCollection.updateOne({ _id: new ObjectId(req.user.id) }, { $set: updateData })
await usersCollection.updateOne( const updatedUser = await usersCollection.findOne({ _id: new ObjectId(req.user.id) })
{ _id: new (require('mongodb').ObjectId)(req.user.id) }, const safeUser = updatedUser
{ $set: updateData } ? sanitizeUserForClient(updatedUser)
) : { ...req.user, ...updateData }
res.json({ success: true, user: safeUser })
res.json({
success: true,
user: { ...req.user, ...updateData }
})
} catch (err) { } catch (err) {
console.error('[Auth] Update profile error:', err) console.error('[Auth] Update profile error:', err)
res.status(500).json({ success: false, error: err.message }) 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
}

View File

@ -56,6 +56,12 @@ function registerChatRoutes(app, db, authRoutes) {
} }
const userObjectId = (req) => new ObjectId(req.user.id) 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 --- // --- Sessions ---
@ -113,6 +119,7 @@ function registerChatRoutes(app, db, authRoutes) {
return res.status(400).json({ success: false, error: 'id required' }) return res.status(400).json({ success: false, error: 'id required' })
} }
const now = Date.now() const now = Date.now()
if (!assertModeAllowed(req, res, b.mode ?? 'chat')) return
const doc = { const doc = {
_id: id, _id: id,
user_id: uid, user_id: uid,
@ -164,6 +171,7 @@ function registerChatRoutes(app, db, authRoutes) {
else $set[k] = patch[k] else $set[k] = patch[k]
} }
} }
if (patch.mode !== undefined && !assertModeAllowed(req, res, patch.mode)) return
if (Object.keys($set).length === 0) { if (Object.keys($set).length === 0) {
return res.json({ success: true }) return res.json({ success: true })
} }

171
server.js
View File

@ -14,12 +14,36 @@ const UPDATES_DIR = process.env.UPDATES_DIR || 'C:\\apps\\skills-market-server\\
app.use(cors()) app.use(cors())
app.use(express.json({ limit: '50mb' })) app.use(express.json({ limit: '50mb' }))
app.use('/updates', express.static(UPDATES_DIR)) app.use('/updates', express.static(UPDATES_DIR))
app.use('/admin', express.static(path.join(__dirname, 'admin-web')))
let db let db
let skillsCollection let skillsCollection
let agentsCollection let agentsCollection
let authRoutes 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() { async function connectDB() {
const client = new MongoClient(MONGO_URL) const client = new MongoClient(MONGO_URL)
await client.connect() await client.connect()
@ -28,6 +52,7 @@ async function connectDB() {
agentsCollection = db.collection('agents') agentsCollection = db.collection('agents')
authRoutes = createAuthRoutes(db) authRoutes = createAuthRoutes(db)
console.log(`[MongoDB] Connected to ${DB_NAME}`) console.log(`[MongoDB] Connected to ${DB_NAME}`)
await authRoutes.ensureAdminBootstrap()
await skillsCollection.createIndex({ name: 1 }, { unique: true }) await skillsCollection.createIndex({ name: 1 }, { unique: true })
await skillsCollection.createIndex({ owner: 1 }) await skillsCollection.createIndex({ owner: 1 })
@ -44,6 +69,9 @@ async function connectDB() {
await codesCollection.createIndex({ email: 1 }) await codesCollection.createIndex({ email: 1 })
await codesCollection.createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }) 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') const { ensureChatIndexes, registerChatRoutes } = require('./routes/chat')
await ensureChatIndexes(db) await ensureChatIndexes(db)
registerChatRoutes(app, db, authRoutes) registerChatRoutes(app, db, authRoutes)
@ -68,6 +96,7 @@ function injectConfidentialityInstruction(files) {
} }
const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes
const PUBLIC_VISIBILITY_FILTER = { $ne: false }
function getActiveLock(doc) { function getActiveLock(doc) {
if (!doc?.lock?.userId || !doc?.lock?.at) return null 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) 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) => { app.get('/api/auth/profile', async (req, res, next) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
await authRoutes.getProfile(req, res) 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) => { app.get('/api/skills', async (req, res) => {
try { try {
const { query, offset = 0, limit = 50 } = req.query const { query, offset = 0, limit = 50 } = req.query
let filter = { is_public: true } let filter = { is_public: PUBLIC_VISIBILITY_FILTER }
if (query && query.trim()) { if (query && query.trim()) {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
filter.$or = [ filter.$or = [
@ -180,7 +228,7 @@ app.get('/api/skills/:name', async (req, res) => {
try { try {
const skill = await skillsCollection.findOne({ const skill = await skillsCollection.findOne({
name: req.params.name, name: req.params.name,
is_public: true is_public: PUBLIC_VISIBILITY_FILTER
}) })
if (!skill) { if (!skill) {
@ -212,7 +260,7 @@ app.get('/api/skills/:name/download', async (req, res) => {
try { try {
const skill = await skillsCollection.findOne({ const skill = await skillsCollection.findOne({
name: req.params.name, name: req.params.name,
is_public: true is_public: PUBLIC_VISIBILITY_FILTER
}) })
if (!skill) { 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 { 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({ const skill = await skillsCollection.findOne({
name: req.params.name, name: req.params.name,
is_public: true is_public: PUBLIC_VISIBILITY_FILTER
}) })
if (!skill) { if (!skill) {
@ -302,7 +353,7 @@ app.post('/api/skills/:name/lock', async (req, res) => {
console.error('[API] Execute skill script error:', err) console.error('[API] Execute skill script error:', err)
res.status(500).json({ success: false, error: err.message }) res.status(500).json({ success: false, error: err.message })
} }
}) }))
// 获取技能的单个文件内容(用于 agent 按需读取) // 获取技能的单个文件内容(用于 agent 按需读取)
app.get('/api/skills/:name/files/*', async (req, res) => { 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 filePath = req.params[0] // 捕获通配符部分
const skill = await skillsCollection.findOne({ const skill = await skillsCollection.findOne({
name: req.params.name, name: req.params.name,
is_public: true is_public: PUBLIC_VISIBILITY_FILTER
}) })
if (!skill) { if (!skill) {
@ -337,9 +388,17 @@ app.get('/api/skills/:name/files/*', async (req, res) => {
app.post('/api/skills/:name/lock', async (req, res) => { app.post('/api/skills/:name/lock', async (req, res) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { 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( await skillsCollection.updateOne(
{ name: req.params.name }, { _id: skill._id },
{ $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date() } } } { $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date().toISOString() } } }
) )
res.json({ success: true }) res.json({ success: true })
} catch (err) { } catch (err) {
@ -351,8 +410,17 @@ app.post('/api/skills/:name/lock', async (req, res) => {
app.delete('/api/skills/:name/lock', async (req, res) => { app.delete('/api/skills/:name/lock', async (req, res) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { 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( await skillsCollection.updateOne(
{ name: req.params.name, 'lock.userId': req.user.id }, { _id: skill._id },
{ $unset: { lock: '' } } { $unset: { lock: '' } }
) )
res.json({ success: true }) res.json({ success: true })
@ -423,6 +491,14 @@ app.post('/api/skills/:name/publish', async (req, res) => {
const existingSkill = await skillsCollection.findOne({ name: skillName }) const existingSkill = await skillsCollection.findOne({ name: skillName })
if (existingSkill) { 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 remoteModifiedTime = new Date(existingSkill.updated_at).getTime()
const localModifiedTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0 const localModifiedTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0
const { force } = req.body 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) => { app.delete('/api/skills/:name', requireAdmin(async (req, res) => {
authRoutes.verifyToken(req, res, async () => { try {
try { const skill = await skillsCollection.findOne({ name: req.params.name })
const skill = await skillsCollection.findOne({ name: req.params.name })
if (!skill) { if (!skill) {
return res.status(404).json({ success: false, error: 'Skill not found' }) 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 })
} }
})
}) 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) => { app.get('/api/health', (req, res) => {
res.json({ res.json({
@ -613,9 +687,9 @@ app.get('/api/health', (req, res) => {
app.get('/api/stats', async (req, res) => { app.get('/api/stats', async (req, res) => {
try { try {
const totalSkills = await skillsCollection.countDocuments({ is_public: true }) const totalSkills = await skillsCollection.countDocuments({ is_public: PUBLIC_VISIBILITY_FILTER })
const totalDownloads = await skillsCollection.aggregate([ const totalDownloads = await skillsCollection.aggregate([
{ $match: { is_public: true } }, { $match: { is_public: PUBLIC_VISIBILITY_FILTER } },
{ $group: { _id: null, total: { $sum: '$downloads' } } } { $group: { _id: null, total: { $sum: '$downloads' } } }
]).toArray() ]).toArray()
@ -637,7 +711,7 @@ app.get('/api/stats', async (req, res) => {
app.get('/api/agents', async (req, res) => { app.get('/api/agents', async (req, res) => {
try { try {
const { query, offset = 0, limit = 100 } = req.query const { query, offset = 0, limit = 100 } = req.query
let filter = { is_public: true } let filter = { is_public: PUBLIC_VISIBILITY_FILTER }
if (query && query.trim()) { if (query && query.trim()) {
const q = query.trim() const q = query.trim()
filter.$or = [ filter.$or = [
@ -690,7 +764,7 @@ app.get('/api/agents/mine', async (req, res, next) => {
app.get('/api/agents/:name', async (req, res) => { app.get('/api/agents/:name', async (req, res) => {
try { 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' }) 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 } }) 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) { } catch (err) {
@ -711,6 +785,14 @@ app.post('/api/agents/:name/publish', async (req, res) => {
const existing = await agentsCollection.findOne({ name: agentName }) const existing = await agentsCollection.findOne({ name: agentName })
if (existing) { 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 remoteTime = new Date(existing.updated_at).getTime()
const localTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0 const localTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0
const { force } = req.body 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) => { app.delete('/api/agents/:name', requireAdmin(async (req, res) => {
authRoutes.verifyToken(req, res, async () => { try {
try { const agent = await agentsCollection.findOne({ name: req.params.name })
const agent = await agentsCollection.findOne({ name: req.params.name }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' })
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) await agentsCollection.deleteOne({ _id: agent._id })
await agentsCollection.deleteOne({ _id: agent._id }) res.json({ success: true })
res.json({ success: true }) } catch (err) {
} catch (err) { res.status(500).json({ success: false, error: err.message })
res.status(500).json({ success: false, error: err.message }) }
} }))
})
})
app.post('/api/agents/:name/lock', async (req, res, next) => { app.post('/api/agents/:name/lock', async (req, res, next) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
@ -789,6 +869,11 @@ app.delete('/api/agents/:name/lock', async (req, res, next) => {
try { try {
const agent = await agentsCollection.findOne({ name: req.params.name }) const agent = await agentsCollection.findOne({ name: req.params.name })
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) 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: '' } }) await agentsCollection.updateOne({ _id: agent._id }, { $unset: { lock: '' } })
res.json({ success: true }) res.json({ success: true })
} catch (err) { } catch (err) {