fix(security): apply code review fixes and rewrite README
Deploy EternalAI / deploy (push) Failing after 30s Details

- 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
This commit is contained in:
chiguyong 2026-06-21 14:05:11 +08:00
parent 6d3d735c9c
commit 7beac62b08
7 changed files with 323 additions and 53 deletions

322
README.md
View File

@ -1,50 +1,306 @@
# Eternal AI # 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 SPAIIFE 模式,无框架)、玻璃拟态 UI |
| 后端 | Node.js + Express 5.x |
| 数据库 | PostgreSQL 15 + Prisma ORM 5.x |
| 认证 | JWT7 天有效期)+ API Key`eak_` 前缀SHA-256 哈希存储) |
| 密码 | bcryptjs10 轮盐) |
| 测试 | Playwright E2E35 个测试用例) |
| 部署 | PM2 + Nginx 反向代理 |
### 项目结构
``` ```
EternalAI/ EternalAI/
├── index.html # 单页应用入口9 个视图) ├── server.js # Express 入口,注册路由 + 静态文件
├── app.js # 应用逻辑(路由、状态管理、表单生成) ├── app.js # 前端 SPA 逻辑路由、状态、表单、API 调用)
├── styles.css # 样式(玻璃拟态、深空背景) ├── index.html # 单页应用入口9 个视图)
├── server.js # Express 静态服务器(端口 3001 ├── styles.css # 玻璃拟态样式
├── img/ # 背景与卡片素材 ├── prisma/
├── docs/plans/ # 规划文档 │ └── schema.prisma # 数据模型定义User / Role / ApiKey / Order
├── Eternal_AI_PRD_v1.docx # 产品需求文档 ├── 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 └── package.json
``` ```
## 快速开始 ## 快速开始
```bash ### 本地开发
# 方式一Express 服务器
npm install && npm start
# → http://localhost:3001
# 方式二:任意静态服务器 ```bash
python3 -m http.server 8083 # 1. 安装依赖
# → http://localhost:8083 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 | 说明 | ```bash
|------|---------|------| # 在服务器上执行一键初始化脚本
| 首页 | landing | 两张入口卡片 + 底部链接 | bash deploy/setup-server.sh
| 登录/注册 | auth | 登录态分流 | # 自动安装 Node.js / PostgreSQL / Nginx / PM2创建 .env推送 Schema
| 角色库 | role-library | 创作者上架的角色列表 | ```
| 角色详情 | role-detail | 单个角色详情 + 付款流程 |
| 蒸馏前任 | distill | 自营情感服务介绍页 |
| 关于 Eternal AI | about | 平台简介 + FAQ |
| 创作者入驻 | onboarding | 合作说明 + 微信联系方式 |
| 创作者管理中心 | creator-center | 角色/收入/设置 三 tab |
| 角色编辑 | creator | 4 步表单 → 生成 Soul.md + config.yaml |
## 技术栈 ## 操作流程
- 纯前端 HTML5 SPAIIFE 模式,无框架) ### 1. 用户注册与登录
- localStorage 持久化登录态
- Express 静态文件服务 ```
- 玻璃拟态 UI + 深空背景 用户 → 首页 → 登录/注册视图
├─ 注册:填写账号 + 密码≥6位→ 自动登录,返回 JWT
└─ 登录:账号 + 密码 → 验证成功,返回 JWT
└─ JWT 存入 localStorage后续请求携带 Authorization: Bearer <token>
```
### 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/<role-id>/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/<role-id>/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 位随机 hex128 位熵)
- **存储**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.mdtext/plain |
| GET | `/api/hermes/roles/:id/config.yaml` | API Key / JWT | 拉取 config.yamltext/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

6
app.js
View File

@ -486,12 +486,12 @@
if (!listEl) return; if (!listEl) return;
listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">加载中…</p>'; listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">加载中…</p>';
try { try {
const { keys } = await api('/apikeys'); const { apiKeys } = await api('/apikeys');
if (keys.length === 0) { if (apiKeys.length === 0) {
listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">还没有 API Key</p>'; listEl.innerHTML = '<p style="color:var(--text-muted);font-size:0.875rem;">还没有 API Key</p>';
return; return;
} }
listEl.innerHTML = keys listEl.innerHTML = apiKeys
.map((k) => { .map((k) => {
const id = escapeHtml(k.id); const id = escapeHtml(k.id);
const name = escapeHtml(k.name); const name = escapeHtml(k.name);

View File

@ -150,11 +150,12 @@ DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
JWT_SECRET="$JWT_SECRET" JWT_SECRET="$JWT_SECRET"
PORT=3001 PORT=3001
EOF EOF
info ".env 文件已创建(请妥善保管数据库密码和 JWT 密钥)" chmod 600 "$ENV_FILE"
info ".env 文件已创建(权限 600请妥善保管数据库密码和 JWT 密钥)"
echo "" echo ""
echo "==============================" echo "=============================="
echo " 数据库密码: $DB_PASS"
echo " JWT 密钥已自动生成" echo " JWT 密钥已自动生成"
echo " 数据库密码已写入 .env 文件"
echo "==============================" echo "=============================="
echo "" echo ""
else else

View File

@ -28,11 +28,13 @@ model ApiKey {
userId String userId String
name String @default("default") name String @default("default")
keyHash String @unique keyHash String @unique
keyPrefix String // 前 8 位,用于脱敏显示 keyPrefix String // 前 12 位,用于脱敏显示
lastUsedAt DateTime? lastUsedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
} }
model Role { model Role {

View File

@ -1,5 +1,7 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const prisma = require('./prisma');
const JWT_EXPIRES_IN = '7d'; const JWT_EXPIRES_IN = '7d';
@ -67,8 +69,6 @@ async function apiKeyMiddleware(req, res, next) {
// 如果是 API Keyeak_ 前缀),查数据库验证 // 如果是 API Keyeak_ 前缀),查数据库验证
if (token.startsWith('eak_')) { if (token.startsWith('eak_')) {
try { try {
const prisma = require('./prisma');
const crypto = require('crypto');
const keyHash = crypto.createHash('sha256').update(token).digest('hex'); const keyHash = crypto.createHash('sha256').update(token).digest('hex');
const apiKey = await prisma.apiKey.findUnique({ const apiKey = await prisma.apiKey.findUnique({
where: { keyHash }, where: { keyHash },
@ -81,7 +81,7 @@ async function apiKeyMiddleware(req, res, next) {
prisma.apiKey.update({ prisma.apiKey.update({
where: { id: apiKey.id }, where: { id: apiKey.id },
data: { lastUsedAt: new Date() }, data: { lastUsedAt: new Date() },
}).catch(() => {}); }).catch((err) => console.warn('更新 API Key lastUsedAt 失败:', err));
req.userId = apiKey.userId; req.userId = apiKey.userId;
req.authMethod = 'apikey'; req.authMethod = 'apikey';
return next(); return next();

View File

@ -51,7 +51,7 @@ router.get('/', authMiddleware, async (req, res) => {
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
res.json({ keys }); res.json({ apiKeys: keys });
} catch (err) { } catch (err) {
console.error('获取 API Key 列表失败:', err); console.error('获取 API Key 列表失败:', err);
res.status(500).json({ error: '获取失败' }); res.status(500).json({ error: '获取失败' });

View File

@ -4,15 +4,26 @@ const { apiKeyMiddleware } = require('../lib/auth');
const router = express.Router(); 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 格式 // 将 EternalAI 角色数据适配为 Hermes config.yaml 格式
function adaptToHermesConfig(role) { function adaptToHermesConfig(role) {
const lines = [ const lines = [
'# Hermes Agent Config — generated by EternalAI', '# Hermes Agent Config — generated by EternalAI',
`# Character: ${role.displayName}`, `# Character: ${sanitizeComment(role.displayName)}`,
`# Agent ID: ${role.agentId || 'default'}`, `# Agent ID: ${sanitizeComment(role.agentId || 'default')}`,
`# Generated at: ${new Date().toISOString()}`, `# Generated at: ${new Date().toISOString()}`,
'', '',
`model: ${role.model || 'hermes-3-llama-3.1-70b'}`, `model: ${yamlString(role.model)}`,
`temperature: ${role.temperature ?? 0.85}`, `temperature: ${role.temperature ?? 0.85}`,
`max_tokens: ${role.maxTokens ?? 2048}`, `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 }, select: { id: true, creatorId: true, displayName: true, soulMd: true },
}); });
if (!role) { 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) { 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) { 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); res.type('text/plain').send(role.soulMd);
} catch (err) { } catch (err) {
console.error('获取 SOUL.md 失败:', 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) { 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) { 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); const config = adaptToHermesConfig(role);
res.type('text/plain').send(config); res.type('text/plain').send(config);
} catch (err) { } catch (err) {
console.error('获取 config.yaml 失败:', err); console.error('获取 config.yaml 失败:', err);
res.status(500).type('text/plain').send('Server error'); res.status(500).json({ error: 'Server error' });
} }
}); });