150 lines
5.4 KiB
JavaScript
150 lines
5.4 KiB
JavaScript
// 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;
|