From 7beac62b08dc28350dea386b77f62197d26960d4 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sun, 21 Jun 2026 14:05:11 +0800 Subject: [PATCH] fix(security): apply code review fixes and rewrite README - fix(P1): prevent YAML injection in adaptToHermesConfig via yamlString() and sanitizeComment() - fix(P2): add @@index([userId, createdAt]) to ApiKey model - fix(P2): change Hermes error responses from text/plain to JSON - fix(P2): set .env file permissions to 600 in setup-server.sh - fix(P2): remove dead model fallback code - fix(P2): unify API Key response naming (GET returns { apiKeys }) - fix(P3): add console.warn to fire-and-forget catch - fix(P3): correct keyPrefix comment (8 -> 12 chars) - fix(P3): move require() to file top in auth.js - fix(P3): stop printing database password in setup-server.sh - docs: rewrite README with architecture, operation flow, and Hermes interaction flow --- README.md | 322 ++++++++++++++++++++++++++++++++++++----- app.js | 6 +- deploy/setup-server.sh | 5 +- prisma/schema.prisma | 4 +- src/lib/auth.js | 6 +- src/routes/apikeys.js | 2 +- src/routes/hermes.js | 31 ++-- 7 files changed, 323 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 2436707..635568b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,306 @@ # Eternal AI -AI 陪伴平台 — 开放给人设创作者进行人设设定并发布,生成 Hermes agent 可用的配置文件(Soul.md + config.yaml)。 +AI 陪伴平台 — 人设创作者设定并发布角色,自动生成 Hermes Agent 可用的配置文件(SOUL.md + config.yaml),支持跨机器 CLI 拉取部署。 -## 项目结构 +## 架构 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 浏览器 (SPA) │ +│ index.html + app.js + styles.css │ +│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ 认证视图 │ │ 角色库 │ │ 创作者中心 │ │ 角色编辑器 │ │ +│ └──────────┘ └──────────┘ └───────────┘ └───────────┘ │ +└──────────────────────┬───────────────────────────────────┘ + │ HTTP / JSON +┌──────────────────────▼───────────────────────────────────┐ +│ Express.js 服务端 (server.js) │ +│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ /api/auth│ │/api/roles│ │/api/apikeys│ │ /api/hermes │ │ +│ └────┬────┘ └────┬────┘ └─────┬────┘ └──────┬────────┘ │ +│ │ │ │ │ │ +│ ┌────▼───────────▼────────────▼──────────────▼────────┐ │ +│ │ 认证中间件 (src/lib/auth.js) │ │ +│ │ JWT 认证 (authMiddleware) + API Key 认证 │ │ +│ │ (apiKeyMiddleware, eak_ 前缀, SHA-256 哈希) │ │ +│ └─────────────────────┬───────────────────────────────┘ │ +└────────────────────────┼─────────────────────────────────┘ + │ Prisma ORM +┌────────────────────────▼─────────────────────────────────┐ +│ PostgreSQL 数据库 │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ User │ │ Role │ │ ApiKey │ │ Order │ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 技术栈 + +| 层 | 技术 | +|----|------| +| 前端 | 原生 HTML5 SPA(IIFE 模式,无框架)、玻璃拟态 UI | +| 后端 | Node.js + Express 5.x | +| 数据库 | PostgreSQL 15 + Prisma ORM 5.x | +| 认证 | JWT(7 天有效期)+ API Key(`eak_` 前缀,SHA-256 哈希存储) | +| 密码 | bcryptjs(10 轮盐) | +| 测试 | Playwright E2E(35 个测试用例) | +| 部署 | PM2 + Nginx 反向代理 | + +### 项目结构 ``` EternalAI/ -├── index.html # 单页应用入口(9 个视图) -├── app.js # 应用逻辑(路由、状态管理、表单生成) -├── styles.css # 样式(玻璃拟态、深空背景) -├── server.js # Express 静态服务器(端口 3001) -├── img/ # 背景与卡片素材 -├── docs/plans/ # 规划文档 -├── Eternal_AI_PRD_v1.docx # 产品需求文档 +├── server.js # Express 入口,注册路由 + 静态文件 +├── app.js # 前端 SPA 逻辑(路由、状态、表单、API 调用) +├── index.html # 单页应用入口(9 个视图) +├── styles.css # 玻璃拟态样式 +├── prisma/ +│ └── schema.prisma # 数据模型定义(User / Role / ApiKey / Order) +├── src/ +│ ├── lib/ +│ │ ├── auth.js # JWT + API Key 认证中间件 +│ │ └── prisma.js # Prisma Client 单例 +│ └── routes/ +│ ├── auth.js # 注册 / 登录 / 用户信息 / 设置 +│ ├── roles.js # 角色 CRUD + 角色库 +│ ├── apikeys.js # API Key 生成 / 列表 / 删除 +│ └── hermes.js # Hermes 配置拉取端点(SOUL.md / config.yaml) +├── deploy/ +│ ├── setup-server.sh # 服务器一键初始化脚本 +│ ├── deploy.sh # CI/CD 部署脚本 +│ └── nginx.conf # Nginx 反向代理配置 +├── e2e/ # Playwright E2E 测试 +├── docs/ # 需求文档与规划 └── package.json ``` ## 快速开始 -```bash -# 方式一:Express 服务器 -npm install && npm start -# → http://localhost:3001 +### 本地开发 -# 方式二:任意静态服务器 -python3 -m http.server 8083 -# → http://localhost:8083 +```bash +# 1. 安装依赖 +npm install + +# 2. 配置环境变量 +cp .env.example .env +# 编辑 .env,设置 DATABASE_URL 和 JWT_SECRET +# JWT_SECRET 可用 openssl rand -hex 32 生成 + +# 3. 初始化数据库 +npx prisma db push +npx prisma generate + +# 4. 启动开发服务器 +npm run dev +# → http://localhost:3001 ``` -## 页面一览 +### 服务器部署 -| 页面 | 视图 ID | 说明 | -|------|---------|------| -| 首页 | landing | 两张入口卡片 + 底部链接 | -| 登录/注册 | auth | 登录态分流 | -| 角色库 | role-library | 创作者上架的角色列表 | -| 角色详情 | role-detail | 单个角色详情 + 付款流程 | -| 蒸馏前任 | distill | 自营情感服务介绍页 | -| 关于 Eternal AI | about | 平台简介 + FAQ | -| 创作者入驻 | onboarding | 合作说明 + 微信联系方式 | -| 创作者管理中心 | creator-center | 角色/收入/设置 三 tab | -| 角色编辑 | creator | 4 步表单 → 生成 Soul.md + config.yaml | +```bash +# 在服务器上执行一键初始化脚本 +bash deploy/setup-server.sh +# 自动安装 Node.js / PostgreSQL / Nginx / PM2,创建 .env,推送 Schema +``` -## 技术栈 +## 操作流程 -- 纯前端 HTML5 SPA(IIFE 模式,无框架) -- localStorage 持久化登录态 -- Express 静态文件服务 -- 玻璃拟态 UI + 深空背景 +### 1. 用户注册与登录 + +``` +用户 → 首页 → 登录/注册视图 + ├─ 注册:填写账号 + 密码(≥6位)→ 自动登录,返回 JWT + └─ 登录:账号 + 密码 → 验证成功,返回 JWT + └─ JWT 存入 localStorage,后续请求携带 Authorization: Bearer +``` + +### 2. 创作者入驻与角色设定 + +``` +登录用户 → 创作者入驻页 → 开通创作者身份 + → 创作者管理中心(三个 Tab) + ├─ 角色 Tab:新建 / 编辑角色 + ├─ 收入 Tab:余额 + 付费流水 + └─ 设置 Tab:昵称 / 角色库名 / API Key 管理 + +新建角色(4 步表单): + Step 1 → 基础信息(名称、性别、年龄、关系) + Step 2 → 性格设定(性格、背景、说话风格、喜好) + Step 3 → 模型配置(模型、温度、最大 Token、记忆/工具开关) + Step 4 → 发布设置(问候语、价格、描述) + → 提交后自动生成 SOUL.md + config.yaml 存入数据库 + → 角色上架到角色库 +``` + +### 3. 角色库浏览与购买 + +``` +任意用户 → 角色库 → 浏览已上架角色 + → 点击角色卡片 → 查看详情 + → 付款 → 创建订单 → 解锁角色 +``` + +## 与 Hermes Agent 交互流程 + +EternalAI 生成的角色配置(SOUL.md + config.yaml)可通过 CLI 拉取命令部署到任意 Hermes Agent 机器上,支持跨机器部署。 + +### 架构图 + +``` +服务器 A (EternalAI) 服务器 B (Hermes Agent) +┌───────────────────────┐ ┌───────────────────────┐ +│ 1. 设置页生成 API Key │ │ │ +│ (eak_xxxxxxxxxx) │ │ 5. hermes profile │ +│ │ │ create role-mio │ +│ 2. 创建角色 → 发布 │ │ │ +│ 生成 SOUL.md + │ │ 6. curl 拉取文件 │ +│ config.yaml │ │ SOUL.md │ +│ │ │ config.yaml │ +│ 3. 角色管理页显示 │ 4. 用户复制 │ │ +│ curl 命令模板 │──── curl ────▶│ 7. 配置 .env │ +│ (含 API Key) │ 命令执行 │ (API 密钥) │ +│ │ │ │ +│ │ │ 8. role-mio chat │ +└───────────────────────┘ └───────────────────────┘ +``` + +### 步骤详解 + +#### Step 1-3:在 EternalAI Web UI 操作 + +1. **生成 API Key**:创作者管理中心 → 设置 Tab → 点击「生成 API Key」→ 明文 Key 仅显示一次,请妥善保存 +2. **创建角色**:创作者管理中心 → 角色 Tab → 新建角色 → 填写 4 步表单 → 发布 +3. **获取部署命令**:角色卡片点击「Hermes 部署」→ 弹窗显示完整的 curl 命令模板(含服务器地址、角色 ID、API Key)→ 点击复制 + +#### Step 4-8:在 Hermes Agent 机器上操作 + +```bash +# 4. 复制 curl 命令到 Hermes 机器执行 + +# 5. 创建 Hermes profile +hermes profile create role-mio + +# 6. 拉取 SOUL.md +curl -H "Authorization: Bearer eak_xxx" \ + https://your-eternalai-domain.com/api/hermes/roles//SOUL.md \ + -o ~/.hermes/profiles/role-mio/SOUL.md + +# 7. 拉取 config.yaml +curl -H "Authorization: Bearer eak_xxx" \ + https://your-eternalai-domain.com/api/hermes/roles//config.yaml \ + -o ~/.hermes/profiles/role-mio/config.yaml + +# 8. 配置 API 密钥并启动 +echo "OPENROUTER_API_KEY=sk-or-xxx" > ~/.hermes/profiles/role-mio/.env +role-mio chat +``` + +### config.yaml 格式适配 + +EternalAI 角色数据自动转换为 Hermes config.yaml 格式: + +| EternalAI 字段 | Hermes config.yaml 字段 | +|---|---| +| `role.model` | `model:` | +| `role.temperature` | `temperature:` | +| `role.maxTokens` | `max_tokens:` | +| `role.enableMemory` | `memory.enabled:` | +| `role.enableTools` | `tools.enabled:` | + +生成的 config.yaml 示例: + +```yaml +# Hermes Agent Config — generated by EternalAI +# Character: Mio +# Agent ID: default +# Generated at: 2026-06-21T12:00:00.000Z + +model: "gpt-4o" +temperature: 0.85 +max_tokens: 2048 + +memory: + enabled: true + storage: local + +tools: + enabled: true + toolsets: + - memory + +terminal: + backend: local +``` + +### API Key 安全机制 + +- **格式**:`eak_` + 32 位随机 hex(128 位熵) +- **存储**:SHA-256 哈希存储,明文仅在生成时返回一次 +- **脱敏显示**:列表中仅显示前 12 位(如 `eak_a1b2c3d4`) +- **认证**:通过 `Authorization: Bearer eak_xxx` 头传递 +- **权限**:API Key 只能拉取所属用户创建的角色配置 +- **可吊销**:随时在设置页删除,删除后立即失效 + +## API 参考 + +### 认证 API + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| POST | `/api/auth/register` | 无 | 注册(账号 + 密码) | +| POST | `/api/auth/login` | 无 | 登录 | +| GET | `/api/auth/me` | JWT | 获取当前用户信息 | +| PUT | `/api/auth/settings` | JWT | 更新用户设置 | + +### 角色 API + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/api/roles` | 无 | 获取角色库(已上架) | +| GET | `/api/roles/:id` | 无 | 获取角色详情 | +| GET | `/api/roles/my/roles` | JWT | 获取我创建的角色 | +| POST | `/api/roles` | JWT | 发布新角色 | +| PUT | `/api/roles/:id` | JWT | 编辑角色 | +| GET | `/api/roles/:id/full` | JWT | 获取角色完整信息(含 SOUL.md) | + +### API Key 管理 + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| POST | `/api/apikeys` | JWT | 生成 API Key(明文仅返回一次) | +| GET | `/api/apikeys` | JWT | 列出所有 API Key(脱敏) | +| DELETE | `/api/apikeys/:id` | JWT | 删除 API Key | + +### Hermes 配置拉取 + +| 方法 | 路径 | 认证 | 说明 | +|------|------|------|------| +| GET | `/api/hermes/roles/:id/SOUL.md` | API Key / JWT | 拉取 SOUL.md(text/plain) | +| GET | `/api/hermes/roles/:id/config.yaml` | API Key / JWT | 拉取 config.yaml(text/plain) | + +## 测试 + +```bash +# 运行全部 E2E 测试(35 个用例) +npm test + +# 查看测试报告 +npx playwright show-report +``` + +测试覆盖:认证流程、角色发布/编辑、角色库浏览、导航与可访问性。 + +## 安全 + +- **JWT 密钥**:生产环境必须设置 `JWT_SECRET` 环境变量,未设置时 fail-fast 抛错 +- **密码存储**:bcrypt 哈希(10 轮盐),不存明文 +- **API Key 存储**:SHA-256 哈希存储,明文仅显示一次 +- **XSS 防护**:所有用户可控数据通过 `escapeHtml()` 转义后插入 DOM +- **YAML 注入防护**:config.yaml 生成时对用户可控字段做双引号转义和换行符过滤 +- **所有权校验**:所有角色操作验证 `creatorId === req.userId` +- **.env 文件权限**:部署脚本自动设置 `chmod 600` + +## License + +MIT diff --git a/app.js b/app.js index 739c28c..75e12a9 100644 --- a/app.js +++ b/app.js @@ -486,12 +486,12 @@ if (!listEl) return; listEl.innerHTML = '

