mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-06 15:58:25 +08:00
问题: - 用户完成 eSIM 激活后,因外部二维码服务不可用导致页面空白 - Session 恢复时未调用 showESimResult() 显示二维码和 LPA 信息 解决方案: 1. 新增通用二维码生成模块 (src/js/modules/qrcode-generator.js) - 实现三层降级策略:本地 CDN → 后端 Function → 文本提示 - 消除对外部服务的依赖,提升隐私保护 2. 新增后端 Function (netlify/functions/qrcode-generate.js) - POST /bff/qrcode-generate 接口 - 返回 base64 编码的 PNG 二维码 - withAuth 中间件保护 + 输入验证 3. 重构前端二维码生成逻辑 - Giffgaff/Simyo 统一使用 generateQRCodeWithFallback() - 保留并发调用防护和 tooltip 交互 - 使用 i18n 翻译替代硬编码错误提示 4. 修复 Session 恢复逻辑 - 在 handleSessionRestore() 中调用 showESimResult() - 确保刷新页面后二维码和 LPA 正常显示 技术改进: - 懒加载 qrcode.js(~13KB gzip),仅在首次调用时加载 - 完整的测试覆盖(前端单元测试 + 后端安全测试) - 更新 BFF 路由配置(Edge Function + 本地开发服务器) Closes #75
155 lines
5.7 KiB
JavaScript
155 lines
5.7 KiB
JavaScript
/**
|
||
* Netlify Edge Function: BFF Proxy
|
||
* - 接收前端对 /bff/* 的请求
|
||
* - 在服务端附加 x-esim-key(来自环境变量 ACCESS_KEY)
|
||
* - 仅转发显式允许的 /.netlify/functions/* 目标
|
||
*/
|
||
|
||
const BFF_ROUTES = new Map([
|
||
['giffgaff-token-exchange', ['POST', 'OPTIONS']],
|
||
['giffgaff-graphql', ['POST', 'OPTIONS']],
|
||
['giffgaff-mfa-challenge', ['POST', 'OPTIONS']],
|
||
['giffgaff-mfa-validation', ['POST', 'OPTIONS']],
|
||
['giffgaff-sms-activate', ['POST', 'OPTIONS']],
|
||
['auto-activate-esim', ['POST', 'OPTIONS']],
|
||
['qrcode-generate', ['POST', 'OPTIONS']],
|
||
['verify-cookie', ['POST', 'OPTIONS']],
|
||
['public-config', ['GET', 'OPTIONS']]
|
||
]);
|
||
|
||
// 注意:此文件运行在 Deno(Edge Function),无法直接引用 Node.js 的 _shared/cors.js。
|
||
// 下方 parseAllowedOrigins 逻辑需与 netlify/functions/_shared/cors.js 的 parseOrigins 保持同步。
|
||
const DEFAULT_ALLOWED_ORIGIN = 'https://esim.cosr.eu.org';
|
||
|
||
function getEnv(name, fallback = '') {
|
||
return (typeof Deno !== 'undefined' && Deno.env && Deno.env.get(name)) || fallback;
|
||
}
|
||
|
||
function parseAllowedOrigins(raw) {
|
||
return String(raw || DEFAULT_ALLOWED_ORIGIN)
|
||
.split(',')
|
||
.map(origin => origin.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function jsonResponse(status, body, headers = {}) {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...headers
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildCorsHeaders(origin) {
|
||
return {
|
||
'Access-Control-Allow-Origin': origin,
|
||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-MFA-Signature, X-MFA-Ref, X-MFA-Challenge-Ref, X-CF-Turnstile',
|
||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||
'Vary': 'Origin'
|
||
};
|
||
}
|
||
|
||
export default async (request, context) => {
|
||
const url = new URL(request.url);
|
||
const ts = new Date().toISOString();
|
||
|
||
// 仅处理 /bff/* 路径
|
||
if (!url.pathname.startsWith('/bff/')) {
|
||
return new Response('Not Found', { status: 404 });
|
||
}
|
||
|
||
const targetName = url.pathname.replace(/^\/bff\//, '');
|
||
if (!targetName || !/^[a-z0-9-]+$/.test(targetName)) {
|
||
return jsonResponse(400, { error: 'Bad Request', message: 'Invalid BFF target' });
|
||
}
|
||
|
||
const allowedMethods = BFF_ROUTES.get(targetName);
|
||
if (!allowedMethods) {
|
||
console.warn(`[BFF] ${ts} | blocked unknown target=${targetName}`);
|
||
return jsonResponse(404, { error: 'Not Found', message: 'BFF target not allowed' });
|
||
}
|
||
|
||
if (!allowedMethods.includes(request.method)) {
|
||
return jsonResponse(405, { error: 'Method Not Allowed' });
|
||
}
|
||
|
||
const allowedOrigins = parseAllowedOrigins(getEnv('ALLOWED_ORIGIN'));
|
||
const requestOrigin = request.headers.get('origin') || '';
|
||
const sameOrigin = requestOrigin === url.origin;
|
||
const configuredOrigin = allowedOrigins.includes(requestOrigin);
|
||
const corsOrigin = sameOrigin || configuredOrigin ? requestOrigin : '';
|
||
const allowMissingOriginForPublicGet = targetName === 'public-config' && request.method === 'GET';
|
||
const fallbackCorsOrigin = allowedOrigins[0] || DEFAULT_ALLOWED_ORIGIN;
|
||
const errorCorsHeaders = buildCorsHeaders(corsOrigin || fallbackCorsOrigin);
|
||
|
||
if (!corsOrigin && !allowMissingOriginForPublicGet) {
|
||
console.warn(`[BFF] ${ts} | blocked origin=${requestOrigin || 'missing'} target=${targetName}`);
|
||
return jsonResponse(403, { error: 'Forbidden', message: 'Origin not allowed' }, errorCorsHeaders);
|
||
}
|
||
|
||
const corsHeaders = corsOrigin ? buildCorsHeaders(corsOrigin) : {};
|
||
if (request.method === 'OPTIONS') {
|
||
return new Response('', { status: 200, headers: corsHeaders });
|
||
}
|
||
|
||
console.log(`[BFF] ${ts} | ${request.method} ${url.pathname} → target=${targetName}`);
|
||
|
||
// 目标 Netlify Function URL(同域)
|
||
const functionUrl = new URL(`/.netlify/functions/${targetName}` , request.url);
|
||
|
||
// 从 Edge 运行时环境读取密钥
|
||
// Netlify Edge 使用 Deno 运行时
|
||
const accessKey = getEnv('ACCESS_KEY');
|
||
if (!accessKey) {
|
||
console.error(`[BFF] ${ts} | ACCESS_KEY missing in Edge env`);
|
||
return jsonResponse(500, { error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }, corsHeaders);
|
||
}
|
||
console.log(`[BFF] ${ts} | ACCESS_KEY present, proceeding`);
|
||
|
||
// 复制请求头并添加服务端密钥头;客户端提供的内部密钥一律不透传。
|
||
const headers = new Headers(request.headers);
|
||
headers.delete('x-app-key');
|
||
headers.set('x-esim-key', accessKey);
|
||
|
||
// 复制请求体
|
||
let body = undefined;
|
||
const isMutating = request.method !== 'GET' && request.method !== 'HEAD';
|
||
if (isMutating) {
|
||
try {
|
||
body = await request.arrayBuffer();
|
||
console.log(`[BFF] ${ts} | body read OK, size=${body.byteLength} bytes`);
|
||
} catch (bodyErr) {
|
||
console.error(`[BFF] ${ts} | body read FAILED: ${bodyErr.message}`);
|
||
return jsonResponse(400, { error: 'Bad Request', message: 'Failed to read request body' }, corsHeaders);
|
||
}
|
||
}
|
||
|
||
const proxiedRequest = new Request(functionUrl.toString(), {
|
||
method: request.method,
|
||
headers,
|
||
body
|
||
});
|
||
|
||
console.log(`[BFF] ${ts} | forwarding to ${functionUrl.pathname}`);
|
||
let response;
|
||
try {
|
||
response = await fetch(proxiedRequest);
|
||
} catch (fetchErr) {
|
||
console.error(`[BFF] ${ts} | fetch to Function FAILED: ${fetchErr.message}`);
|
||
return jsonResponse(502, { error: 'Bad Gateway', message: 'Failed to reach upstream function' }, corsHeaders);
|
||
}
|
||
|
||
// 响应日志(不包含敏感信息)
|
||
console.log(`[BFF] ${ts} | response from ${functionUrl.pathname}: status=${response.status} ok=${response.ok}`);
|
||
|
||
const responseHeaders = new Headers(response.headers);
|
||
Object.entries(corsHeaders).forEach(([key, value]) => responseHeaders.set(key, value));
|
||
return new Response(response.body, {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
headers: responseHeaders
|
||
});
|
||
};
|