Files
eSIM-Tools/server.js
Neo Vern bb80d3dc0a feat(sentry): 添加用户反馈功能 (#83)
*  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]>
2026-06-24 19:04:38 +08:00

288 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://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;