mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
* ✨ feat(sentry): 添加用户反馈功能 - 升级 CDN bundle 以支持 feedback 模块 - 添加 feedbackIntegration 内置 Widget(主动反馈) - 实现错误后自动弹窗(Crash-Report Modal) - 添加冷却机制防止频繁弹窗(60秒冷却 + 同一错误不重复) * ♻️ refactor(sentry): 修复审查发现的问题 - 消除指纹计算重复(DRY 违反) - 统一使用 const/let 替代 var - 复用已有 showReportDialog 函数 - 添加 beforeSend UI 副作用设计说明注释 * 🐛 fix(sentry): 修复 SentryMock 缺失方法 - 添加 feedbackIntegration 和 replayIntegration 到 SentryMock - 修复 CDN 加载失败时 TypeError - 删除多余空行 * 🐛 fix(sentry): 修复代码质量问题 - sentry-loader.js 添加 replayIntegration 和 feedbackIntegration 配置 - sentry-loader.js 添加错误后自动弹窗逻辑(冷却机制 + try-catch) - sentry-init.js 添加 showReportDialog try-catch 保护 - 弹窗延迟从 100ms 改为 500ms(弱网兼容) * ✨ feat(sentry): 修复所有代码质量问题 - H1: CSP 添加 *.sentry.io 到 script-src(4 个文件) - M1: Feedback Widget 和自动弹窗添加中文文案 - M2: 同步两套初始化路径配置(tracing、脱敏、ignoreErrors) - M3: sentry-entry.js 添加 feedbackIntegration 导出 - M4: 新增专项测试(13 个测试用例) - L1: SentryMock 补齐 showReportDialog/lastEventId - L2: 空事件对象防御 * ✨ feat(sentry): 修复 PR 审查发现的全部问题 功能正确性: - query_string 添加类型检查(字符串/对象兼容) - 指纹计算移到脱敏前(避免不同错误被误判为重复) - 冷却状态更新移到 setTimeout 内部(避免空转) - subtitleLine2 改为 subtitle2(修正字段名) CSP 修复: - script-src 移除 *.sentry.io(减少攻击面) - frame-src 添加 *.sentry.io(支持弹窗 iframe) - 添加 worker-src/child-src blob:(支持 Session Replay) - server.js helmet CSP 同步更新 - netlify.toml 注释与实际策略对齐 配置/架构: - sentry-entry.js 添加 lastEventId 导出 - tracePropagationTargets 添加 CORS 风险注释 - CDN 架构添加说明注释 代码质量: - URL 参数脱敏支持大小写不敏感 - 移除过宽的 ignoreErrors 规则 - 测试文件添加 use strict - 添加 i18n 和漂移风险注释 * fix: apply CodeRabbit auto-fixes (#85) Fixed 3 file(s) based on 5 unresolved review comments. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <[email protected]> * ✨ feat(sentry): 修复新一轮审查意见 CSP 修复: - script-src 恢复 *.sentry.io(showReportDialog 需要) - netlify.toml Header CSP 同步 Cloudflare/Google - server.js helmet CSP 同步 冷却竞态修复: - 状态预留移到 setTimeout 之前(防止 500ms 内重复弹窗) - 弹窗失败时回滚冷却状态 脱敏修复: - query_string 对象分支改为大小写不敏感 - sanitizeQueryString 迭代改为安全模式(先收集键再遍历) --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <[email protected]>
288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
/**
|
||
* 本地开发服务器
|
||
* 提供静态文件服务和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://cdnjs.cloudflare.com", "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://cdn.jsdelivr.net", "https://browser.sentry-cdn.com", "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 qrcodeGenerate = require('./netlify/functions/qrcode-generate');
|
||
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],
|
||
['qrcode-generate', qrcodeGenerate],
|
||
['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;
|