加载中…

'; try { - const { keys } = await api('/apikeys'); - if (keys.length === 0) { + const { apiKeys } = await api('/apikeys'); + if (apiKeys.length === 0) { listEl.innerHTML = '

还没有 API Key

'; return; } - listEl.innerHTML = keys + listEl.innerHTML = apiKeys .map((k) => { const id = escapeHtml(k.id); const name = escapeHtml(k.name); diff --git a/deploy/setup-server.sh b/deploy/setup-server.sh index 3dd993f..07478d6 100644 --- a/deploy/setup-server.sh +++ b/deploy/setup-server.sh @@ -150,11 +150,12 @@ DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME" JWT_SECRET="$JWT_SECRET" PORT=3001 EOF - info ".env 文件已创建(请妥善保管数据库密码和 JWT 密钥)" + chmod 600 "$ENV_FILE" + info ".env 文件已创建(权限 600,请妥善保管数据库密码和 JWT 密钥)" echo "" echo "==============================" - echo " 数据库密码: $DB_PASS" echo " JWT 密钥已自动生成" + echo " 数据库密码已写入 .env 文件" echo "==============================" echo "" else diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1a2ed0..4d08d11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,11 +28,13 @@ model ApiKey { userId String name String @default("default") keyHash String @unique - keyPrefix String // 前 8 位,用于脱敏显示 + keyPrefix String // 前 12 位,用于脱敏显示 lastUsedAt DateTime? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) } model Role { diff --git a/src/lib/auth.js b/src/lib/auth.js index 51ff165..afb03d7 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -1,5 +1,7 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const prisma = require('./prisma'); const JWT_EXPIRES_IN = '7d'; @@ -67,8 +69,6 @@ async function apiKeyMiddleware(req, res, next) { // 如果是 API Key(eak_ 前缀),查数据库验证 if (token.startsWith('eak_')) { try { - const prisma = require('./prisma'); - const crypto = require('crypto'); const keyHash = crypto.createHash('sha256').update(token).digest('hex'); const apiKey = await prisma.apiKey.findUnique({ where: { keyHash }, @@ -81,7 +81,7 @@ async function apiKeyMiddleware(req, res, next) { prisma.apiKey.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() }, - }).catch(() => {}); + }).catch((err) => console.warn('更新 API Key lastUsedAt 失败:', err)); req.userId = apiKey.userId; req.authMethod = 'apikey'; return next(); diff --git a/src/routes/apikeys.js b/src/routes/apikeys.js index 30de3cf..cdf515a 100644 --- a/src/routes/apikeys.js +++ b/src/routes/apikeys.js @@ -51,7 +51,7 @@ router.get('/', authMiddleware, async (req, res) => { }, orderBy: { createdAt: 'desc' }, }); - res.json({ keys }); + res.json({ apiKeys: keys }); } catch (err) { console.error('获取 API Key 列表失败:', err); res.status(500).json({ error: '获取失败' }); diff --git a/src/routes/hermes.js b/src/routes/hermes.js index 4544220..08a85b2 100644 --- a/src/routes/hermes.js +++ b/src/routes/hermes.js @@ -4,15 +4,26 @@ const { apiKeyMiddleware } = require('../lib/auth'); const router = express.Router(); +// 转义 YAML 字符串值:双引号包裹,转义内部反斜杠和双引号 +function yamlString(value) { + const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +// 过滤注释行中的换行符,防止注释注入 +function sanitizeComment(value) { + return String(value).replace(/[\r\n]/g, ' '); +} + // 将 EternalAI 角色数据适配为 Hermes config.yaml 格式 function adaptToHermesConfig(role) { const lines = [ '# Hermes Agent Config — generated by EternalAI', - `# Character: ${role.displayName}`, - `# Agent ID: ${role.agentId || 'default'}`, + `# Character: ${sanitizeComment(role.displayName)}`, + `# Agent ID: ${sanitizeComment(role.agentId || 'default')}`, `# Generated at: ${new Date().toISOString()}`, '', - `model: ${role.model || 'hermes-3-llama-3.1-70b'}`, + `model: ${yamlString(role.model)}`, `temperature: ${role.temperature ?? 0.85}`, `max_tokens: ${role.maxTokens ?? 2048}`, '', @@ -37,18 +48,18 @@ router.get('/roles/:id/SOUL.md', apiKeyMiddleware, async (req, res) => { select: { id: true, creatorId: true, displayName: true, soulMd: true }, }); if (!role) { - return res.status(404).type('text/plain').send('Role not found'); + return res.status(404).json({ error: 'Role not found' }); } if (role.creatorId !== req.userId) { - return res.status(403).type('text/plain').send('Forbidden: not the role owner'); + return res.status(403).json({ error: 'Forbidden: not the role owner' }); } if (!role.soulMd) { - return res.status(404).type('text/plain').send('SOUL.md not generated for this role'); + return res.status(404).json({ error: 'SOUL.md not generated for this role' }); } res.type('text/plain').send(role.soulMd); } catch (err) { console.error('获取 SOUL.md 失败:', err); - res.status(500).type('text/plain').send('Server error'); + res.status(500).json({ error: 'Server error' }); } }); @@ -70,16 +81,16 @@ router.get('/roles/:id/config.yaml', apiKeyMiddleware, async (req, res) => { }, }); if (!role) { - return res.status(404).type('text/plain').send('Role not found'); + return res.status(404).json({ error: 'Role not found' }); } if (role.creatorId !== req.userId) { - return res.status(403).type('text/plain').send('Forbidden: not the role owner'); + return res.status(403).json({ error: 'Forbidden: not the role owner' }); } const config = adaptToHermesConfig(role); res.type('text/plain').send(config); } catch (err) { console.error('获取 config.yaml 失败:', err); - res.status(500).type('text/plain').send('Server error'); + res.status(500).json({ error: 'Server error' }); } });