EternalAI/hermes-server/src/routes/sync.js

150 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// POST /api/sync — 接收 EternalAI 的同步请求
// 流程:验证 sync_token → 回调拉取 SOUL.md + config.yaml → 创建/更新 profile → 生成二维码 → 返回
const express = require('express');
const { verifySyncToken } = require('../lib/sync-token-verify');
const { pullRoleFiles } = require('../lib/eternalai-client');
const { generateProfileId, createProfile, findByRoleId, deleteProfile } = require('../lib/profile-store');
const { generateAndSaveQrCode } = require('../lib/qrcode-gen');
const router = express.Router();
// P2 修复:输入校验
const MAX_PROFILE_NAME_LEN = 128;
// 来源 IP 白名单检查P1 修复:使用 socket.remoteAddress 而非可伪造的 req.ip
function checkSourceIp(req, res, next) {
const allowedIps = process.env.ALLOWED_SOURCE_IPS;
if (!allowedIps) return next();
// 使用 socket 远程地址,不受 X-Forwarded-For 影响
const clientIp = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
if (!clientIp) {
return res.status(403).json({ error: '无法确定来源 IP' });
}
const allowed = allowedIps.split(',').map((ip) => ip.trim());
if (!allowed.includes(clientIp)) {
return res.status(403).json({ error: '来源 IP 不在白名单中' });
}
next();
}
router.post('/sync', checkSourceIp, async (req, res) => {
try {
const {
profileName,
modelKey,
provider,
multimediaModelKey,
multimediaProvider,
enableSchedule,
syncToken,
roleId,
} = req.body;
// 参数校验
if (!syncToken || !roleId || !profileName) {
return res.status(400).json({ error: '缺少必要参数syncToken, roleId, profileName' });
}
// P2 修复profileName 长度限制
if (profileName.length > MAX_PROFILE_NAME_LEN) {
return res.status(400).json({ error: `profileName 过长(上限 ${MAX_PROFILE_NAME_LEN} 字符)` });
}
// 验证 sync_token
const payload = verifySyncToken(syncToken);
if (!payload) {
return res.status(401).json({ error: 'sync_token 无效或已过期' });
}
// 验证 roleId 匹配
if (payload.roleId !== roleId) {
return res.status(403).json({ error: 'roleId 与 sync_token 不匹配' });
}
// P1 修复:幂等去重 — 若该 roleId 已有 profile先删除旧的再创建新的
const existing = findByRoleId(roleId);
if (existing) {
deleteProfile(existing.profileId);
}
// 回调 EternalAI 拉取 SOUL.md 和 config.yaml
// P0 修复:使用环境变量 ETERNALAI_BASE_URL不信任请求体中的 filePullBaseUrl
const { soulMd, configYaml } = await pullRoleFiles(roleId, syncToken);
// P2 修复:校验两个文件都非空
if (!soulMd || soulMd.length === 0) {
return res.status(400).json({ error: '拉取的 SOUL.md 为空' });
}
if (!configYaml || configYaml.length === 0) {
return res.status(400).json({ error: '拉取的 config.yaml 为空' });
}
// 创建 profile
const profileId = generateProfileId();
const baseUrl = process.env.HERMES_BASE_URL || `http://localhost:${process.env.PORT || 3002}`;
const bindUrl = `${baseUrl}/api/bind/${profileId}`;
// P2 修复:先创建 profile再生成二维码避免孤儿文件
// 先用 null 作为 qrCodeUrl 创建 profile
const meta = createProfile({
profileId,
profileName,
roleId,
sourceBaseUrl: process.env.ETERNALAI_BASE_URL || 'unknown',
modelKey,
provider,
multimediaModelKey,
multimediaProvider,
enableSchedule: !!enableSchedule,
soulMd,
configYaml,
qrCodeUrl: null, // 稍后更新
});
// 生成二维码
try {
const qrFilename = await generateAndSaveQrCode(profileId, bindUrl);
const qrCodeUrl = `${baseUrl}/api/qrcodes/${qrFilename}`;
// 更新 meta 中的 qrCodeUrl通过重新写入 meta.json
const { getProfileMeta } = require('../lib/profile-store');
const updatedMeta = getProfileMeta(profileId);
if (updatedMeta) {
updatedMeta.qrCodeUrl = qrCodeUrl;
const fs = require('fs');
const path = require('path');
const metaPath = path.join(__dirname, '..', '..', 'data', 'profiles', profileId, 'meta.json');
fs.writeFileSync(metaPath, JSON.stringify(updatedMeta, null, 2));
}
meta.qrCodeUrl = qrCodeUrl;
} catch (qrErr) {
console.error('[Hermes] 二维码生成失败:', qrErr.message);
// 二维码生成失败不阻塞 profile 创建,但返回中不包含 qrCodeUrl
}
console.log(`[Hermes] Profile 创建成功: ${profileId} (name=${profileName}, roleId=${roleId})`);
res.json({
qrCodeUrl: meta.qrCodeUrl,
profileId,
message: 'Profile 创建成功',
});
} catch (err) {
console.error('[Hermes] 同步失败:', err.message);
// P3 修复:区分已知错误类型
if (err.message.includes('超时')) {
return res.status(504).json({ error: '拉取文件超时,请检查 EternalAI 服务状态' });
}
if (err.message.includes('ETERNALAI_BASE_URL')) {
return res.status(500).json({ error: 'Hermes Server 未配置 ETERNALAI_BASE_URL' });
}
if (err.message.includes('拉取') && err.message.includes('失败')) {
return res.status(502).json({ error: '拉取文件失败,请检查 EternalAI 服务是否正常' });
}
res.status(500).json({ error: '同步失败,请稍后重试' });
}
});
module.exports = router;