Files
eSIM-Tools/netlify/edge-functions/bff-proxy.js
Abner f5c5dee5ab 🐛 fix: 修复 Session 恢复后二维码和 LPA 不显示的问题
问题:
- 用户完成 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
2026-06-13 19:20:22 +08:00

155 lines
5.7 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.
/**
* 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']]
]);
// 注意:此文件运行在 DenoEdge 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
});
};