mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
✨ 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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
51
server.js
51
server.js
@@ -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
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user