Files
eSIM-Tools/server.js
Neo Vern 7298ae4a0d feat: QR 码本地打包 + Edge Function 直接生成 (#86)
*  feat: 打包 [email protected] 到本地 ES 模块

* ♻️ refactor(qrcode): 移除 CDN 加载逻辑,改用本地 import 引入 qrcode-generator

* 🔧 chore: 移除 CDN preconnect 提示,QR 码库已内联

* 🔧 chore: 清理 CSP 配置,移除不再需要的 CDN 域名

*  feat: 将 QR 码生成迁移到 Edge Function,消除后端冷启动延迟

- 在 Edge Function 中内联 qrcode-generator 库(~20KB),直接生成 QR 码
- 删除废弃的 Netlify Function (qrcode-generate.js)
- 更新 server.js 移除对已删除函数的引用
- 更新测试文件适配新的 Edge 内联架构

* ♻️ refactor: 代码质量修复 — 移除死代码、消除变量遮蔽、添加交叉引用注释

* 🔧 chore: 修复非阻塞风险 — 补充 strict 模式、移除废弃 qrcode 依赖

- Edge Function qrcode-lib.js 补充 'use strict' 声明,与浏览器版保持一致
- 移除已废弃的 qrcode npm 依赖(原用于已删除的 Netlify Function)

* ♻️ refactor: 消除魔法数字、补充脆耦合和 async 技术债注释

- Edge Function 中 QR margin 魔法数字 8 替换为 QR_MARGIN_MODULES 常量
- generateQRCodeLocal 补充 async 无 await 的技术债说明
- error.message.startsWith 条件补充校验函数耦合关系注释

* 🐛 fix: 修复边界条件 — null JSON body、vendor 字符串异常、CDN preconnect 残留

- Edge Function: null JSON body 解构移到 try/catch 内,添加 null/非对象检查
- qrcode-generator: 库 throw 字符串时统一转换为 Error 对象,避免 .startsWith 崩溃
- index.html: 移除不再需要的 CDN preconnect 提示(jsdelivr/cdnjs)

* ♻️ refactor: 修复 CSP 恢复、异常归一化、自定义 Error 类、负面路径测试

- netlify.toml: 恢复 script-src 中 cdn.jsdelivr.net(Bootstrap JS 仍依赖)
- Edge Function catch: 归一化非 Error 异常(库可能 throw 字符串)
- Edge Function QR margin: createDataURL(cellSize, 2) → createDataURL(cellSize, cellSize * 2) 对齐模块边距
- Edge Function: 补充认证模型和 QR 格式变更注释
- qrcode-generator.js: 引入 QRCodeValidationError 替代 error.message.startsWith 脆耦合
- tests/bff-proxy: 添加 6 个 Edge QR 负面路径测试(无效 JSON、空/超长/非字符串 data、超范围 size)
- tests/qrcode-generator: 添加库 throw 字符串异常的测试
- scripts/sync-qrcode-lib.js: 新增库代码同步验证脚本

* 🐛 fix: 修复 server.js CSP 缺少 jsdelivr、同步脚本 CRLF 归一化、fallback 拦截校验错误

- server.js: 恢复 script-src 中 cdn.jsdelivr.net(本地开发 Bootstrap JS 依赖)
- sync-qrcode-lib.js: 归一化 CRLF 换行符避免跨平台误报
- qrcode-generator.js: generateQRCodeWithFallback 入口处拦截 QRCodeValidationError,避免无效输入触发无意义的后端降级
2026-06-25 22:15:17 +08:00

286 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 本地开发服务器
* 提供静态文件服务和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);
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)';
// 启动时环境检查
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:"],
connectSrc: ["'self'", "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代理路由
app.use('/api/simyo/*', (req, res) => {
const [pathPart, queryPart] = req.originalUrl.replace(/^\/api\/simyo/, '').split('?');
const proxyPath = pathPart || '/';
const queryString = queryPart ? `?${queryPart}` : '';
const targetUrl = `https://appapi.simyo.nl/simyoapi/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, X-Client-Token, X-Client-Platform, X-Client-Version, 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 未配置'
});
}
// 代理请求
const axios = require('axios');
const config = {
method: req.method.toLowerCase(),
url: targetUrl,
headers: {
'Content-Type': 'application/json',
'User-Agent': req.headers['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,
...(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;