/** * 本地开发服务器 * 提供静态文件服务和API代理功能 */ const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const helmet = require('helmet'); const morgan = require('morgan'); 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 { parseOrigins, isAllowedOrigin: _isAllowedOrigin, resolveCorsOrigin: _resolveCorsOrigin } = require('./netlify/functions/_shared/cors'); const origins = parseOrigins(process.env.ALLOWED_ORIGIN); const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins); const getCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins); // 与 src/simyo/js/modules/client-identity.js 保持同步 const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios'; const DEFAULT_SIMYO_CLIENT_VERSION = '4.28.0'; const DEFAULT_SIMYO_IOS_VERSION = '18.2'; const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone12,8'; // 版本号与括号之间为两个空格 const DEFAULT_SIMYO_USER_AGENT = `MijnSimyoFT/${DEFAULT_SIMYO_CLIENT_VERSION} (iOS ${DEFAULT_SIMYO_IOS_VERSION}; ${DEFAULT_SIMYO_DEVICE_MODEL})`; const crypto = require('crypto'); function getDefaultSimyoDeviceId() { if (process.env.SIMYO_DEVICE_ID) return process.env.SIMYO_DEVICE_ID; // 进程内稳定 ID,避免每次请求换设备身份 if (!global.__simyoDeviceId) { global.__simyoDeviceId = crypto.randomUUID().toUpperCase(); } return global.__simyoDeviceId; } // 启动时环境检查 if (!INTERNAL_FUNCTION_KEY) { console.error('❌ ACCESS_KEY 未配置'); console.error('💡 请在 .env 文件或环境变量中设置 ACCESS_KEY'); console.error('⚠️ Netlify Functions 将无法正常工作,请修复后重启'); } if (!process.env.SIMYO_CLIENT_TOKEN) { console.warn('⚠️ SIMYO_CLIENT_TOKEN 未配置,Simyo 代理请求可能失败'); console.warn('💡 请在 .env 文件中设置 SIMYO_CLIENT_TOKEN'); } if (!fs.existsSync(STATIC_ROOT)) { console.warn(`⚠️ 静态目录 ${STATIC_ROOT} 不存在,请先运行 npm run build`); console.warn('💡 运行: npm run build'); } if (origins.allowAll) { Logger.warn('⚠️ ALLOWED_ORIGIN 包含通配符(*),所有来源均可访问。请勿在生产环境使用'); } // 中间件配置 app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://browser.sentry-cdn.com", "https://sentry.io", "https://*.sentry.io"], styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com"], imgSrc: ["'self'", "data:", "https:", "http:"], // jsdelivr:Bootstrap source map;sentry-cdn:SDK 回退加载 connectSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://browser.sentry-cdn.com", "https://sentry.io", "https://*.sentry.io"], fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"], frameSrc: ["'self'", "https://*.sentry.io"], workerSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"] } } })); // 仅允许特定来源访问本地API(前端文件本地打开时可能 Origin 为 undefined) app.use(cors({ origin: function(origin, callback) { if (isAllowedOrigin(origin)) return callback(null, true); // 非浏览器/本地文件放行 return callback(new Error('Not allowed by CORS')); }, credentials: false })); app.use(morgan('combined')); app.use(express.json()); app.use(express.urlencoded({ extended: true })); const staticMiddleware = express.static(STATIC_ROOT, { fallthrough: true, index: false }); // 全局限流:每 IP 每分钟最多 200 次请求 const { createRateLimiter } = require('./src/js/middleware/validation.js'); app.use(createRateLimiter({ windowMs: 60000, maxRequests: 200 })); app.use((req, res, next) => { if (!['GET', 'HEAD'].includes(req.method)) { return next(); } if (/\.html?$/i.test(req.path)) { return next(); } return staticMiddleware(req, res, next); }); // API路由 - 模拟Netlify Functions const giffgaffMfaChallenge = require('./netlify/functions/giffgaff-mfa-challenge'); const giffgaffMfaValidation = require('./netlify/functions/giffgaff-mfa-validation'); const giffgaffGraphql = require('./netlify/functions/giffgaff-graphql'); const giffgaffTokenExchange = require('./netlify/functions/giffgaff-token-exchange'); const verifyCookie = require('./netlify/functions/verify-cookie'); const giffgaffSmsActivate = require('./netlify/functions/giffgaff-sms-activate'); const autoActivateEsim = require('./netlify/functions/auto-activate-esim'); const publicConfig = require('./netlify/functions/public-config'); // 包装Netlify Functions为Express路由 function wrapNetlifyFunction(handler) { return async (req, res) => { try { const headers = Object.assign({}, req.headers); // 仅在客户端未提供密钥时注入内部密钥(避免覆盖) if (INTERNAL_FUNCTION_KEY && !headers['x-esim-key'] && !headers['x-app-key']) { headers['x-esim-key'] = INTERNAL_FUNCTION_KEY; } const event = { httpMethod: req.method, headers, body: JSON.stringify(req.body), queryStringParameters: req.query }; const context = {}; const result = await handler.handler(event, context); res.status(result.statusCode); if (result.headers) { Object.entries(result.headers).forEach(([key, value]) => { res.set(key, value); }); } if (result.body) { const body = typeof result.body === 'string' ? result.body : JSON.stringify(result.body); res.send(body); } else { res.end(); } } catch (error) { console.error('API Error:', error); res.status(500).json({ error: 'Internal Server Error', message: error.message }); } }; } // API端点(同时挂 /.netlify/functions/* 与 /bff/*,本地模拟 Edge BFF 代理) const functionRoutes = [ ['giffgaff-mfa-challenge', giffgaffMfaChallenge], ['giffgaff-mfa-validation', giffgaffMfaValidation], ['giffgaff-graphql', giffgaffGraphql], ['giffgaff-token-exchange', giffgaffTokenExchange], ['verify-cookie', verifyCookie], ['giffgaff-sms-activate', giffgaffSmsActivate], ['auto-activate-esim', autoActivateEsim], ['public-config', publicConfig] ]; app.locals.bffRoutes = functionRoutes.map(([name]) => `/bff/${name}`); app.locals.functionRoutes = functionRoutes.map(([name]) => `/.netlify/functions/${name}`); functionRoutes.forEach(([name, handler]) => { const wrapped = wrapNetlifyFunction(handler); app.use(`/.netlify/functions/${name}`, wrapped); app.use(`/bff/${name}`, wrapped); }); // Simyo API代理路由(支持 /api/simyo/v2/* → webapi/api/v2,其余 → webapi/api/v1) app.use('/api/simyo/*', (req, res) => { const [pathPart, queryPart] = req.originalUrl.replace(/^\/api\/simyo/, '').split('?'); const proxyPath = pathPart || '/'; const queryString = queryPart ? `?${queryPart}` : ''; const isV2 = proxyPath === '/v2' || proxyPath.startsWith('/v2/'); const apiVersionPath = isV2 ? proxyPath.replace(/^\/v2/, '') || '/' : proxyPath; const targetUrl = isV2 ? `https://appapi.simyo.nl/webapi/api/v2${apiVersionPath}${queryString}` : `https://appapi.simyo.nl/webapi/api/v1${proxyPath}${queryString}`; Logger.log(`[Simyo Proxy] ${req.method} ${req.path} -> ${targetUrl}`); // 设置CORS头(仅允许指定域) 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, Accept, X-Client-Token, X-Client-Platform, X-Client-Version, X-Device-ID, X-Session-Token'); res.header('Vary', 'Origin'); if (req.method === 'OPTIONS') { return res.status(200).end(); } const simyoClientToken = process.env.SIMYO_CLIENT_TOKEN; if (!simyoClientToken) { return res.status(500).json({ error: 'Server Misconfigured', message: 'SIMYO_CLIENT_TOKEN 未配置' }); } // 代理请求:强制使用客户端身份头(浏览器 UA 不可靠,禁止透传) // X-Device-ID 必填,优先使用前端持久化 ID const axios = require('axios'); const config = { method: req.method.toLowerCase(), url: targetUrl, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Accept-Encoding': 'gzip', 'User-Agent': process.env.SIMYO_USER_AGENT || DEFAULT_SIMYO_USER_AGENT, 'X-Client-Token': simyoClientToken, 'X-Client-Platform': process.env.SIMYO_CLIENT_PLATFORM || DEFAULT_SIMYO_CLIENT_PLATFORM, 'X-Client-Version': process.env.SIMYO_CLIENT_VERSION || DEFAULT_SIMYO_CLIENT_VERSION, 'X-Device-ID': req.headers['x-device-id'] || process.env.SIMYO_DEVICE_ID || getDefaultSimyoDeviceId(), ...(req.headers['x-session-token'] ? { 'X-Session-Token': req.headers['x-session-token'] } : {}) }, timeout: 30000 }; if (req.body && Object.keys(req.body).length > 0) { config.data = req.body; } axios(config) .then(response => { res.status(response.status).json(response.data); }) .catch(error => { console.error('[Simyo Proxy Error]:', error.message); const status = error.response?.status || 500; const data = error.response?.data || { error: 'Proxy Error', message: error.message }; res.status(status).json(data); }); }); // 路由配置 const htmlRoutes = [ { url: '/giffgaff', file: 'src/giffgaff/giffgaff_modular.html' }, { url: '/simyo', file: 'src/simyo/simyo_modular.html' }, // 兼容静态路径访问(与 Netlify 重写保持一致) { url: '/src/giffgaff/giffgaff_modular.html', file: 'src/giffgaff/giffgaff_modular.html' }, { url: '/src/simyo/simyo_modular.html', file: 'src/simyo/simyo_modular.html' }, { url: '/', file: 'index.html' } ]; htmlRoutes.forEach(({ url, file }) => { app.get(url, (req, res) => { res.sendFile(path.join(STATIC_ROOT, file)); }); }); // 错误处理 app.use((err, req, res, next) => { console.error('Server Error:', err); const safeMessage = process.env.NODE_ENV === 'development' ? String(err.message || '').replace(/[<>"'&]/g, '') : '服务器内部错误'; res.status(500).json({ error: 'Internal Server Error', message: safeMessage }); }); // 404处理 app.use((req, res) => { // 优先返回 HTML 404 页面(如果存在) const html404Path = path.join(STATIC_ROOT, '404.html'); if (fs.existsSync(html404Path) && req.accepts('html')) { return res.status(404).sendFile(html404Path); } // API 请求或无 404 页面时返回 JSON res.status(404).json({ error: 'Not Found', message: '请求的资源不存在' }); }); // 启动服务器。被测试或其他模块 require 时不自动占用端口。 if (require.main === module) { app.listen(PORT, () => { Logger.log(`🚀 eSIM工具服务器已启动`); Logger.log(`📍 本地地址: http://localhost:${PORT}`); Logger.log(`🔧 Giffgaff工具: http://localhost:${PORT}/giffgaff`); Logger.log(`📱 Simyo工具: http://localhost:${PORT}/simyo`); Logger.log(`🌐 环境: ${process.env.NODE_ENV || 'development'}`); }); } module.exports = app;