feat: CORS 多源支持 + Simyo 代理修复 + API 响应处理重构

- CORS 支持逗号分隔多源列表和通配符,新增 isAllowedOrigin/resolveCorsOrigin
- Simyo 代理路径从 req.path 改为 req.originalUrl,修复查询参数丢失
- Simyo 客户端配置提取为常量,支持环境变量覆盖
- API 端点检测浏览器协议,HTTP 访问时使用相对路径
- handleApiResponse 重构:content-type 检查、HTTP 错误优先、兼容三种响应格式
- 新增 X-Session-Token 头透传
- Logger 简化,移除前端模块依赖
- 同步更新测试 mock 以匹配新接口契约
This commit is contained in:
Abner
2026-05-24 20:36:05 +08:00
parent b5929cab3c
commit 59a7a97c29
4 changed files with 115 additions and 49 deletions

View File

@@ -5,7 +5,10 @@
const { captureException, flush, setContext } = require('./sentry');
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
const DEFAULT_ALLOWED_ORIGIN = 'https://esim.cosr.eu.org';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || DEFAULT_ALLOWED_ORIGIN;
const ALLOWED_ORIGINS = ALLOWED_ORIGIN.split(',').map(origin => origin.trim()).filter(Boolean);
const ALLOW_ALL_ORIGINS = ALLOWED_ORIGINS.includes('*');
const ACCESS_KEY = process.env.ACCESS_KEY;
// 启动时检查密钥
@@ -29,6 +32,18 @@ class AuthError extends Error {
}
}
function isAllowedOrigin(origin) {
if (!origin) return true;
if (ALLOW_ALL_ORIGINS) return true;
return ALLOWED_ORIGINS.includes(origin);
}
function resolveCorsOrigin(origin) {
if (ALLOW_ALL_ORIGINS) return '*';
if (origin && ALLOWED_ORIGINS.includes(origin)) return origin;
return ALLOWED_ORIGINS[0] || DEFAULT_ALLOWED_ORIGIN;
}
/**
* 提取请求中提供的认证密钥
* @param {Object} event - Netlify event 对象
@@ -71,14 +86,14 @@ function authenticate(event) {
// CORS 预检请求
if (event.httpMethod === 'OPTIONS') {
// 验证来源
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
if (!isAllowedOrigin(requestOrigin)) {
throw new AuthError('Origin not allowed', 403);
}
return { preflight: true, origin: requestOrigin };
}
// 非预检请求:验证来源
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
if (!isAllowedOrigin(requestOrigin)) {
throw new AuthError('Origin not allowed', 403);
}
@@ -104,7 +119,7 @@ function authenticate(event) {
*/
function createHeaders(origin = ALLOWED_ORIGIN, additionalHeaders = {}) {
return {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Origin': resolveCorsOrigin(origin),
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-MFA-Signature, X-MFA-Ref, X-MFA-Challenge-Ref, X-CF-Turnstile, X-Esim-Key, X-App-Key',
'Access-Control-Allow-Methods': 'POST, OPTIONS, GET',
'Vary': 'Origin',
@@ -241,7 +256,7 @@ function withAuth(handler, options = {}) {
// CORS 预检请求
if (event.httpMethod === 'OPTIONS') {
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
if (!isAllowedOrigin(requestOrigin)) {
throw new AuthError('Origin not allowed', 403);
}
return {
@@ -252,7 +267,7 @@ function withAuth(handler, options = {}) {
}
// 非预检请求:仅验证来源
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
if (!isAllowedOrigin(requestOrigin)) {
throw new AuthError('Origin not allowed', 403);
}

View File

@@ -9,13 +9,42 @@ const path = require('path');
const fs = require('fs');
const helmet = require('helmet');
const morgan = require('morgan');
const Logger = require('./src/js/modules/logger.js');
require('dotenv').config();
if (typeof global.File === 'undefined') {
global.File = class File {};
}
const Logger = {
log: (...args) => console.log('[INFO]', ...args),
warn: (...args) => console.warn('[WARN]', ...args),
error: (...args) => console.error('[ERROR]', ...args)
};
const app = express();
const PORT = process.env.PORT || 3000;
const STATIC_ROOT = path.join(__dirname, process.env.STATIC_ROOT || 'dist');
const INTERNAL_FUNCTION_KEY = process.env.ACCESS_KEY || '';
const DEFAULT_ALLOWED_ORIGIN = 'https://esim.cosr.eu.org';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || DEFAULT_ALLOWED_ORIGIN;
const ALLOWED_ORIGINS = ALLOWED_ORIGIN.split(',').map(origin => origin.trim()).filter(Boolean);
const ALLOW_ALL_ORIGINS = ALLOWED_ORIGINS.includes('*');
const DEFAULT_SIMYO_CLIENT_TOKEN = 'e77b7e2f43db41bb95b17a2a11581a38';
const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios';
const DEFAULT_SIMYO_CLIENT_VERSION = '4.23.5';
const DEFAULT_SIMYO_USER_AGENT = 'MijnSimyoFT/4.23.5 (iOS 26.3; iPhone16,1)';
function isAllowedOrigin(origin) {
if (!origin) return true;
if (ALLOW_ALL_ORIGINS) return true;
return ALLOWED_ORIGINS.includes(origin);
}
function getCorsOrigin(origin) {
if (ALLOW_ALL_ORIGINS) return '*';
if (origin && ALLOWED_ORIGINS.includes(origin)) return origin;
return ALLOWED_ORIGINS[0] || DEFAULT_ALLOWED_ORIGIN;
}
// 启动时环境检查
if (!INTERNAL_FUNCTION_KEY) {
@@ -49,11 +78,9 @@ app.use(helmet({
}));
// 仅允许特定来源访问本地API前端文件本地打开时可能 Origin 为 undefined
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
app.use(cors({
origin: function(origin, callback) {
if (!origin) return callback(null, true); // 非浏览器/本地文件放行
if (origin === ALLOWED_ORIGIN) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true); // 非浏览器/本地文件放行
return callback(new Error('Not allowed by CORS'));
},
credentials: false
@@ -140,13 +167,14 @@ app.use('/.netlify/functions/public-config', wrapNetlifyFunction(publicConfig));
// Simyo API代理路由
app.use('/api/simyo/*', (req, res) => {
const targetUrl = `https://appapi.simyo.nl/simyoapi/api/v1${req.path.replace('/api/simyo', '')}`;
const proxyPath = req.originalUrl.replace(/^\/api\/simyo/, '').split('?')[0] || '/';
const targetUrl = `https://appapi.simyo.nl/simyoapi/api/v1${proxyPath}`;
Logger.log(`[Simyo Proxy] ${req.method} ${req.path} -> ${targetUrl}`);
// 设置CORS头仅允许指定域
res.header('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
res.header('Access-Control-Allow-Origin', getCorsOrigin(req.headers.origin));
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Token, X-Client-Platform, X-Client-Version');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Token, X-Client-Platform, X-Client-Version, X-Session-Token');
res.header('Vary', 'Origin');
if (req.method === 'OPTIONS') {
@@ -160,10 +188,11 @@ app.use('/api/simyo/*', (req, res) => {
url: targetUrl,
headers: {
'Content-Type': 'application/json',
'User-Agent': req.headers['user-agent'] || 'MijnSimyoFT/4.23.5 (iOS 26.3; iPhone16,1)',
'X-Client-Token': req.headers['x-client-token'] || process.env.SIMYO_CLIENT_TOKEN || '',
'X-Client-Platform': req.headers['x-client-platform'] || 'ios',
'X-Client-Version': req.headers['x-client-version'] || '4.23.5'
'User-Agent': req.headers['user-agent'] || process.env.SIMYO_USER_AGENT || DEFAULT_SIMYO_USER_AGENT,
'X-Client-Token': req.headers['x-client-token'] || process.env.SIMYO_CLIENT_TOKEN || DEFAULT_SIMYO_CLIENT_TOKEN,
'X-Client-Platform': req.headers['x-client-platform'] || process.env.SIMYO_CLIENT_PLATFORM || DEFAULT_SIMYO_CLIENT_PLATFORM,
'X-Client-Version': req.headers['x-client-version'] || process.env.SIMYO_CLIENT_VERSION || DEFAULT_SIMYO_CLIENT_VERSION,
'X-Session-Token': req.headers['x-session-token'] || ''
},
timeout: 30000
};

View File

@@ -21,35 +21,37 @@ export const simyoConfig = {
*/
export function getApiEndpoints() {
const isNetlify = isNetlifyEnvironment();
const isBrowserServed = typeof window !== 'undefined' && /^https?:$/.test(window.location.protocol);
const localBase = isBrowserServed ? '' : 'http://localhost:3000';
return {
// 认证相关
login: isNetlify
? "/api/simyo/sessions"
: "http://localhost:3000/api/simyo/sessions",
: `${localBase}/api/simyo/sessions`,
// eSIM相关
getEsim: isNetlify
? "/api/simyo/esim/get-by-customer"
: "http://localhost:3000/api/simyo/esim/get-by-customer",
: `${localBase}/api/simyo/esim/get-by-customer`,
// 设备更换相关
applyNewEsim: isNetlify
? "/api/simyo/settings/simcard"
: "http://localhost:3000/api/simyo/settings/simcard",
: `${localBase}/api/simyo/settings/simcard`,
verifyCode: isNetlify
? "/api/simyo/esim/verify-code"
: "http://localhost:3000/api/simyo/esim/verify-code",
: `${localBase}/api/simyo/esim/verify-code`,
// 新增:查询可用的验证方式 (v2 API)
availableValidationMethods: isNetlify
? "/api/simyo/esim.availableValidationMethods"
: "http://localhost:3000/api/simyo/esim.availableValidationMethods",
: `${localBase}/api/simyo/esim.availableValidationMethods`,
// 安装确认
confirmInstall: isNetlify
? "/api/simyo/esim/reorder-profile-installed"
: "http://localhost:3000/api/simyo/esim/reorder-profile-installed",
: `${localBase}/api/simyo/esim/reorder-profile-installed`,
// 二维码服务
qrcode: "https://qrcode.show/"
@@ -82,30 +84,45 @@ export function createHeaders(includeSession = false, sessionToken = "") {
* @param {Response} response - Fetch API响应对象
*/
export async function handleApiResponse(response) {
const data = await response.json();
const contentType = response.headers.get('content-type') || '';
const data = contentType.includes('application/json')
? await response.json()
: { message: await response.text() };
// 适配不同环境的响应格式
const isNetlify = isNetlifyEnvironment();
if (isNetlify) {
// Netlify环境直接从Simyo API响应获取
// Simyo API响应格式{result: {success: true, ...}}
// 需要展平 result 以便统一处理
if (data.result && data.result.success !== undefined) {
// 返回展平后的结构,便于后续使用
return {
success: data.result.success,
result: data.result,
message: data.result.message || data.result.reason
};
}
return data;
} else {
// 本地代理环境:从包装的响应获取
if (data.success && data.result) {
return data;
} else {
throw new Error(data.message || data.error || t('simyo.api.error.generic'));
}
// 先处理 HTTP 错误,尽量透传服务端信息
if (!response.ok) {
throw new Error(
data.message ||
data.error ||
data.reason ||
`HTTP ${response.status}`
);
}
// 统一兼容三种返回格式:
// 1. 本地旧代理包装: { success, result, message }
// 2. 直通 Simyo: { result: {...} }
// 3. Simyo 展平成功结构: { success: true, ... }
if (data.success === true && data.result) {
return data;
}
if (data.result) {
const nestedSuccess = data.result.success;
return {
success: nestedSuccess !== false,
result: data.result,
message: data.message || data.result.message || data.result.reason
};
}
if (data.success === true) {
return {
success: true,
result: data,
message: data.message || data.reason
};
}
throw new Error(data.message || data.error || data.reason || t('simyo.api.error.generic'));
}

View File

@@ -13,8 +13,11 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
});
it('应完成设备更换主流程applyNewEsim -> verifyCode', async () => {
const jsonHeaders = { get: (name) => name === 'content-type' ? 'application/json' : null };
global.fetch
.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
success: true,
result: {
@@ -24,6 +27,8 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
})
})
.mockResolvedValueOnce({
headers: jsonHeaders,
ok: true,
json: async () => ({
success: true,
result: {
@@ -42,12 +47,12 @@ describe('Simyo DeviceChangeHandler 集成覆盖', () => {
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
'http://localhost:3000/api/simyo/settings/simcard',
'/api/simyo/settings/simcard',
expect.objectContaining({ method: 'POST' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
'http://localhost:3000/api/simyo/esim/verify-code',
'/api/simyo/esim/verify-code',
expect.objectContaining({ method: 'POST' })
);
});