diff --git a/netlify/functions/_shared/middleware.js b/netlify/functions/_shared/middleware.js index bf695a5..655e81f 100644 --- a/netlify/functions/_shared/middleware.js +++ b/netlify/functions/_shared/middleware.js @@ -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); } diff --git a/server.js b/server.js index e7177a1..88df39b 100644 --- a/server.js +++ b/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 }; diff --git a/src/simyo/js/modules/api-config.js b/src/simyo/js/modules/api-config.js index e625984..969fffb 100644 --- a/src/simyo/js/modules/api-config.js +++ b/src/simyo/js/modules/api-config.js @@ -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')); } diff --git a/tests/simyo/device-change-handler.test.js b/tests/simyo/device-change-handler.test.js index 67320be..5b91909 100644 --- a/tests/simyo/device-change-handler.test.js +++ b/tests/simyo/device-change-handler.test.js @@ -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' }) ); });