-
-
Notifications
You must be signed in to change notification settings - Fork 222
Expand file tree
/
Copy pathserver.js
More file actions
308 lines (275 loc) · 12.4 KB
/
Copy pathserver.js
File metadata and controls
308 lines (275 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
/**
* 本地开发服务器
* 提供静态文件服务和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;