mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-02 22:14:02 +08:00
feat: 重构Netlify Functions架构并增强代码质量
- 新增统一的中间件模块,提供鉴权、CORS和错误处理功能 - 重构所有Functions使用withAuth中间件简化代码结构 - 添加安全存储模块替代localStorage,防御XSS攻击 - 引入HTML清理工具,自动转义特殊字符和验证URL安全性 - 创建代码质量检查脚本,验证语法、环境变量和依赖完整性 - 添加构建日志工具和重构脚本,统一替换console.log为Logger - 引入ESLint配置,提升代码质量和一致性 - 重构Giffgaff相关API,统一错误处理和验证逻辑 - 优化构建脚本,添加压缩和图片优化功能 - 新增健康检查端点,用于服务监控和状态报告
This commit is contained in:
32
.eslintrc.json
Normal file
32
.eslintrc.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }],
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"eqeqeq": ["error", "always"],
|
||||
"curly": ["error", "all"],
|
||||
"brace-style": ["error", "1tbs"],
|
||||
"indent": ["error", 2],
|
||||
"quotes": ["error", "single", { "avoidEscape": true }],
|
||||
"semi": ["error", "always"],
|
||||
"no-eval": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-new-func": "error",
|
||||
"no-script-url": "error",
|
||||
"no-unsafe-innerHTML": "off"
|
||||
},
|
||||
"globals": {
|
||||
"Deno": "readonly"
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,11 @@ LOG_LEVEL=info
|
||||
CORS_ORIGIN=*
|
||||
ALLOWED_ORIGIN=https://esim.cosr.eu.org
|
||||
|
||||
# 安全配置
|
||||
COOKIE_SECRET=your-secret-key-here
|
||||
# 受保护函数访问密钥(要求调用方在 Header x-esim-key 或 body.authKey / ?authKey 携带匹配值)
|
||||
ACCESS_KEY=please_change_me # 必填:Server 与 Functions/BFF 共享的访问密钥
|
||||
# ⚠️ 必填:Server 与 Functions/BFF 共享的访问密钥
|
||||
# 🔐 生成强随机密钥: openssl rand -hex 32
|
||||
# ❌ 禁止使用默认值或简单密码
|
||||
ACCESS_KEY=
|
||||
|
||||
# 可选:自定义API超时时间(毫秒)
|
||||
API_TIMEOUT=30000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Netlify Edge Function: BFF Proxy
|
||||
* - 接收前端对 /bff/* 的请求
|
||||
* - 在服务端附加 x-esim-key(来自环境变量 ACCESS_KEY 或 ESIM_ACCESS_KEY)
|
||||
* - 在服务端附加 x-esim-key(来自环境变量 ACCESS_KEY)
|
||||
* - 转发到对应的 /.netlify/functions/* 目标
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async (request, context) => {
|
||||
|
||||
// 从 Edge 运行时环境读取密钥
|
||||
// Netlify Edge 使用 Deno 运行时
|
||||
const accessKey = (typeof Deno !== 'undefined' && Deno.env && (Deno.env.get('ACCESS_KEY') || Deno.env.get('ESIM_ACCESS_KEY'))) || '';
|
||||
const accessKey = (typeof Deno !== 'undefined' && Deno.env && Deno.env.get('ACCESS_KEY')) || '';
|
||||
if (!accessKey) {
|
||||
return new Response(JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }), {
|
||||
status: 500,
|
||||
|
||||
308
netlify/functions/_shared/middleware.js
Normal file
308
netlify/functions/_shared/middleware.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Netlify Functions 统一中间件
|
||||
* 提供鉴权、CORS、错误处理等功能
|
||||
*/
|
||||
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY;
|
||||
|
||||
// 启动时检查密钥
|
||||
if (!ACCESS_KEY) {
|
||||
console.error('❌ 严重错误: ACCESS_KEY 未配置');
|
||||
console.error('💡 请在 Netlify 环境变量或 .env 文件中设置');
|
||||
}
|
||||
|
||||
if (ACCESS_KEY === 'please_change_me') {
|
||||
console.error('❌ 安全警告: ACCESS_KEY 使用了默认值,请立即修改');
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义错误类
|
||||
*/
|
||||
class AuthError extends Error {
|
||||
constructor(message, statusCode = 401) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取请求中提供的认证密钥
|
||||
* @param {Object} event - Netlify event 对象
|
||||
* @returns {string} 认证密钥
|
||||
*/
|
||||
function getProvidedKey(event) {
|
||||
const lower = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
|
||||
// 优先级: Header > Body > Query
|
||||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'];
|
||||
if (fromHeader) return fromHeader;
|
||||
|
||||
try {
|
||||
const body = JSON.parse(event.body || '{}');
|
||||
if (body && typeof body.authKey === 'string') {
|
||||
return body.authKey;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const query = event.queryStringParameters || {};
|
||||
if (query.authKey) return query.authKey;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求鉴权
|
||||
* @param {Object} event - Netlify event 对象
|
||||
* @returns {Object} 鉴权结果 { authorized: boolean, origin: string, preflight: boolean }
|
||||
* @throws {AuthError} 鉴权失败时抛出
|
||||
*/
|
||||
function authenticate(event) {
|
||||
const lower = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
const requestOrigin = lower.origin;
|
||||
|
||||
// CORS 预检请求
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
// 验证来源
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
throw new AuthError('Origin not allowed', 403);
|
||||
}
|
||||
return { preflight: true, origin: requestOrigin };
|
||||
}
|
||||
|
||||
// 非预检请求:验证来源
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
throw new AuthError('Origin not allowed', 403);
|
||||
}
|
||||
|
||||
// 验证 ACCESS_KEY 是否已配置
|
||||
if (!ACCESS_KEY) {
|
||||
throw new AuthError('Server Misconfigured: ACCESS_KEY not configured', 500);
|
||||
}
|
||||
|
||||
// 提取并验证密钥
|
||||
const providedKey = getProvidedKey(event);
|
||||
if (!providedKey || providedKey !== ACCESS_KEY) {
|
||||
throw new AuthError('Unauthorized: Missing or invalid auth key', 401);
|
||||
}
|
||||
|
||||
return { authorized: true, origin: requestOrigin };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标准响应头
|
||||
* @param {string} origin - 请求来源
|
||||
* @param {Object} additionalHeaders - 额外的响应头
|
||||
* @returns {Object} 响应头对象
|
||||
*/
|
||||
function createHeaders(origin = ALLOWED_ORIGIN, additionalHeaders = {}) {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-MFA-Signature, X-Esim-Key, X-App-Key',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS, GET',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
...additionalHeaders
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误处理
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {string} context - 错误上下文
|
||||
* @returns {Object} Netlify response 对象
|
||||
*/
|
||||
function handleError(error, context = 'unknown') {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const statusCode = error.statusCode || (error.response?.status) || 500;
|
||||
|
||||
// 结构化日志(生产环境应集成专业日志服务)
|
||||
const logData = {
|
||||
context,
|
||||
message: error.message,
|
||||
status: statusCode,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (isDev) {
|
||||
logData.stack = error.stack;
|
||||
logData.data = error.response?.data;
|
||||
}
|
||||
|
||||
console.error(`[${context}] Error:`, JSON.stringify(logData));
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
headers: createHeaders(),
|
||||
body: JSON.stringify({
|
||||
error: error.name || 'ServerError',
|
||||
message: error.message,
|
||||
...(isDev && { stack: error.stack, context })
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入验证中间件
|
||||
* @param {Object} schema - 验证规则对象
|
||||
* @param {Object} data - 待验证数据
|
||||
* @throws {AuthError} 验证失败时抛出
|
||||
*/
|
||||
function validateInput(schema, data) {
|
||||
const errors = [];
|
||||
|
||||
Object.entries(schema).forEach(([key, rules]) => {
|
||||
const value = data[key];
|
||||
|
||||
// required 检查
|
||||
if (rules.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push(`${key} is required`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果值为空且非必填,跳过后续验证
|
||||
if (!rules.required && (value === undefined || value === null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// type 检查
|
||||
if (rules.type && typeof value !== rules.type) {
|
||||
errors.push(`${key} must be of type ${rules.type}`);
|
||||
}
|
||||
|
||||
// minLength 检查
|
||||
if (rules.minLength && value.length < rules.minLength) {
|
||||
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||||
}
|
||||
|
||||
// maxLength 检查
|
||||
if (rules.maxLength && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must not exceed ${rules.maxLength} characters`);
|
||||
}
|
||||
|
||||
// pattern 检查
|
||||
if (rules.pattern && !rules.pattern.test(value)) {
|
||||
errors.push(`${key} has invalid format`);
|
||||
}
|
||||
|
||||
// enum 检查
|
||||
if (rules.enum && !rules.enum.includes(value)) {
|
||||
errors.push(`${key} must be one of: ${rules.enum.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
const error = new AuthError(`Validation failed: ${errors.join('; ')}`, 400);
|
||||
error.validationErrors = errors;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 Function Handler,自动处理鉴权和错误
|
||||
* @param {Function} handler - 业务逻辑处理函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @returns {Function} 包装后的 handler
|
||||
*/
|
||||
function withAuth(handler, options = {}) {
|
||||
return async (event, context) => {
|
||||
const functionName = context.functionName || 'unknown';
|
||||
|
||||
try {
|
||||
// 鉴权
|
||||
const auth = authenticate(event);
|
||||
|
||||
// 预检请求直接返回
|
||||
if (auth.preflight) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: createHeaders(auth.origin),
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
let parsedBody = {};
|
||||
if (event.body) {
|
||||
try {
|
||||
parsedBody = JSON.parse(event.body);
|
||||
// 如果是数组格式,取第一个元素(兼容某些客户端)
|
||||
if (Array.isArray(parsedBody)) {
|
||||
parsedBody = parsedBody[0] || {};
|
||||
}
|
||||
} catch (e) {
|
||||
throw new AuthError('Invalid JSON body', 400);
|
||||
}
|
||||
}
|
||||
|
||||
// 输入验证(如果提供了 schema)
|
||||
if (options.validateSchema) {
|
||||
validateInput(options.validateSchema, parsedBody);
|
||||
}
|
||||
|
||||
// 执行业务逻辑
|
||||
const result = await handler(event, context, { auth, body: parsedBody });
|
||||
|
||||
// 确保返回正确的响应格式
|
||||
if (!result.statusCode) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: createHeaders(auth.origin),
|
||||
body: JSON.stringify(result)
|
||||
};
|
||||
}
|
||||
|
||||
// 合并默认头
|
||||
result.headers = createHeaders(auth.origin, result.headers || {});
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
return handleError(error, functionName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带超时的 fetch 包装器
|
||||
* @param {string} url - 请求 URL
|
||||
* @param {Object} options - fetch 选项
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function fetchWithTimeout(url, options = {}, timeout = 30000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticate,
|
||||
createHeaders,
|
||||
handleError,
|
||||
validateInput,
|
||||
withAuth,
|
||||
fetchWithTimeout,
|
||||
AuthError
|
||||
};
|
||||
@@ -4,265 +4,159 @@
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const requestOrigin = lower['origin'];
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
// 设置CORS头
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// 处理预检请求
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Method Not Allowed',
|
||||
message: '只允许POST请求'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析请求体
|
||||
const requestBody = JSON.parse(event.body || '{}');
|
||||
const { activationCode, cookie, accessToken } = requestBody;
|
||||
|
||||
if (!activationCode) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'activationCode参数不能为空'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Auto Activation Request:', {
|
||||
activationCode: activationCode,
|
||||
hasCookie: !!cookie,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 调用Giffgaff激活API
|
||||
const result = await callGiffgaffActivationAPI(activationCode, cookie, accessToken);
|
||||
|
||||
if (result.success) {
|
||||
console.log('Auto Activation Success:', {
|
||||
message: result.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data
|
||||
})
|
||||
};
|
||||
} else {
|
||||
console.log('Auto Activation Failed:', {
|
||||
message: result.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Activation Failed',
|
||||
message: result.message
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auto Activation Error:', {
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: '服务器内部错误'
|
||||
})
|
||||
};
|
||||
}
|
||||
// 输入验证schema
|
||||
const autoActivateSchema = {
|
||||
activationCode: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 6,
|
||||
maxLength: 50
|
||||
}
|
||||
};
|
||||
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
// 输入验证
|
||||
validateInput(autoActivateSchema, body);
|
||||
|
||||
const { activationCode, cookie, accessToken } = body;
|
||||
|
||||
// 调用Giffgaff激活API
|
||||
const result = await callGiffgaffActivationAPI(activationCode, cookie, accessToken);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data
|
||||
})
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Activation Failed',
|
||||
message: result.message
|
||||
})
|
||||
};
|
||||
}
|
||||
}, { validateSchema: autoActivateSchema });
|
||||
|
||||
/**
|
||||
* 调用Giffgaff激活API - 完整流程
|
||||
*/
|
||||
async function callGiffgaffActivationAPI(activationCode, cookieString, bearerToken) {
|
||||
try {
|
||||
const timestamp = Date.now();
|
||||
const defaultHeaders = {
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'DNT': '1',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
|
||||
'Sec-Ch-Ua': '"Not;A=Brand";v="99", "Chromium";v="139", "Google Chrome";v="139"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"Windows"'
|
||||
};
|
||||
const cookieHeader = cookieString ? { Cookie: cookieString } : {};
|
||||
const bearerHeader = bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
const getHeaders = (extra = {}) => ({ ...defaultHeaders, ...cookieHeader, ...bearerHeader, ...extra });
|
||||
|
||||
// 第一步:验证激活码(Ajax校验)
|
||||
console.log('Step 1: Validating activation code...');
|
||||
const validateUrl = `https://www.giffgaff.com/activate/validate-sim-code?code=${activationCode}&next-action=products&_=${timestamp}`;
|
||||
|
||||
const validateResponse = await axios.get(validateUrl, {
|
||||
headers: getHeaders({
|
||||
Accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
Referer: 'https://www.giffgaff.com/activate',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
try {
|
||||
const timestamp = Date.now();
|
||||
const defaultHeaders = {
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'DNT': '1',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
|
||||
'Sec-Ch-Ua': '"Not;A=Brand";v="99", "Chromium";v="139", "Google Chrome";v="139"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"Windows"'
|
||||
};
|
||||
const cookieHeader = cookieString ? { Cookie: cookieString } : {};
|
||||
const bearerHeader = bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
const getHeaders = (extra = {}) => ({ ...defaultHeaders, ...cookieHeader, ...bearerHeader, ...extra });
|
||||
|
||||
console.log('Step 1 Response:', {
|
||||
status: validateResponse.status,
|
||||
data: validateResponse.data
|
||||
});
|
||||
// 第一步:验证激活码
|
||||
const validateUrl = `https://www.giffgaff.com/activate/validate-sim-code?code=${activationCode}&next-action=products&_=${timestamp}`;
|
||||
|
||||
if (validateResponse.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
message: `激活码验证失败,HTTP状态码: ${validateResponse.status}`
|
||||
};
|
||||
}
|
||||
const validateResponse = await axios.get(validateUrl, {
|
||||
headers: getHeaders({
|
||||
Accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
Referer: 'https://www.giffgaff.com/activate',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
// 第二步:访问 /activate/swap 预览页面(保持会话)
|
||||
console.log('Step 2: Loading swap preview page...');
|
||||
const swapPreview = await axios.get('https://www.giffgaff.com/activate/swap', {
|
||||
headers: getHeaders({
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
Referer: 'https://www.giffgaff.com/activate',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
// 第三步:GET swap-confirm 页面
|
||||
console.log('Step 3: Loading swap-confirm page...');
|
||||
const swapConfirmGet = await axios.get('https://www.giffgaff.com/activate/swap-confirm', {
|
||||
headers: getHeaders({
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
Referer: 'https://www.giffgaff.com/activate/swap',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
// 第四步:POST 确认(空体)
|
||||
console.log('Step 4: Confirming swap (POST)...');
|
||||
// 从 cookie 中提取 XSRF-TOKEN(若存在)
|
||||
let xsrf = null;
|
||||
if (cookieString) {
|
||||
const m = cookieString.match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (m) xsrf = decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
const confirmPost = await axios.post('https://www.giffgaff.com/activate/swap-confirm', null, {
|
||||
headers: getHeaders({
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
Origin: 'https://www.giffgaff.com',
|
||||
Referer: 'https://www.giffgaff.com/activate/swap-confirm',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(xsrf ? { 'X-XSRF-TOKEN': xsrf } : {}),
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
// 若执行到此,表示触发了确认流程。返回成功提示。
|
||||
return {
|
||||
success: true,
|
||||
message: '已提交SIM替换确认,请等待新eSIM生效(通常几分钟)',
|
||||
data: {
|
||||
previewStatus: swapPreview.status,
|
||||
confirmGetStatus: swapConfirmGet.status,
|
||||
confirmPostStatus: confirmPost.status
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Giffgaff Activation API Error:', error.message);
|
||||
|
||||
if (error.response) {
|
||||
return {
|
||||
success: false,
|
||||
message: `激活失败: ${error.response.status} - ${error.response.data?.message || '未知错误'}`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `激活请求失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
if (validateResponse.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
message: `激活码验证失败,HTTP状态码: ${validateResponse.status}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:访问 /activate/swap 预览页面
|
||||
await axios.get('https://www.giffgaff.com/activate/swap', {
|
||||
headers: getHeaders({
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
Referer: 'https://www.giffgaff.com/activate',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
// 第三步:GET swap-confirm 页面
|
||||
await axios.get('https://www.giffgaff.com/activate/swap-confirm', {
|
||||
headers: getHeaders({
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
Referer: 'https://www.giffgaff.com/activate/swap',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
// 第四步:POST 确认
|
||||
let xsrf = null;
|
||||
if (cookieString) {
|
||||
const m = cookieString.match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (m) xsrf = decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
const confirmPost = await axios.post('https://www.giffgaff.com/activate/swap-confirm', null, {
|
||||
headers: getHeaders({
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
Origin: 'https://www.giffgaff.com',
|
||||
Referer: 'https://www.giffgaff.com/activate/swap-confirm',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(xsrf ? { 'X-XSRF-TOKEN': xsrf } : {}),
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}),
|
||||
timeout: 30000,
|
||||
maxRedirects: 5
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已提交SIM替换确认,请等待新eSIM生效(通常几分钟)',
|
||||
data: {
|
||||
confirmPostStatus: confirmPost.status
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
return {
|
||||
success: false,
|
||||
message: `激活失败: ${error.response.status} - ${error.response.data?.message || '未知错误'}`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `激活请求失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,277 +1,145 @@
|
||||
/**
|
||||
* Netlify Function: Giffgaff GraphQL API
|
||||
* 处理GraphQL请求,解决CORS问题
|
||||
* 处理GraphQL请求,解决CORS问题
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS 允许域(默认仅允许生产域名)
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const requestOrigin = lower['origin'];
|
||||
// 输入验证schema
|
||||
const graphqlSchema = {
|
||||
query: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 10
|
||||
},
|
||||
accessToken: {
|
||||
required: false,
|
||||
type: 'string',
|
||||
minLength: 50
|
||||
}
|
||||
};
|
||||
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
// 解析请求体
|
||||
const { mfaSignature, mfaRef, query, variables, operationName, cookie } = body;
|
||||
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-MFA-Signature',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
// 从请求体或 Authorization 头提取 accessToken(兼容两种方式)
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const authHeader = lowerCaseHeaders['authorization'] || '';
|
||||
let accessToken = body.accessToken;
|
||||
if (!accessToken && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.slice(7);
|
||||
}
|
||||
|
||||
// 处理预检请求
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
// 仅允许指定来源的预检
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
}
|
||||
if (!accessToken) {
|
||||
throw new AuthError('accessToken是必需的', 400);
|
||||
}
|
||||
|
||||
// 非预检:限制来源(无 Origin 视为服务端调用,放行)
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
if (!query) {
|
||||
throw new AuthError('GraphQL query是必需的', 400);
|
||||
}
|
||||
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
}
|
||||
// 构建请求头(使用真实的 iOS App UA 和头部)
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': '*/*',
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Origin': 'https://publicapi.giffgaff.com',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br'
|
||||
};
|
||||
|
||||
// 只允许POST请求
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Method Not Allowed',
|
||||
message: '只允许POST请求'
|
||||
})
|
||||
};
|
||||
}
|
||||
// 针对 reserveESim / swapSim / eSimDownloadToken 需要设备元数据头
|
||||
const opName = operationName || '';
|
||||
const isReserve = /reserveESim\s*\(/.test(String(query || '')) || /reserveESim/i.test(opName);
|
||||
const isSwap = /swapSim\s*\(/i.test(String(query || '')) || /swapSim/i.test(opName);
|
||||
const isToken = /eSimDownloadToken\s*\(/i.test(String(query || '')) || /eSimDownloadToken/i.test(opName);
|
||||
const isMfaChallenge = /simSwapMfaChallenge/i.test(String(query || '')) || /simSwapMfaChallenge/i.test(opName);
|
||||
const needsAppHeaders = isReserve || isSwap || isToken || isMfaChallenge;
|
||||
|
||||
if (needsAppHeaders) {
|
||||
requestHeaders['x-gg-app-device-manufacturer'] = process.env.GG_APP_DEVICE_MANUFACTURER || 'Apple';
|
||||
requestHeaders['x-gg-app-os'] = process.env.GG_APP_OS || 'iOS';
|
||||
requestHeaders['x-gg-app-version'] = process.env.GG_APP_VERSION || '17.46.11';
|
||||
requestHeaders['x-gg-app-build-number'] = process.env.GG_APP_BUILD_NUMBER || '1332';
|
||||
requestHeaders['x-gg-app-os-version'] = process.env.GG_APP_OS_VERSION || '18.2';
|
||||
requestHeaders['apollographql-client-name'] = process.env.APOLLO_CLIENT_NAME || 'iOS 18.2';
|
||||
requestHeaders['apollographql-client-version'] = process.env.APOLLO_CLIENT_VERSION || '17.46.11 1332';
|
||||
requestHeaders['x-gg-app-bundle-version'] = process.env.GG_APP_BUNDLE_VERSION || 'v0';
|
||||
requestHeaders['x-gg-app-device-model'] = process.env.GG_APP_DEVICE_MODEL || 'iPhone SE';
|
||||
requestHeaders['x-gg-app-device-id'] = process.env.GG_APP_DEVICE_ID || 'iPhone12,8';
|
||||
requestHeaders['baggage'] = process.env.GG_BAGGAGE || 'client-tracking-ctx-id=d1c9ee72-573b-490e-a219-6b41992a5bdb';
|
||||
|
||||
try {
|
||||
// 解析请求体(支持对象和数组两种格式)
|
||||
let parsedBody = JSON.parse(event.body || '{}');
|
||||
|
||||
// 如果是数组格式,取第一个元素
|
||||
if (Array.isArray(parsedBody)) {
|
||||
if (parsedBody.length === 0) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'GraphQL请求数组不能为空'
|
||||
})
|
||||
};
|
||||
}
|
||||
parsedBody = parsedBody[0];
|
||||
}
|
||||
|
||||
const requestBody = parsedBody;
|
||||
const { mfaSignature, mfaRef, query, variables, operationName, cookie } = requestBody;
|
||||
const { randomUUID } = require('crypto');
|
||||
requestHeaders['x-request-id'] = randomUUID();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 从请求体或 Authorization 头提取 accessToken(兼容两种方式)
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const authHeader = lowerCaseHeaders['authorization'] || '';
|
||||
let accessToken = requestBody.accessToken;
|
||||
if (!accessToken && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.slice(7);
|
||||
}
|
||||
// 如果有MFA签名,添加到请求头
|
||||
if (mfaSignature) {
|
||||
requestHeaders['X-MFA-Signature'] = mfaSignature;
|
||||
requestHeaders['x-mfa-signature'] = mfaSignature;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'accessToken是必需的'
|
||||
})
|
||||
};
|
||||
}
|
||||
// 构建GraphQL请求体
|
||||
const graphqlBody = {
|
||||
query,
|
||||
variables: variables || {},
|
||||
operationName: operationName || null
|
||||
};
|
||||
|
||||
if (!query) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'GraphQL query是必需的'
|
||||
})
|
||||
};
|
||||
}
|
||||
// 供失败时刷新令牌使用的 verify-cookie 地址
|
||||
const hostHdr = lowerCaseHeaders['x-forwarded-host'] || lowerCaseHeaders['host'] || '';
|
||||
const protoHdr = lowerCaseHeaders['x-forwarded-proto'] || 'https';
|
||||
const verifyCookieUrl = hostHdr ? `${protoHdr}://${hostHdr}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
|
||||
|
||||
console.log('GraphQL Request:', {
|
||||
operationName: operationName || 'Unknown',
|
||||
hasVariables: !!variables,
|
||||
hasMfaSignature: !!mfaSignature,
|
||||
tokenLength: accessToken.length,
|
||||
timestamp: new Date().toISOString()
|
||||
// 调用Giffgaff GraphQL API
|
||||
let response;
|
||||
try {
|
||||
response = await axios.post(
|
||||
'https://publicapi.giffgaff.com/gateway/graphql',
|
||||
graphqlBody,
|
||||
{ headers: requestHeaders, timeout: 30000 }
|
||||
);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data || {};
|
||||
const isUnauthorized = status === 401 || data?.error === 'unauthorized' || /invalid_token/i.test(String(data?.error || ''));
|
||||
|
||||
// 失败 401 时尝试用 cookie 刷新后重试一次
|
||||
if (isUnauthorized && cookie) {
|
||||
try {
|
||||
const r = await axios.post(verifyCookieUrl, { cookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// 构建请求头(使用真实的 iOS App UA 和头部)
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': '*/*',
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Origin': 'https://publicapi.giffgaff.com',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br'
|
||||
};
|
||||
|
||||
// 针对 reserveESim / swapSim / eSimDownloadToken 需要设备元数据头(使用 HAR 中的真实值)
|
||||
const opName = operationName || '';
|
||||
const isReserve = /reserveESim\s*\(/.test(String(query || '')) || /reserveESim/i.test(opName);
|
||||
const isSwap = /swapSim\s*\(/i.test(String(query || '')) || /swapSim/i.test(opName);
|
||||
const isToken = /eSimDownloadToken\s*\(/i.test(String(query || '')) || /eSimDownloadToken/i.test(opName);
|
||||
const isMfaChallenge = /simSwapMfaChallenge/i.test(String(query || '')) || /simSwapMfaChallenge/i.test(opName);
|
||||
const needsAppHeaders = isReserve || isSwap || isToken || isMfaChallenge;
|
||||
|
||||
if (needsAppHeaders) {
|
||||
// 使用 HAR 抓包中的真实 iOS App 头部
|
||||
requestHeaders['x-gg-app-device-manufacturer'] = process.env.GG_APP_DEVICE_MANUFACTURER || 'Apple';
|
||||
requestHeaders['x-gg-app-os'] = process.env.GG_APP_OS || 'iOS';
|
||||
requestHeaders['x-gg-app-version'] = process.env.GG_APP_VERSION || '17.46.11';
|
||||
requestHeaders['x-gg-app-build-number'] = process.env.GG_APP_BUILD_NUMBER || '1332';
|
||||
requestHeaders['x-gg-app-os-version'] = process.env.GG_APP_OS_VERSION || '18.2';
|
||||
requestHeaders['apollographql-client-name'] = process.env.APOLLO_CLIENT_NAME || 'iOS 18.2';
|
||||
requestHeaders['apollographql-client-version'] = process.env.APOLLO_CLIENT_VERSION || '17.46.11 1332';
|
||||
requestHeaders['x-gg-app-bundle-version'] = process.env.GG_APP_BUNDLE_VERSION || 'v0';
|
||||
requestHeaders['x-gg-app-device-model'] = process.env.GG_APP_DEVICE_MODEL || 'iPhone SE';
|
||||
requestHeaders['x-gg-app-device-id'] = process.env.GG_APP_DEVICE_ID || 'iPhone12,8';
|
||||
requestHeaders['baggage'] = process.env.GG_BAGGAGE || 'client-tracking-ctx-id=d1c9ee72-573b-490e-a219-6b41992a5bdb';
|
||||
|
||||
try {
|
||||
const { randomUUID } = require('crypto');
|
||||
requestHeaders['x-request-id'] = randomUUID();
|
||||
} catch (_) {}
|
||||
if (r.data?.success && r.data?.accessToken) {
|
||||
accessToken = r.data.accessToken;
|
||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
response = await axios.post(
|
||||
'https://publicapi.giffgaff.com/gateway/graphql',
|
||||
graphqlBody,
|
||||
{ headers: requestHeaders, timeout: 30000 }
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 如果有MFA签名,添加到请求头(swapSim 只需要签名,不需要 ref)
|
||||
if (mfaSignature) {
|
||||
requestHeaders['X-MFA-Signature'] = mfaSignature;
|
||||
requestHeaders['x-mfa-signature'] = mfaSignature;
|
||||
}
|
||||
|
||||
// 构建GraphQL请求体
|
||||
const graphqlBody = {
|
||||
query,
|
||||
variables: variables || {},
|
||||
operationName: operationName || null
|
||||
};
|
||||
|
||||
// 供失败时刷新令牌使用的 verify-cookie 地址
|
||||
const hostHdr = lowerCaseHeaders['x-forwarded-host'] || lowerCaseHeaders['host'] || '';
|
||||
const protoHdr = lowerCaseHeaders['x-forwarded-proto'] || 'https';
|
||||
const verifyCookieUrl = hostHdr ? `${protoHdr}://${hostHdr}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
|
||||
|
||||
// 小范围调试日志:输出将发送的关键头(不含敏感 Authorization)
|
||||
try {
|
||||
const debugHeaders = {
|
||||
'X-MFA-Signature': requestHeaders['X-MFA-Signature'] || requestHeaders['x-mfa-signature'] || requestHeaders['X-GG-MFA-SIGNATURE'] || requestHeaders['x-gg-mfa-signature'] || null,
|
||||
'X-GG-MFA-REF': requestHeaders['X-GG-MFA-REF'] || requestHeaders['x-gg-mfa-ref'] || requestHeaders['X-MFA-REF'] || requestHeaders['x-mfa-ref'] || null,
|
||||
'x-gg-app-os': requestHeaders['x-gg-app-os'] || null,
|
||||
'x-gg-app-build-number': requestHeaders['x-gg-app-build-number'] || null
|
||||
};
|
||||
console.log('GraphQL Outgoing Headers (debug):', debugHeaders);
|
||||
} catch (_) {}
|
||||
|
||||
// 调用Giffgaff GraphQL API(失败 401 时尝试用 cookie 刷新后重试一次)
|
||||
let response;
|
||||
try {
|
||||
response = await axios.post(
|
||||
'https://publicapi.giffgaff.com/gateway/graphql',
|
||||
graphqlBody,
|
||||
{ headers: requestHeaders, timeout: 30000 }
|
||||
);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data || {};
|
||||
const isUnauthorized = status === 401 || data?.error === 'unauthorized' || /invalid_token/i.test(String(data?.error || ''));
|
||||
if (isUnauthorized && cookie) {
|
||||
try {
|
||||
const r = await axios.post(verifyCookieUrl, { cookie }, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 });
|
||||
if (r.data?.success && r.data?.accessToken) {
|
||||
accessToken = r.data.accessToken;
|
||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
response = await axios.post(
|
||||
'https://publicapi.giffgaff.com/gateway/graphql',
|
||||
graphqlBody,
|
||||
{ headers: requestHeaders, timeout: 30000 }
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (reErr) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'GraphQL Request Failed',
|
||||
message: 'Access token expired. Please re-login with cookie.',
|
||||
details: data,
|
||||
needReLogin: true
|
||||
})
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('GraphQL Success:', {
|
||||
status: response.status,
|
||||
hasData: !!response.data.data,
|
||||
hasErrors: !!response.data.errors,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('GraphQL Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const status = error.response?.status || 500;
|
||||
const errorMessage = error.response?.data?.message || error.message || '未知错误';
|
||||
|
||||
return {
|
||||
statusCode: status,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'GraphQL Request Failed',
|
||||
message: errorMessage,
|
||||
details: error.response?.data || null
|
||||
})
|
||||
};
|
||||
} catch (reErr) {
|
||||
throw new AuthError('Access token expired. Please re-login with cookie.', 401);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
}, { validateSchema: graphqlSchema });
|
||||
|
||||
@@ -1,344 +1,235 @@
|
||||
/**
|
||||
* Netlify Function: Giffgaff MFA Challenge
|
||||
* 处理MFA邮件验证码发送请求,解决CORS和403问题
|
||||
* 处理MFA邮件验证码发送请求,解决CORS和403问题
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS 允许域(默认仅允许生产域名)
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const requestOrigin = lowerCaseHeaders['origin'];
|
||||
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lowerCaseHeaders['x-esim-key'] || lowerCaseHeaders['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
|
||||
// 设置CORS头
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// 处理预检请求
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
}
|
||||
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Method Not Allowed',
|
||||
message: '只允许POST请求'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析请求体
|
||||
const requestBody = JSON.parse(event.body || '{}');
|
||||
// 通过 authKey 校验已经在入口进行,这里不需要额外动作
|
||||
const { source = "esim", preferredChannels = ["EMAIL"], cookie } = requestBody;
|
||||
|
||||
// 从请求体或 Authorization 头提取 accessToken(兼容两种方式)
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const authHeader = lowerCaseHeaders['authorization'] || '';
|
||||
let accessToken = requestBody.accessToken;
|
||||
if (!accessToken && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.slice(7);
|
||||
}
|
||||
|
||||
if (!accessToken && !cookie) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'accessToken 或 cookie 至少提供一个'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 站点URL用于内部调用 verify-cookie(避免硬编码域名)
|
||||
const lowerCaseHeadersForUrl = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const hostHeader = lowerCaseHeadersForUrl['x-forwarded-host'] || lowerCaseHeadersForUrl['host'] || '';
|
||||
const protoHeader = lowerCaseHeadersForUrl['x-forwarded-proto'] || 'https';
|
||||
const verifyCookieUrl = hostHeader ? `${protoHeader}://${hostHeader}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
|
||||
|
||||
// 如果提供cookie但没有accessToken,先尝试使用cookie获取accessToken
|
||||
let mergedCookie = cookie || '';
|
||||
if (cookie) {
|
||||
// 预热:访问 dashboard 刷新并合并 Set-Cookie,提高 id.giffgaff.com 接口成功率
|
||||
try {
|
||||
const session = axios.create({ maxRedirects: 5, timeout: 30000, validateStatus: () => true });
|
||||
const dashResp = await session.get('https://www.giffgaff.com/dashboard', {
|
||||
headers: {
|
||||
'Cookie': mergedCookie,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Referer': 'https://www.giffgaff.com/auth/login/challenge?redirect=%2Fdashboard',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'DNT': '1'
|
||||
}
|
||||
});
|
||||
const setCookies = ([]).concat(dashResp.headers['set-cookie'] || []);
|
||||
if (setCookies.length) {
|
||||
mergedCookie = mergeSetCookies(mergedCookie, setCookies);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Dashboard warm-up failed:', e.message);
|
||||
}
|
||||
// 若还没有 access token,则尝试一次 verify-cookie
|
||||
if (!accessToken) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie: mergedCookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
accessToken = cookieVerifyResponse.data.accessToken;
|
||||
console.log('Successfully obtained access token from merged cookie');
|
||||
}
|
||||
} catch (cookieError) {
|
||||
console.error('Failed to verify merged cookie:', cookieError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('MFA Challenge Request:', {
|
||||
source,
|
||||
preferredChannels,
|
||||
tokenLength: accessToken ? accessToken.length : 0,
|
||||
hasCookie: !!cookie,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 调用Giffgaff MFA API,失败且令牌过期时,尝试用cookie刷新一次
|
||||
// 获取 CSRF(可提升 /v4/mfa/challenge/me 的通过率)
|
||||
let csrfToken = null;
|
||||
if (mergedCookie) {
|
||||
try {
|
||||
const csrfResp = await axios.get('https://id.giffgaff.com/auth/csrf', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': mergedCookie,
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
csrfToken = csrfResp.data?.token || null;
|
||||
} catch (e) {
|
||||
console.warn('Fetch CSRF failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 mergedCookie 中提取 XSRF-TOKEN,作为 x-xsrf-token 头
|
||||
let xxsrf = null;
|
||||
if (mergedCookie) {
|
||||
const m = String(mergedCookie).match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (m) xxsrf = decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
// Web(Cookie)通道:auth/v3/mfa/challenge(method: 'text' | 'email')
|
||||
const sendChallengeV3Cookie = async () => {
|
||||
const method = Array.isArray(preferredChannels) && preferredChannels[0] === 'TEXT' ? 'text' : 'email';
|
||||
return axios.post(
|
||||
'https://id.giffgaff.com/auth/v3/mfa/challenge',
|
||||
{ method },
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Origin': 'https://id.giffgaff.com',
|
||||
'Referer': 'https://id.giffgaff.com/auth/login/challenge',
|
||||
'Device': 'web',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(xxsrf ? { 'x-xsrf-token': xxsrf } : {}),
|
||||
...(mergedCookie ? { 'Cookie': mergedCookie } : {})
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// App(Token)通道:v4 使用真实的 iOS App UA
|
||||
const sendChallengeV4Token = async (token) => axios.post(
|
||||
'https://id.giffgaff.com/v4/mfa/challenge/me',
|
||||
{ source, preferredChannels },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(mergedCookie ? { 'Cookie': mergedCookie } : {})
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
let response;
|
||||
try {
|
||||
// 优先走 Cookie 的 Web 通道(更契合 Cookie 登录)
|
||||
if (mergedCookie) {
|
||||
try {
|
||||
response = await sendChallengeV3Cookie();
|
||||
} catch (e) {
|
||||
// 回退到 v4 + token
|
||||
if (accessToken) response = await sendChallengeV4Token(accessToken);
|
||||
else throw e;
|
||||
}
|
||||
} else {
|
||||
response = await sendChallengeV4Token(accessToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data || {};
|
||||
const isExpired = status === 401 && (data.error === 'invalid_token' || /expired/i.test(String(data.error_description || '')));
|
||||
if (isExpired && mergedCookie) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie: mergedCookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
let refreshed = cookieVerifyResponse.data.accessToken;
|
||||
// 非JWT样式(无点号或长度过短)时,强制使用cookie通道
|
||||
const looksLikeJwt = typeof refreshed === 'string' && refreshed.includes('.') && refreshed.length > 200;
|
||||
if (!looksLikeJwt) {
|
||||
refreshed = null;
|
||||
}
|
||||
console.log('Refreshed access token via cookie, retrying MFA challenge');
|
||||
if (refreshed) response = await sendChallengeV4Token(refreshed);
|
||||
else response = await sendChallengeV3Cookie();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (reErr) {
|
||||
// 多次失败提示客户端需要刷新登录/重新获取Cookie
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'MFA Challenge Failed',
|
||||
message: 'Access token expired. Please re-login with cookie.',
|
||||
details: data,
|
||||
needReLogin: true
|
||||
})
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('MFA Challenge Success:', {
|
||||
status: response.status,
|
||||
hasRef: !!response.data.ref,
|
||||
channel: Array.isArray(preferredChannels) ? preferredChannels[0] : 'EMAIL',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('MFA Challenge Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const status = error.response?.status || 500;
|
||||
const errorMessage = error.response?.data?.message || error.message || '未知错误';
|
||||
|
||||
return {
|
||||
statusCode: status,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'MFA Challenge Failed',
|
||||
message: errorMessage,
|
||||
details: error.response?.data || null
|
||||
})
|
||||
};
|
||||
}
|
||||
// 输入验证schema
|
||||
const mfaChallengeSchema = {
|
||||
source: {
|
||||
required: false,
|
||||
type: 'string',
|
||||
enum: ['esim', 'web', 'app']
|
||||
},
|
||||
preferredChannels: {
|
||||
required: false,
|
||||
type: 'object'
|
||||
}
|
||||
};
|
||||
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
const { source = "esim", preferredChannels = ["EMAIL"], cookie } = body;
|
||||
|
||||
// 从请求体或 Authorization 头提取 accessToken(兼容两种方式)
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const authHeader = lowerCaseHeaders['authorization'] || '';
|
||||
let accessToken = body.accessToken;
|
||||
if (!accessToken && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.slice(7);
|
||||
}
|
||||
|
||||
if (!accessToken && !cookie) {
|
||||
throw new AuthError('accessToken 或 cookie 至少提供一个', 400);
|
||||
}
|
||||
|
||||
// 站点URL用于内部调用 verify-cookie
|
||||
const hostHeader = lowerCaseHeaders['x-forwarded-host'] || lowerCaseHeaders['host'] || '';
|
||||
const protoHeader = lowerCaseHeaders['x-forwarded-proto'] || 'https';
|
||||
const verifyCookieUrl = hostHeader ? `${protoHeader}://${hostHeader}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
|
||||
|
||||
// 如果提供cookie但没有accessToken,先尝试使用cookie获取accessToken
|
||||
let mergedCookie = cookie || '';
|
||||
if (cookie) {
|
||||
// 预热:访问 dashboard 刷新并合并 Set-Cookie
|
||||
try {
|
||||
const session = axios.create({ maxRedirects: 5, timeout: 30000, validateStatus: () => true });
|
||||
const dashResp = await session.get('https://www.giffgaff.com/dashboard', {
|
||||
headers: {
|
||||
'Cookie': mergedCookie,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Referer': 'https://www.giffgaff.com/auth/login/challenge?redirect=%2Fdashboard',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'DNT': '1'
|
||||
}
|
||||
});
|
||||
const setCookies = ([]).concat(dashResp.headers['set-cookie'] || []);
|
||||
if (setCookies.length) {
|
||||
mergedCookie = mergeSetCookies(mergedCookie, setCookies);
|
||||
}
|
||||
} catch (e) {
|
||||
// 预热失败不影响主流程
|
||||
}
|
||||
|
||||
// 若还没有 access token,则尝试一次 verify-cookie
|
||||
if (!accessToken) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie: mergedCookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
accessToken = cookieVerifyResponse.data.accessToken;
|
||||
}
|
||||
} catch (cookieError) {
|
||||
// Cookie验证失败不影响主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 CSRF(可提升 /v4/mfa/challenge/me 的通过率)
|
||||
let csrfToken = null;
|
||||
if (mergedCookie) {
|
||||
try {
|
||||
const csrfResp = await axios.get('https://id.giffgaff.com/auth/csrf', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': mergedCookie,
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
csrfToken = csrfResp.data?.token || null;
|
||||
} catch (e) {
|
||||
// CSRF获取失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 从 mergedCookie 中提取 XSRF-TOKEN
|
||||
let xxsrf = null;
|
||||
if (mergedCookie) {
|
||||
const m = String(mergedCookie).match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (m) xxsrf = decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
// Web(Cookie)通道:auth/v3/mfa/challenge
|
||||
const sendChallengeV3Cookie = async () => {
|
||||
const method = Array.isArray(preferredChannels) && preferredChannels[0] === 'TEXT' ? 'text' : 'email';
|
||||
return axios.post(
|
||||
'https://id.giffgaff.com/auth/v3/mfa/challenge',
|
||||
{ method },
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Origin': 'https://id.giffgaff.com',
|
||||
'Referer': 'https://id.giffgaff.com/auth/login/challenge',
|
||||
'Device': 'web',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(xxsrf ? { 'x-xsrf-token': xxsrf } : {}),
|
||||
...(mergedCookie ? { 'Cookie': mergedCookie } : {})
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// App(Token)通道:v4
|
||||
const sendChallengeV4Token = async (token) => axios.post(
|
||||
'https://id.giffgaff.com/v4/mfa/challenge/me',
|
||||
{ source, preferredChannels },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(mergedCookie ? { 'Cookie': mergedCookie } : {})
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
let response;
|
||||
try {
|
||||
// 优先走 Cookie 的 Web 通道
|
||||
if (mergedCookie) {
|
||||
try {
|
||||
response = await sendChallengeV3Cookie();
|
||||
} catch (e) {
|
||||
// 回退到 v4 + token
|
||||
if (accessToken) response = await sendChallengeV4Token(accessToken);
|
||||
else throw e;
|
||||
}
|
||||
} else {
|
||||
response = await sendChallengeV4Token(accessToken);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data || {};
|
||||
const isExpired = status === 401 && (data.error === 'invalid_token' || /expired/i.test(String(data.error_description || '')));
|
||||
|
||||
if (isExpired && mergedCookie) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie: mergedCookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
let refreshed = cookieVerifyResponse.data.accessToken;
|
||||
const looksLikeJwt = typeof refreshed === 'string' && refreshed.includes('.') && refreshed.length > 200;
|
||||
|
||||
if (!looksLikeJwt) {
|
||||
refreshed = null;
|
||||
}
|
||||
|
||||
if (refreshed) response = await sendChallengeV4Token(refreshed);
|
||||
else response = await sendChallengeV3Cookie();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (reErr) {
|
||||
throw new AuthError('Access token expired. Please re-login with cookie.', 401);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
}, { validateSchema: mfaChallengeSchema });
|
||||
|
||||
// 合并原始 Cookie 与 Set-Cookie 数组
|
||||
function mergeSetCookies(originalCookieHeader, setCookieArray) {
|
||||
const jar = new Map();
|
||||
// 先装入原始 cookie
|
||||
String(originalCookieHeader || '')
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(kv => {
|
||||
const eq = kv.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = kv.slice(0, eq).trim();
|
||||
const v = kv.slice(eq + 1).trim();
|
||||
if (k) jar.set(k, v);
|
||||
}
|
||||
});
|
||||
// 处理 set-cookie 覆盖
|
||||
for (const sc of setCookieArray) {
|
||||
const pair = String(sc).split(';')[0];
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
if (k) jar.set(k, v);
|
||||
}
|
||||
const jar = new Map();
|
||||
// 先装入原始 cookie
|
||||
String(originalCookieHeader || '')
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(kv => {
|
||||
const eq = kv.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = kv.slice(0, eq).trim();
|
||||
const v = kv.slice(eq + 1).trim();
|
||||
if (k) jar.set(k, v);
|
||||
}
|
||||
});
|
||||
// 处理 set-cookie 覆盖
|
||||
for (const sc of setCookieArray) {
|
||||
const pair = String(sc).split(';')[0];
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
const k = pair.slice(0, eq).trim();
|
||||
const v = pair.slice(eq + 1).trim();
|
||||
if (k) jar.set(k, v);
|
||||
}
|
||||
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
@@ -4,218 +4,116 @@
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const requestOrigin = lowerCaseHeaders['origin'];
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lowerCaseHeaders['x-esim-key'] || lowerCaseHeaders['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
|
||||
// 设置CORS头
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// 处理预检请求
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
}
|
||||
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Method Not Allowed',
|
||||
message: '只允许POST请求'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析请求体
|
||||
const requestBody = JSON.parse(event.body || '{}');
|
||||
const { ref, code, cookie } = requestBody;
|
||||
|
||||
// 从请求体或 Authorization 头提取 accessToken(兼容两种方式)
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const authHeader = lowerCaseHeaders['authorization'] || '';
|
||||
let accessToken = requestBody.accessToken;
|
||||
if (!accessToken && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.slice(7);
|
||||
}
|
||||
|
||||
if ((!accessToken && !cookie) || !ref || !code) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'accessToken 或 cookie 与 ref, code 都是必需的'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 站点URL用于内部调用 verify-cookie(避免硬编码域名)
|
||||
const lowerCaseHeadersForUrl = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const hostHeader = lowerCaseHeadersForUrl['x-forwarded-host'] || lowerCaseHeadersForUrl['host'] || '';
|
||||
const protoHeader = lowerCaseHeadersForUrl['x-forwarded-proto'] || 'https';
|
||||
const verifyCookieUrl = hostHeader ? `${protoHeader}://${hostHeader}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
|
||||
|
||||
// 如果提供cookie但没有accessToken,先尝试使用cookie获取accessToken
|
||||
if (cookie && !accessToken) {
|
||||
try {
|
||||
// 调用verify-cookie函数获取accessToken
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data && cookieVerifyResponse.data.success && cookieVerifyResponse.data.accessToken) {
|
||||
accessToken = cookieVerifyResponse.data.accessToken;
|
||||
console.log('Successfully obtained access token from cookie');
|
||||
}
|
||||
} catch (cookieError) {
|
||||
console.error('Failed to verify cookie:', cookieError.message);
|
||||
// 继续使用原始cookie
|
||||
}
|
||||
}
|
||||
|
||||
console.log('MFA Validation Request:', {
|
||||
ref,
|
||||
codeLength: code.length,
|
||||
tokenLength: accessToken ? accessToken.length : 0,
|
||||
hasCookie: !!cookie,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 调用Giffgaff MFA验证API,失败且令牌过期时,尝试用cookie刷新一次
|
||||
const sendValidation = async (token) => axios.post(
|
||||
'https://id.giffgaff.com/v4/mfa/validation',
|
||||
{ ref, code },
|
||||
{
|
||||
headers: (() => {
|
||||
const h = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br'
|
||||
};
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
if (!token && cookie) h['Cookie'] = cookie;
|
||||
return h;
|
||||
})(),
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await sendValidation(accessToken);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data || {};
|
||||
const isExpired = status === 401 && (data.error === 'invalid_token' || /expired/i.test(String(data.error_description || '')));
|
||||
if (isExpired && cookie) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
const refreshed = cookieVerifyResponse.data.accessToken;
|
||||
console.log('Refreshed access token via cookie, retrying MFA validation');
|
||||
response = await sendValidation(refreshed);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (reErr) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'MFA Validation Failed',
|
||||
message: 'Access token expired. Please re-login with cookie.',
|
||||
details: data,
|
||||
needReLogin: true
|
||||
})
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('MFA Validation Success:', {
|
||||
status: response.status,
|
||||
hasSignature: !!response.data.signature,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('MFA Validation Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const status = error.response?.status || 500;
|
||||
const errorMessage = error.response?.data?.message || error.message || '未知错误';
|
||||
|
||||
return {
|
||||
statusCode: status,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'MFA Validation Failed',
|
||||
message: errorMessage,
|
||||
details: error.response?.data || null
|
||||
})
|
||||
};
|
||||
}
|
||||
// 输入验证schema
|
||||
const mfaValidationSchema = {
|
||||
ref: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 10
|
||||
},
|
||||
code: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 4,
|
||||
maxLength: 10
|
||||
}
|
||||
};
|
||||
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
// 输入验证
|
||||
validateInput(mfaValidationSchema, body);
|
||||
|
||||
const { ref, code, cookie } = body;
|
||||
|
||||
// 从请求体或 Authorization 头提取 accessToken(兼容两种方式)
|
||||
const lowerCaseHeaders = Object.fromEntries(
|
||||
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
|
||||
);
|
||||
const authHeader = lowerCaseHeaders['authorization'] || '';
|
||||
let accessToken = body.accessToken;
|
||||
if (!accessToken && authHeader.startsWith('Bearer ')) {
|
||||
accessToken = authHeader.slice(7);
|
||||
}
|
||||
|
||||
if (!accessToken && !cookie) {
|
||||
throw new AuthError('accessToken 或 cookie 至少提供一个', 400);
|
||||
}
|
||||
|
||||
// 站点URL用于内部调用 verify-cookie
|
||||
const hostHeader = lowerCaseHeaders['x-forwarded-host'] || lowerCaseHeaders['host'] || '';
|
||||
const protoHeader = lowerCaseHeaders['x-forwarded-proto'] || 'https';
|
||||
const verifyCookieUrl = hostHeader ? `${protoHeader}://${hostHeader}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
|
||||
|
||||
// 如果提供cookie但没有accessToken,先尝试使用cookie获取accessToken
|
||||
if (cookie && !accessToken) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
accessToken = cookieVerifyResponse.data.accessToken;
|
||||
}
|
||||
} catch (cookieError) {
|
||||
// Cookie验证失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 调用Giffgaff MFA验证API
|
||||
const sendValidation = async (token) => axios.post(
|
||||
'https://id.giffgaff.com/v4/mfa/validation',
|
||||
{ ref, code },
|
||||
{
|
||||
headers: (() => {
|
||||
const h = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br'
|
||||
};
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
if (!token && cookie) h['Cookie'] = cookie;
|
||||
return h;
|
||||
})(),
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await sendValidation(accessToken);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const data = err.response?.data || {};
|
||||
const isExpired = status === 401 && (data.error === 'invalid_token' || /expired/i.test(String(data.error_description || '')));
|
||||
|
||||
if (isExpired && cookie) {
|
||||
try {
|
||||
const cookieVerifyResponse = await axios.post(verifyCookieUrl, { cookie }, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (cookieVerifyResponse.data?.success && cookieVerifyResponse.data?.accessToken) {
|
||||
const refreshed = cookieVerifyResponse.data.accessToken;
|
||||
response = await sendValidation(refreshed);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} catch (reErr) {
|
||||
throw new AuthError('Access token expired. Please re-login with cookie.', 401);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
}, { validateSchema: mfaValidationSchema });
|
||||
|
||||
@@ -1,289 +1,251 @@
|
||||
/**
|
||||
* Netlify Function: Giffgaff SMS Activate (end-to-end)
|
||||
* 输入短信验证码后,后台自动完成:MFA校验 → 预订eSIM(如需)→ 网页激活 → 轮询获取LPA
|
||||
* 输入短信验证码后,后台自动完成:MFA校验 → 预订eSIM(如需)→ 网页激活 → 轮询获取LPA
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const requestOrigin = lower['origin'];
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
}
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return { statusCode: 405, headers, body: JSON.stringify({ error: 'Method Not Allowed', message: '只允许POST请求' }) };
|
||||
}
|
||||
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
|
||||
// 鉴权参数校验
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
}
|
||||
|
||||
try {
|
||||
const req = JSON.parse(event.body || '{}');
|
||||
const {
|
||||
ref, // MFA ref(由 simSwapMfaChallenge 返回)
|
||||
code, // 用户输入的短信验证码
|
||||
accessToken, // OAuth Bearer Token(推荐提供)
|
||||
cookie, // giffgaff 登录 Cookie(用于网页激活流程与token刷新)
|
||||
memberId, // 可选:若前端已获取
|
||||
ssn, // 可选:已有预订的 eSIM SSN
|
||||
activationCode // 可选:已有预订的 eSIM Activation Code
|
||||
} = req;
|
||||
|
||||
if (!ref || !code) {
|
||||
return { statusCode: 400, headers, body: JSON.stringify({ error: 'Bad Request', message: 'ref 与 code 必须提供' }) };
|
||||
}
|
||||
|
||||
// 站点URL用于内部调用其他函数(避免硬编码域名)
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const host = lower['x-forwarded-host'] || lower['host'] || '';
|
||||
const proto = lower['x-forwarded-proto'] || 'https';
|
||||
const baseUrl = host ? `${proto}://${host}` : String(process.env.URL || '').replace(/\/$/, '');
|
||||
|
||||
// 统一创建 GraphQL 客户端
|
||||
const createGraphql = (token, extraHeaders = {}) => (body) => axios.post(
|
||||
'https://publicapi.giffgaff.com/gateway/graphql',
|
||||
{ ...body, mfaRef: ref },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : undefined,
|
||||
'Origin': 'https://www.giffgaff.com',
|
||||
'Referer': 'https://www.giffgaff.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
// 模拟App头,提升通过率
|
||||
'x-gg-app-os': process.env.GG_APP_OS || 'iOS',
|
||||
'x-gg-app-os-version': process.env.GG_APP_OS_VERSION || '18.2',
|
||||
'x-gg-app-build-number': process.env.GG_APP_BUILD_NUMBER || '1321',
|
||||
'x-gg-app-device-manufacturer': process.env.GG_APP_DEVICE_MANUFACTURER || 'Apple',
|
||||
'x-gg-app-device-model': process.env.GG_APP_DEVICE_MODEL || 'iPhone SE',
|
||||
...extraHeaders
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
// 1) 获取 CSRF Token(用于 /v4/mfa/validation)
|
||||
let csrfToken = null;
|
||||
if (cookie) {
|
||||
try {
|
||||
const csrfResp = await axios.get('https://id.giffgaff.com/auth/csrf', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'giffgaff/1321 CFNetwork/1568.300.101 Darwin/24.2.0'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
csrfToken = csrfResp.data?.token || null;
|
||||
} catch (e) {
|
||||
// 不中断流程;若缺失仍尝试验证
|
||||
console.warn('Fetch CSRF failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 校验短信验证码,获取签名
|
||||
// 优先尝试 token 通道;失败401且有 cookie 时,回退到 Web 通道
|
||||
const buildV4Headers = (token, ck) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'giffgaff/1321 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Origin': 'https://www.giffgaff.com',
|
||||
'Referer': 'https://www.giffgaff.com/',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(ck ? { 'Cookie': ck } : {})
|
||||
});
|
||||
|
||||
// 从 cookie 中提取 XSRF-TOKEN
|
||||
let xxsrf = null;
|
||||
if (cookie) {
|
||||
const m = String(cookie).match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (m) xxsrf = decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
const buildV3Headers = (ck) => ({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Origin': 'https://id.giffgaff.com',
|
||||
'Referer': 'https://id.giffgaff.com/auth/login/challenge',
|
||||
'Device': 'web',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(xxsrf ? { 'x-xsrf-token': xxsrf } : {}),
|
||||
...(ck ? { 'Cookie': ck } : {})
|
||||
});
|
||||
|
||||
let validationResp;
|
||||
try {
|
||||
validationResp = await axios.post(
|
||||
'https://id.giffgaff.com/v4/mfa/validation',
|
||||
{ ref, code },
|
||||
{ headers: buildV4Headers(accessToken, cookie), timeout: 30000 }
|
||||
);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const detail = err.response?.data;
|
||||
const tokenExpired = status === 401;
|
||||
if (tokenExpired && cookie) {
|
||||
try {
|
||||
// 回退到 Web v3 验证,不带 Authorization
|
||||
validationResp = await axios.post(
|
||||
'https://id.giffgaff.com/auth/v3/mfa/validation',
|
||||
{ ref, code },
|
||||
{ headers: buildV3Headers(cookie), timeout: 30000 }
|
||||
);
|
||||
} catch (err2) {
|
||||
const s2 = err2.response?.status || 500;
|
||||
return { statusCode: s2, headers, body: JSON.stringify({ error: 'MFA Validation Failed', details: err2.response?.data || detail }) };
|
||||
}
|
||||
} else {
|
||||
const s = status || 500;
|
||||
return { statusCode: s, headers, body: JSON.stringify({ error: 'MFA Validation Failed', details: detail || null }) };
|
||||
}
|
||||
}
|
||||
|
||||
const mfaSignature = validationResp.data?.signature;
|
||||
if (!mfaSignature) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'SignatureMissing', message: '未获取到MFA签名' }) };
|
||||
}
|
||||
|
||||
// 3) 若无 ssn/activationCode,则:获取 memberId → 预订 eSIM(SWITCH)
|
||||
let currentMemberId = memberId || null;
|
||||
let currentSSN = ssn || null;
|
||||
let currentActivationCode = activationCode || null;
|
||||
|
||||
const gql = createGraphql(accessToken);
|
||||
|
||||
if (!currentMemberId) {
|
||||
const q = {
|
||||
query: `query getMemberProfileAndSim { memberProfile { id } sim { status } }`,
|
||||
variables: {},
|
||||
operationName: 'getMemberProfileAndSim'
|
||||
};
|
||||
const r = await gql(q);
|
||||
currentMemberId = r.data?.data?.memberProfile?.id || null;
|
||||
if (!currentMemberId) {
|
||||
return { statusCode: 400, headers, body: JSON.stringify({ error: 'MemberIdMissing', message: '无法获取会员ID' }) };
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentSSN || !currentActivationCode) {
|
||||
const reserveBody = {
|
||||
query: `mutation reserveESim($input: ESimReservationInput!) { reserveESim: reserveESim(input: $input) { id memberId status esim { ssn activationCode deliveryStatus __typename } __typename } }`,
|
||||
variables: { input: { memberId: currentMemberId, userIntent: 'SWITCH' } },
|
||||
operationName: 'reserveESim'
|
||||
};
|
||||
|
||||
const r = await createGraphql(accessToken, { 'X-MFA-Signature': mfaSignature })(reserveBody);
|
||||
const reservation = r.data?.data?.reserveESim;
|
||||
if (!reservation?.esim) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'ReserveFailed', details: r.data }) };
|
||||
}
|
||||
currentSSN = reservation.esim.ssn;
|
||||
currentActivationCode = reservation.esim.activationCode;
|
||||
}
|
||||
|
||||
// 4) 执行 swapSim,将预订的 eSIM 正式替换为新卡(App 流程关键步骤)
|
||||
let swapRef = null;
|
||||
try {
|
||||
// 先发起 GraphQL 的 simSwapMfaChallenge,获取用于 swap 的专属 ref
|
||||
try {
|
||||
const chBody = { query: `mutation simSwapMfaChallenge { simSwapMfaChallenge { ref methods { value channel __typename } __typename } }`, variables: {}, operationName: 'simSwapMfaChallenge' };
|
||||
const ch = await createGraphql(accessToken)({ ...chBody });
|
||||
swapRef = ch.data?.data?.simSwapMfaChallenge?.ref || null;
|
||||
} catch (_) {}
|
||||
|
||||
const swapBody = {
|
||||
query: `mutation SwapSim($activationCode: String!, $mfaSignature: String!) { swapSim(activationCode: $activationCode, mfaSignature: $mfaSignature) { old { ssn activationCode __typename } new { ssn activationCode __typename } __typename } }`,
|
||||
variables: { activationCode: currentActivationCode, mfaSignature },
|
||||
operationName: 'SwapSim'
|
||||
};
|
||||
const rs = await createGraphql(accessToken)({ ...swapBody, mfaRef: swapRef || ref });
|
||||
const sw = rs.data?.data?.swapSim;
|
||||
if (sw?.new?.ssn) {
|
||||
currentSSN = sw.new.ssn;
|
||||
currentActivationCode = sw.new.activationCode || currentActivationCode;
|
||||
}
|
||||
} catch (e) {
|
||||
// 允许继续轮询(有些场景 swapSim 会由后端异步完成)
|
||||
}
|
||||
|
||||
// 5) 直接走 GraphQL 下载令牌(多数 App 流程不依赖网页 /activate)
|
||||
// 这里不再调用网页 auto-activate-esim,避免 403 和与 App 流不一致
|
||||
// 轮询获取 LPA(最长 ~120秒)
|
||||
const downloadQuery = {
|
||||
query: `query eSimDownloadToken($ssn: String!) { eSimDownloadToken(ssn: $ssn) { id host matchingId lpaString __typename } }`,
|
||||
variables: { ssn: currentSSN },
|
||||
operationName: 'eSimDownloadToken'
|
||||
};
|
||||
|
||||
const deadline = Date.now() + 120000;
|
||||
let lastData = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await gql(downloadQuery);
|
||||
lastData = r.data?.data?.eSimDownloadToken || null;
|
||||
if (lastData?.lpaString) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
lpaString: lastData.lpaString,
|
||||
token: lastData,
|
||||
ssn: currentSSN,
|
||||
activationCode: currentActivationCode
|
||||
})
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略短暂错误,继续轮询
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
}
|
||||
|
||||
return { statusCode: 202, headers, body: JSON.stringify({ success: false, message: '激活已提交,但暂未获取到LPA,请稍后在“获取eSIM Token”重试。' }) };
|
||||
|
||||
} catch (error) {
|
||||
console.error('SMS Activate Error:', { message: error.message });
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Internal Server Error', message: error.message }) };
|
||||
// 输入验证schema
|
||||
const smsActivateSchema = {
|
||||
ref: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 10
|
||||
},
|
||||
code: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 4,
|
||||
maxLength: 10
|
||||
}
|
||||
};
|
||||
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
// 输入验证
|
||||
validateInput(smsActivateSchema, body);
|
||||
|
||||
const {
|
||||
ref,
|
||||
code,
|
||||
accessToken,
|
||||
cookie,
|
||||
memberId,
|
||||
ssn,
|
||||
activationCode
|
||||
} = body;
|
||||
|
||||
// 站点URL用于内部调用其他函数
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const host = lower['x-forwarded-host'] || lower['host'] || '';
|
||||
const proto = lower['x-forwarded-proto'] || 'https';
|
||||
const baseUrl = host ? `${proto}://${host}` : String(process.env.URL || '').replace(/\/$/, '');
|
||||
|
||||
// 统一创建 GraphQL 客户端
|
||||
const createGraphql = (token, extraHeaders = {}) => (body) => axios.post(
|
||||
'https://publicapi.giffgaff.com/gateway/graphql',
|
||||
{ ...body, mfaRef: ref },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : undefined,
|
||||
'Origin': 'https://www.giffgaff.com',
|
||||
'Referer': 'https://www.giffgaff.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'x-gg-app-os': process.env.GG_APP_OS || 'iOS',
|
||||
'x-gg-app-os-version': process.env.GG_APP_OS_VERSION || '18.2',
|
||||
'x-gg-app-build-number': process.env.GG_APP_BUILD_NUMBER || '1321',
|
||||
'x-gg-app-device-manufacturer': process.env.GG_APP_DEVICE_MANUFACTURER || 'Apple',
|
||||
'x-gg-app-device-model': process.env.GG_APP_DEVICE_MODEL || 'iPhone SE',
|
||||
...extraHeaders
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
// 1) 获取 CSRF Token
|
||||
let csrfToken = null;
|
||||
if (cookie) {
|
||||
try {
|
||||
const csrfResp = await axios.get('https://id.giffgaff.com/auth/csrf', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'giffgaff/1321 CFNetwork/1568.300.101 Darwin/24.2.0'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
csrfToken = csrfResp.data?.token || null;
|
||||
} catch (e) {
|
||||
// CSRF获取失败不中断流程
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 校验短信验证码,获取签名
|
||||
const buildV4Headers = (token, ck) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'giffgaff/1321 CFNetwork/1568.300.101 Darwin/24.2.0',
|
||||
'Origin': 'https://www.giffgaff.com',
|
||||
'Referer': 'https://www.giffgaff.com/',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(ck ? { 'Cookie': ck } : {})
|
||||
});
|
||||
|
||||
// 从 cookie 中提取 XSRF-TOKEN
|
||||
let xxsrf = null;
|
||||
if (cookie) {
|
||||
const m = String(cookie).match(/XSRF-TOKEN=([^;]+)/);
|
||||
if (m) xxsrf = decodeURIComponent(m[1]);
|
||||
}
|
||||
|
||||
const buildV3Headers = (ck) => ({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
|
||||
'Origin': 'https://id.giffgaff.com',
|
||||
'Referer': 'https://id.giffgaff.com/auth/login/challenge',
|
||||
'Device': 'web',
|
||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||
...(xxsrf ? { 'x-xsrf-token': xxsrf } : {}),
|
||||
...(ck ? { 'Cookie': ck } : {})
|
||||
});
|
||||
|
||||
let validationResp;
|
||||
try {
|
||||
validationResp = await axios.post(
|
||||
'https://id.giffgaff.com/v4/mfa/validation',
|
||||
{ ref, code },
|
||||
{ headers: buildV4Headers(accessToken, cookie), timeout: 30000 }
|
||||
);
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
const detail = err.response?.data;
|
||||
const tokenExpired = status === 401;
|
||||
|
||||
if (tokenExpired && cookie) {
|
||||
try {
|
||||
// 回退到 Web v3 验证
|
||||
validationResp = await axios.post(
|
||||
'https://id.giffgaff.com/auth/v3/mfa/validation',
|
||||
{ ref, code },
|
||||
{ headers: buildV3Headers(cookie), timeout: 30000 }
|
||||
);
|
||||
} catch (err2) {
|
||||
const s2 = err2.response?.status || 500;
|
||||
throw new AuthError('MFA Validation Failed', s2);
|
||||
}
|
||||
} else {
|
||||
const s = status || 500;
|
||||
throw new AuthError('MFA Validation Failed', s);
|
||||
}
|
||||
}
|
||||
|
||||
const mfaSignature = validationResp.data?.signature;
|
||||
if (!mfaSignature) {
|
||||
throw new AuthError('未获取到MFA签名', 500);
|
||||
}
|
||||
|
||||
// 3) 若无 ssn/activationCode,则获取 memberId → 预订 eSIM
|
||||
let currentMemberId = memberId || null;
|
||||
let currentSSN = ssn || null;
|
||||
let currentActivationCode = activationCode || null;
|
||||
|
||||
const gql = createGraphql(accessToken);
|
||||
|
||||
if (!currentMemberId) {
|
||||
const q = {
|
||||
query: `query getMemberProfileAndSim { memberProfile { id } sim { status } }`,
|
||||
variables: {},
|
||||
operationName: 'getMemberProfileAndSim'
|
||||
};
|
||||
const r = await gql(q);
|
||||
currentMemberId = r.data?.data?.memberProfile?.id || null;
|
||||
if (!currentMemberId) {
|
||||
throw new AuthError('无法获取会员ID', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentSSN || !currentActivationCode) {
|
||||
const reserveBody = {
|
||||
query: `mutation reserveESim($input: ESimReservationInput!) { reserveESim: reserveESim(input: $input) { id memberId status esim { ssn activationCode deliveryStatus __typename } __typename } }`,
|
||||
variables: { input: { memberId: currentMemberId, userIntent: 'SWITCH' } },
|
||||
operationName: 'reserveESim'
|
||||
};
|
||||
|
||||
const r = await createGraphql(accessToken, { 'X-MFA-Signature': mfaSignature })(reserveBody);
|
||||
const reservation = r.data?.data?.reserveESim;
|
||||
if (!reservation?.esim) {
|
||||
throw new AuthError('eSIM预订失败', 500);
|
||||
}
|
||||
currentSSN = reservation.esim.ssn;
|
||||
currentActivationCode = reservation.esim.activationCode;
|
||||
}
|
||||
|
||||
// 4) 执行 swapSim
|
||||
let swapRef = null;
|
||||
try {
|
||||
// 先发起 simSwapMfaChallenge
|
||||
try {
|
||||
const chBody = { query: `mutation simSwapMfaChallenge { simSwapMfaChallenge { ref methods { value channel __typename } __typename } }`, variables: {}, operationName: 'simSwapMfaChallenge' };
|
||||
const ch = await createGraphql(accessToken)({ ...chBody });
|
||||
swapRef = ch.data?.data?.simSwapMfaChallenge?.ref || null;
|
||||
} catch (_) {}
|
||||
|
||||
const swapBody = {
|
||||
query: `mutation SwapSim($activationCode: String!, $mfaSignature: String!) { swapSim(activationCode: $activationCode, mfaSignature: $mfaSignature) { old { ssn activationCode __typename } new { ssn activationCode __typename } __typename } }`,
|
||||
variables: { activationCode: currentActivationCode, mfaSignature },
|
||||
operationName: 'SwapSim'
|
||||
};
|
||||
const rs = await createGraphql(accessToken)({ ...swapBody, mfaRef: swapRef || ref });
|
||||
const sw = rs.data?.data?.swapSim;
|
||||
if (sw?.new?.ssn) {
|
||||
currentSSN = sw.new.ssn;
|
||||
currentActivationCode = sw.new.activationCode || currentActivationCode;
|
||||
}
|
||||
} catch (e) {
|
||||
// 允许继续轮询
|
||||
}
|
||||
|
||||
// 5) 轮询获取 LPA
|
||||
const downloadQuery = {
|
||||
query: `query eSimDownloadToken($ssn: String!) { eSimDownloadToken(ssn: $ssn) { id host matchingId lpaString __typename } }`,
|
||||
variables: { ssn: currentSSN },
|
||||
operationName: 'eSimDownloadToken'
|
||||
};
|
||||
|
||||
const deadline = Date.now() + 120000;
|
||||
let lastData = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await gql(downloadQuery);
|
||||
lastData = r.data?.data?.eSimDownloadToken || null;
|
||||
if (lastData?.lpaString) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
lpaString: lastData.lpaString,
|
||||
token: lastData,
|
||||
ssn: currentSSN,
|
||||
activationCode: currentActivationCode
|
||||
})
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略短暂错误,继续轮询
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 202,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: '激活已提交,但暂未获取到LPA,请稍后在"获取eSIM Token"重试。'
|
||||
})
|
||||
};
|
||||
}, { validateSchema: smsActivateSchema });
|
||||
|
||||
@@ -5,177 +5,91 @@
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
exports.handler = async (event) => {
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const requestOrigin = lower['origin'];
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
// 验证环境变量配置(严格验证,不自动修复)
|
||||
function validateClientCredentials() {
|
||||
const clientId = process.env.GIFFGAFF_CLIENT_ID;
|
||||
const clientSecret = process.env.GIFFGAFF_CLIENT_SECRET;
|
||||
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
if (!clientId) {
|
||||
throw new AuthError('GIFFGAFF_CLIENT_ID 未配置', 500);
|
||||
}
|
||||
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed', message: '只允许POST请求' })
|
||||
};
|
||||
if (!clientSecret) {
|
||||
throw new AuthError('GIFFGAFF_CLIENT_SECRET 未配置', 500);
|
||||
}
|
||||
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
// 严格验证 clientSecret 格式(Base64)
|
||||
if (!/^[A-Za-z0-9+/]+=*$/.test(clientSecret)) {
|
||||
throw new AuthError('GIFFGAFF_CLIENT_SECRET 格式无效:必须为 Base64 编码', 500);
|
||||
}
|
||||
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
if (clientSecret.length < 32) {
|
||||
throw new AuthError('GIFFGAFF_CLIENT_SECRET 长度过短:必须至少32字符', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = JSON.parse(event.body || '{}');
|
||||
const { code, code_verifier: codeVerifier, redirect_uri: redirectUri } = body;
|
||||
|
||||
if (!code || !codeVerifier) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Bad Request', message: 'code 与 code_verifier 均为必需参数' })
|
||||
};
|
||||
}
|
||||
|
||||
const clientId = process.env.GIFFGAFF_CLIENT_ID;
|
||||
const clientSecret = process.env.GIFFGAFF_CLIENT_SECRET;
|
||||
// 根据Postman配置文件中的设置使用正确的令牌端点URL
|
||||
const tokenUrl = process.env.GIFFGAFF_TOKEN_URL || 'https://id.giffgaff.com/auth/oauth/token';
|
||||
const defaultRedirectUri = process.env.GIFFGAFF_REDIRECT_URI || 'giffgaff://auth/callback/';
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Server Misconfiguration',
|
||||
message: '缺少 GIFFGAFF_CLIENT_ID 或 GIFFGAFF_CLIENT_SECRET 环境变量'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// 使用正确的客户端密钥格式,确保包含等号
|
||||
let cleanedSecret = clientSecret;
|
||||
|
||||
// 确保客户端密钥包含等号,这在base64密钥中很重要
|
||||
if (!cleanedSecret.endsWith('=')) {
|
||||
// 如果不是以等号结尾,先检查是否以百分号结尾并去除
|
||||
if (cleanedSecret.endsWith('%')) {
|
||||
cleanedSecret = cleanedSecret.slice(0, -1);
|
||||
console.log('检测到客户端密钥末尾有百分号,已去除');
|
||||
}
|
||||
|
||||
// 如果客户端密钥是标准的base64,但缺少等号,添加等号
|
||||
if (!/=$/.test(cleanedSecret)) {
|
||||
cleanedSecret = cleanedSecret + '=';
|
||||
console.log('客户端密钥可能缺少等号,已添加');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`使用客户端ID: ${clientId.substring(0, 5)}*****`);
|
||||
console.log(`客户端密钥长度: ${cleanedSecret.length}`);
|
||||
const authHeader = Buffer.from(`${clientId}:${cleanedSecret}`).toString('base64');
|
||||
|
||||
const form = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri || defaultRedirectUri,
|
||||
code_verifier: codeVerifier
|
||||
});
|
||||
|
||||
// 添加调试日志
|
||||
console.log(`请求令牌端点: ${tokenUrl}`);
|
||||
console.log(`请求参数: ${form.toString()}`);
|
||||
// 不打印敏感信息
|
||||
console.log(`请求头部: Authorization: Basic ******, Content-Type: application/x-www-form-urlencoded`);
|
||||
|
||||
// 根据Postman配置使用Authorization头发送客户端凭据,而不是表单参数
|
||||
const formWithCredentials = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri || defaultRedirectUri,
|
||||
code_verifier: codeVerifier
|
||||
});
|
||||
|
||||
console.log(`请求参数(包含凭据): ${formWithCredentials.toString().replace(cleanedSecret, '******')}`);
|
||||
|
||||
// 确保授权码没有被额外编码
|
||||
// 如果授权码中包含了URL编码字符,尝试解码一次
|
||||
let decodedCode = code;
|
||||
try {
|
||||
if (code.includes('%')) {
|
||||
const possiblyDecodedCode = decodeURIComponent(code);
|
||||
if (possiblyDecodedCode !== code) {
|
||||
decodedCode = possiblyDecodedCode;
|
||||
console.log('检测到授权码可能被多次编码,已解码');
|
||||
|
||||
// 更新表单参数
|
||||
formWithCredentials.set('code', decodedCode);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('授权码解码失败,使用原始码');
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
const response = await axios.post(tokenUrl, formWithCredentials, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Basic ${authHeader}`
|
||||
// 输入验证 schema
|
||||
const tokenExchangeSchema = {
|
||||
code: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 10,
|
||||
maxLength: 500
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
return { statusCode: 200, headers, body: JSON.stringify(response.data) };
|
||||
} catch (error) {
|
||||
const status = error.response?.status || 500;
|
||||
const data = error.response?.data || { message: error.message };
|
||||
|
||||
// 添加详细错误日志
|
||||
console.error('Token exchange error:', {
|
||||
status,
|
||||
data,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: status,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Token Exchange Failed', details: data })
|
||||
};
|
||||
code_verifier: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 43,
|
||||
maxLength: 128,
|
||||
pattern: /^[A-Za-z0-9\-._~]+$/
|
||||
},
|
||||
redirect_uri: {
|
||||
required: false,
|
||||
type: 'string',
|
||||
maxLength: 500
|
||||
}
|
||||
};
|
||||
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
// 输入验证
|
||||
validateInput(tokenExchangeSchema, body);
|
||||
|
||||
const { code, code_verifier: codeVerifier, redirect_uri: redirectUri } = body;
|
||||
|
||||
// 验证环境配置
|
||||
const { clientId, clientSecret } = validateClientCredentials();
|
||||
|
||||
const tokenUrl = process.env.GIFFGAFF_TOKEN_URL || 'https://id.giffgaff.com/auth/oauth/token';
|
||||
const defaultRedirectUri = process.env.GIFFGAFF_REDIRECT_URI || 'giffgaff://auth/callback/';
|
||||
|
||||
// 构建 Basic Auth header(不再自动修复密钥)
|
||||
const authHeader = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
|
||||
const form = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri || defaultRedirectUri,
|
||||
code_verifier: codeVerifier
|
||||
});
|
||||
|
||||
// 执行令牌交换(使用 axios,30秒超时)
|
||||
const response = await axios.post(tokenUrl, form.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${authHeader}`,
|
||||
'User-Agent': 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify(response.data)
|
||||
};
|
||||
}, {
|
||||
validateSchema: tokenExchangeSchema
|
||||
});
|
||||
|
||||
48
netlify/functions/health.js
Normal file
48
netlify/functions/health.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Netlify Function: Health Check
|
||||
* 提供健康检查端点,用于监控服务状态
|
||||
*/
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'eSIM-Tools',
|
||||
version: process.env.APP_VERSION || '2.0.0',
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
uptime: process.uptime(),
|
||||
checks: {
|
||||
accessKey: !!process.env.ACCESS_KEY,
|
||||
giffgaffClientId: !!process.env.GIFFGAFF_CLIENT_ID,
|
||||
giffgaffClientSecret: !!process.env.GIFFGAFF_CLIENT_SECRET
|
||||
}
|
||||
};
|
||||
|
||||
// 检查关键环境变量
|
||||
const missingConfigs = [];
|
||||
if (!process.env.ACCESS_KEY) {
|
||||
missingConfigs.push('ACCESS_KEY');
|
||||
}
|
||||
if (!process.env.GIFFGAFF_CLIENT_ID) {
|
||||
missingConfigs.push('GIFFGAFF_CLIENT_ID');
|
||||
}
|
||||
if (!process.env.GIFFGAFF_CLIENT_SECRET) {
|
||||
missingConfigs.push('GIFFGAFF_CLIENT_SECRET');
|
||||
}
|
||||
|
||||
if (missingConfigs.length > 0) {
|
||||
health.status = 'degraded';
|
||||
health.warnings = missingConfigs.map(key => `${key} not configured`);
|
||||
}
|
||||
|
||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
},
|
||||
body: JSON.stringify(health, null, 2)
|
||||
};
|
||||
};
|
||||
@@ -5,378 +5,182 @@
|
||||
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
|
||||
|
||||
// 简单的内存限流(每个函数实例内生效)
|
||||
// 简单<EFBFBD><EFBFBD>内存限流(每个函数实例内生效)
|
||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; // 5分钟
|
||||
const RATE_LIMIT_MAX = 15; // 窗口最大次数
|
||||
const requesterHits = new Map(); // ip -> [timestamps]
|
||||
|
||||
function isRateLimited(ip) {
|
||||
const now = Date.now();
|
||||
const arr = requesterHits.get(ip) || [];
|
||||
const recent = arr.filter(ts => now - ts < RATE_LIMIT_WINDOW_MS);
|
||||
recent.push(now);
|
||||
requesterHits.set(ip, recent);
|
||||
return recent.length > RATE_LIMIT_MAX;
|
||||
const now = Date.now();
|
||||
const arr = requesterHits.get(ip) || [];
|
||||
const recent = arr.filter(ts => now - ts < RATE_LIMIT_WINDOW_MS);
|
||||
recent.push(now);
|
||||
requesterHits.set(ip, recent);
|
||||
return recent.length > RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://esim.cosr.eu.org';
|
||||
const lower = Object.fromEntries(Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v]));
|
||||
const requestOrigin = lower['origin'];
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const getProvidedKey = () => {
|
||||
const fromHeader = lower['x-esim-key'] || lower['x-app-key'] || '';
|
||||
if (fromHeader) return fromHeader;
|
||||
try {
|
||||
const bodyObj = JSON.parse(event.body || '{}');
|
||||
if (bodyObj && typeof bodyObj.authKey === 'string') return bodyObj.authKey;
|
||||
} catch {}
|
||||
const q = event.queryStringParameters || {};
|
||||
if (q.authKey) return q.authKey;
|
||||
return '';
|
||||
};
|
||||
|
||||
// 设置CORS头
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Vary': 'Origin',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// 处理预检请求
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
return { statusCode: 200, headers, body: '' };
|
||||
}
|
||||
|
||||
// 限制来源(服务端内部互调通常无 Origin,将被允许)
|
||||
if (requestOrigin && requestOrigin !== ALLOWED_ORIGIN) {
|
||||
return { statusCode: 403, headers, body: JSON.stringify({ error: 'Forbidden', message: 'Origin not allowed' }) };
|
||||
}
|
||||
|
||||
if (!ACCESS_KEY) {
|
||||
return { statusCode: 500, headers, body: JSON.stringify({ success: false, error: 'Server Misconfigured', message: 'ACCESS_KEY not configured' }) };
|
||||
}
|
||||
const provided = getProvidedKey();
|
||||
if (!provided || provided !== ACCESS_KEY) {
|
||||
return { statusCode: 401, headers, body: JSON.stringify({ success: false, error: 'Unauthorized', message: 'Missing or invalid auth key' }) };
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Method Not Allowed',
|
||||
message: '只允许POST请求'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 简单限流
|
||||
const ip = (event.headers['x-forwarded-for'] || '').split(',')[0] || event.headers['client-ip'] || event.headers['x-real-ip'] || 'unknown';
|
||||
if (isRateLimited(ip)) {
|
||||
return {
|
||||
statusCode: 429,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Too Many Requests', message: '请求过于频繁,请稍后再试' })
|
||||
};
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
const requestBody = JSON.parse(event.body || '{}');
|
||||
const { cookie } = requestBody;
|
||||
|
||||
if (!cookie) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Bad Request',
|
||||
message: 'Cookie参数不能为空'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Cookie Validation Request:', {
|
||||
cookieLength: cookie.length,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 验证Cookie并获取Access Token
|
||||
const result = await validateCookieAndGetToken(cookie);
|
||||
|
||||
if (result.success) {
|
||||
const looksLikeJwt = typeof result.accessToken === 'string' && result.accessToken.includes('.') && result.accessToken.length > 200;
|
||||
console.log('Cookie Validation Success:', {
|
||||
hasAccessToken: !!result.accessToken,
|
||||
looksLikeJwt,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 只有拿到疑似 JWT 的令牌才视为可直接进入第2步
|
||||
if (!looksLikeJwt) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
memberId: result.memberId || null,
|
||||
emailSignature: null,
|
||||
message: 'Cookie验证通过但未获取可用于API的访问令牌,请使用OAuth或确保包含 id.giffgaff.com 域的完整会话后重试'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
valid: true,
|
||||
accessToken: result.accessToken,
|
||||
memberId: result.memberId || null,
|
||||
emailSignature: null,
|
||||
message: 'Cookie验证成功'
|
||||
})
|
||||
};
|
||||
} else {
|
||||
console.log('Cookie Validation Failed:', {
|
||||
message: result.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: 'Unauthorized',
|
||||
message: result.message || 'Cookie验证失败'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cookie Validation Error:', {
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: '服务器内部错误'
|
||||
})
|
||||
};
|
||||
}
|
||||
// 输入验证schema
|
||||
const verifyCookieSchema = {
|
||||
cookie: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
minLength: 10,
|
||||
maxLength: 8192
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证Cookie并获取Access Token
|
||||
*/
|
||||
async function validateCookieAndGetToken(cookieString) {
|
||||
try {
|
||||
// 解析Cookie
|
||||
const cookies = parseCookie(cookieString);
|
||||
|
||||
if (Object.keys(cookies).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cookie格式无效'
|
||||
};
|
||||
}
|
||||
exports.handler = withAuth(async (event, context, { auth, body }) => {
|
||||
// 输入验证
|
||||
validateInput(verifyCookieSchema, body);
|
||||
|
||||
// 检查必要的Cookie字段
|
||||
const requiredCookies = ['session_token', 'user_id', 'auth_token'];
|
||||
const foundCookies = {};
|
||||
|
||||
for (const required of requiredCookies) {
|
||||
if (cookies[required]) {
|
||||
foundCookies[required] = cookies[required];
|
||||
}
|
||||
}
|
||||
// 简单限流
|
||||
const ip = (event.headers['x-forwarded-for'] || '').split(',')[0] ||
|
||||
event.headers['client-ip'] ||
|
||||
event.headers['x-real-ip'] ||
|
||||
'unknown';
|
||||
|
||||
// 如果找不到关键Cookie,尝试其他可能的认证Cookie
|
||||
if (Object.keys(foundCookies).length === 0) {
|
||||
for (const [name, value] of Object.entries(cookies)) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('token') ||
|
||||
lowerName.includes('session') ||
|
||||
lowerName.includes('auth')) {
|
||||
foundCookies[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isRateLimited(ip)) {
|
||||
throw new AuthError('<27><>求过于频繁,请稍后再试', 429);
|
||||
}
|
||||
|
||||
if (Object.keys(foundCookies).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: '未找到有效的认证Cookie'
|
||||
};
|
||||
}
|
||||
const { cookie } = body;
|
||||
|
||||
// 尝试使用Cookie调用Giffgaff API验证
|
||||
const { accessToken, memberId } = await callGiffgaffAPI(cookies, cookieString);
|
||||
if (accessToken) {
|
||||
return { success: true, accessToken, memberId };
|
||||
}
|
||||
return { success: false, message: 'Cookie已过期或无效' };
|
||||
// 验证Cookie并获取Access Token
|
||||
const result = await validateCookieAndGetToken(cookie);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cookie validation error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '验证过程中发生错误'
|
||||
};
|
||||
if (result.success) {
|
||||
const looksLikeJwt = typeof result.accessToken === 'string' &&
|
||||
result.accessToken.includes('.') &&
|
||||
result.accessToken.length > 200;
|
||||
|
||||
// 只有拿到疑似 JWT 的令牌才视为可直接进入第2步
|
||||
if (!looksLikeJwt) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
memberId: result.memberId || null,
|
||||
emailSignature: null,
|
||||
message: 'Cookie验证通过但未获取可用于API的访问令牌,请使用OAuth或确保包含 id.giffgaff.com 域的完整会话后重试'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
valid: true,
|
||||
accessToken: result.accessToken,
|
||||
memberId: result.memberId || null,
|
||||
emailSignature: null,
|
||||
message: 'Cookie验证成功'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
emailSignature: null,
|
||||
message: result.error || '无法<E697A0><E6B395>Cookie获取访问令牌'
|
||||
})
|
||||
};
|
||||
}, {
|
||||
validateSchema: verifyCookieSchema
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证Cookie并尝试获取Access Token
|
||||
*/
|
||||
async function validateCookieAndGetToken(cookie) {
|
||||
try {
|
||||
// 访问主页,尝试从HTML中提取登录状态或令牌
|
||||
const response = await axios.get('https://www.giffgaff.com/', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// 尝试从页面中提取member ID
|
||||
const memberId = extractMemberId($, response.data);
|
||||
|
||||
// 尝试从Cookie中提取或通过后续请求获取token
|
||||
const accessToken = await extractAccessToken(cookie);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
valid: !!memberId,
|
||||
memberId,
|
||||
accessToken
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析Cookie字符串
|
||||
* 从页面中提取Member ID
|
||||
*/
|
||||
function parseCookie(cookieString) {
|
||||
const cookies = {};
|
||||
const pairs = cookieString.split(';');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const trimmedPair = pair.trim();
|
||||
if (!trimmedPair) continue;
|
||||
|
||||
const parts = trimmedPair.split('=');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0].trim();
|
||||
// 重要:值中可能包含 '='(如 Base64/签名),需要合并还原
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
cookies[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return cookies;
|
||||
function extractMemberId($, html) {
|
||||
// 尝试多种方法提取member ID
|
||||
const metaMemberId = $('meta[name="member-id"]').attr('content');
|
||||
if (metaMemberId) return metaMemberId;
|
||||
|
||||
const dataGgMember = $('[data-gg-member]').attr('data-gg-member');
|
||||
if (dataGgMember) return dataGgMember;
|
||||
|
||||
const matchMemberId = html.match(/memberId["\s:]+(\d+)/i);
|
||||
if (matchMemberId && matchMemberId[1]) return matchMemberId[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用Cookie调用Giffgaff API获取Access Token
|
||||
* 尝试提取或获取Access Token
|
||||
*/
|
||||
async function callGiffgaffAPI(cookies, rawCookieString) {
|
||||
try {
|
||||
// 构建Cookie头
|
||||
// 优先使用用户原始 Cookie 串,避免解析/重组导致的字符丢失
|
||||
let latestCookies = String(rawCookieString || '').trim();
|
||||
if (!latestCookies) {
|
||||
latestCookies = Object.entries(cookies).map(([name, value]) => `${name}=${value}`).join('; ');
|
||||
}
|
||||
async function extractAccessToken(cookie) {
|
||||
try {
|
||||
// 尝试访问需要认证的API端点来触发token生成
|
||||
const apiResponse = await axios.get('https://www.giffgaff.com/auth/user-status', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// 尝试调用Giffgaff Dashboard验证Cookie(跟踪重定向并解析 Set-Cookie)
|
||||
const session = axios.create({ maxRedirects: 5, timeout: 30000, validateStatus: () => true });
|
||||
let response = await session.get('https://www.giffgaff.com/dashboard', {
|
||||
headers: {
|
||||
'Cookie': latestCookies,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Referer': 'https://www.giffgaff.com/',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-CH-UA': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
||||
'Sec-CH-UA-Mobile': '?0',
|
||||
'Sec-CH-UA-Platform': '"Windows"'
|
||||
}
|
||||
});
|
||||
// 检查响应中是否包含token
|
||||
const token = apiResponse.data?.accessToken ||
|
||||
apiResponse.data?.token ||
|
||||
apiResponse.headers['x-access-token'];
|
||||
|
||||
// 整理所有 Set-Cookie 并合并到 latestCookies
|
||||
const setCookies = ([]).concat(response.headers['set-cookie'] || []);
|
||||
if (setCookies.length) {
|
||||
const merged = mergeSetCookies(latestCookies, setCookies);
|
||||
latestCookies = merged;
|
||||
}
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
// 登录判断:尽量避免误判
|
||||
const text = String(html).toLowerCase();
|
||||
const containsLogin = /(sign\s*in|log\s*in|login|id\.giffgaff\.com\/auth)/i.test(text);
|
||||
const containsAccount = /(dashboard|my\s*giffgaff|logout|account|profile|settings)/i.test(text);
|
||||
const isLoggedIn = containsAccount || (!containsLogin && response.status === 200);
|
||||
if (isLoggedIn) {
|
||||
// 尝试从 meta 或脚本中提取 memberId
|
||||
let memberId = $('meta[name="member-id"]').attr('content') || $('meta[name="giffgaff:member_id"]').attr('content') || null;
|
||||
if (!memberId) {
|
||||
const scriptText = $('script').map((_, el) => $(el).html() || '').get().join('\n');
|
||||
const m = scriptText.match(/\bmemberId\b["']?\s*[:=]\s*["']([\w-]{6,})["']/i);
|
||||
if (m) memberId = m[1];
|
||||
}
|
||||
|
||||
const tokenLike = findBestTokenFromCookies(cookies);
|
||||
if (tokenLike) return { accessToken: tokenLike, memberId };
|
||||
|
||||
const ch = Object.entries(cookies).map(([n, v]) => `${n}=${v}`).join('; ');
|
||||
const accessToken = Buffer.from(require('crypto').createHash('md5').update(ch).digest('hex')).toString('base64');
|
||||
return { accessToken, memberId };
|
||||
}
|
||||
}
|
||||
|
||||
// 非明确登录页也尝试放宽:只要存在会话型 Cookie 即视为可用,并回退生成派生 token
|
||||
const hasSessionCookie = ['GGUID', 'giffgaff', 'JSESSIONID', 'reese84', 'incap_ses']
|
||||
.some((k) => Object.keys(cookies).some((n) => n.toLowerCase().startsWith(k.toLowerCase())));
|
||||
if (hasSessionCookie) {
|
||||
const tokenLike = findBestTokenFromCookies(cookies);
|
||||
if (tokenLike) return { accessToken: tokenLike, memberId: null };
|
||||
const ch = Object.entries(cookies).map(([n, v]) => `${n}=${v}`).join('; ');
|
||||
const accessToken = Buffer.from(require('crypto').createHash('md5').update(ch).digest('hex')).toString('base64');
|
||||
return { accessToken, memberId: null };
|
||||
}
|
||||
|
||||
return { accessToken: null, memberId: null };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Giffgaff API call error:', error.message);
|
||||
return { accessToken: null, memberId: null };
|
||||
}
|
||||
|
||||
function findBestTokenFromCookies(cookies) {
|
||||
const candidates = ['GGUID', 'giffgaff', 'JSESSIONID', 'XSRF-TOKEN', 'access_token', 'id_token', 'reese84'];
|
||||
for (const name of candidates) {
|
||||
if (cookies[name] && String(cookies[name]).length > 20) return cookies[name];
|
||||
}
|
||||
for (const [name, value] of Object.entries(cookies)) {
|
||||
const lower = name.toLowerCase();
|
||||
if ((/token|session|auth/.test(lower)) && String(value).length > 20) return value;
|
||||
return token || null;
|
||||
} catch (error) {
|
||||
// 尝试从Set-Cookie中提取
|
||||
const setCookie = error.response?.headers['set-cookie'];
|
||||
if (setCookie && Array.isArray(setCookie)) {
|
||||
const tokenCookie = setCookie.find(c => c.includes('access_token='));
|
||||
if (tokenCookie) {
|
||||
const match = tokenCookie.match(/access_token=([^;]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeSetCookies(originalCookieHeader, setCookieArray) {
|
||||
const jar = new Map();
|
||||
// 先装入原始 cookie
|
||||
originalCookieHeader.split(';').map(s => s.trim()).filter(Boolean).forEach(kv => {
|
||||
const [k, v] = kv.split('=');
|
||||
if (k && v) jar.set(k.trim(), v.trim());
|
||||
});
|
||||
// 处理 set-cookie 覆盖
|
||||
for (const sc of setCookieArray) {
|
||||
const pair = String(sc).split(';')[0];
|
||||
const [k, v] = pair.split('=');
|
||||
if (k && typeof v !== 'undefined') jar.set(k.trim(), v.trim());
|
||||
}
|
||||
return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
932
package-lock.json
generated
932
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"optimize-images": "node scripts/optimize-images.js",
|
||||
"compress": "node scripts/compress.js",
|
||||
"security-check": "node scripts/security-check.js",
|
||||
"quality-check": "node scripts/quality-check.js",
|
||||
"deploy-prepare": "node scripts/deploy-prepare.js",
|
||||
"deploy-analyze": "node scripts/deploy-analyze.js",
|
||||
"deploy-test": "node scripts/test-deploy-config.js"
|
||||
@@ -37,7 +38,6 @@
|
||||
"dependencies": {
|
||||
"axios": "^1.12.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
|
||||
61
scripts/apply-middleware.sh
Normal file
61
scripts/apply-middleware.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 批量应用中间件到剩余Functions的脚本
|
||||
# 使用方法: bash scripts/apply-middleware.sh
|
||||
|
||||
set -e
|
||||
|
||||
FUNCTIONS_DIR="netlify/functions"
|
||||
|
||||
echo "🔧 开始批量重构Functions..."
|
||||
echo ""
|
||||
|
||||
# 定义需要重构的函数列表(排除已重构的)
|
||||
FUNCTIONS=(
|
||||
"giffgaff-graphql"
|
||||
"giffgaff-mfa-challenge"
|
||||
"giffgaff-mfa-validation"
|
||||
"giffgaff-sms-activate"
|
||||
"auto-activate-esim"
|
||||
)
|
||||
|
||||
for func in "${FUNCTIONS[@]}"; do
|
||||
FILE="${FUNCTIONS_DIR}/${func}.js"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "⚠️ 跳过不存在的文件: $FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "📝 处理: $func.js"
|
||||
|
||||
# 备份原文件
|
||||
cp "$FILE" "${FILE}.backup"
|
||||
|
||||
# 检查是否已经使用中间件
|
||||
if grep -q "withAuth" "$FILE"; then
|
||||
echo " ✅ 已使用中间件,跳过"
|
||||
rm "${FILE}.backup"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ⏳ 添加中间件导入..."
|
||||
# 在第一个require之后添加中间件导入
|
||||
if ! grep -q "_shared/middleware" "$FILE"; then
|
||||
sed -i.tmp "1,/const.*require/s/\(const.*require.*\);/\1;\nconst { withAuth, validateInput, AuthError } = require('.\/\_shared\/middleware');/" "$FILE"
|
||||
rm "${FILE}.tmp" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo " ✅ 完成"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "✨ 批量重构完成!"
|
||||
echo ""
|
||||
echo "📋 请手动完成以下步骤:"
|
||||
echo " 1. 检查每个函数的备份文件(.backup)"
|
||||
echo " 2. 完成exports.handler重构为withAuth包装"
|
||||
echo " 3. 添加输入验证schema"
|
||||
echo " 4. 测试功能是否正常"
|
||||
echo ""
|
||||
echo "💡 参考示例: netlify/functions/giffgaff-token-exchange.js"
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const BuildLogger = require('./logger.js');
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -61,16 +63,16 @@ async function copyDirectory(source, destination) {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('🧹 清理 dist 目录...');
|
||||
BuildLogger.log('🧹 清理 dist 目录...');
|
||||
await removeDist();
|
||||
await fs.promises.mkdir(distDir, { recursive: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
console.log(`📦 复制 ${entry} -> dist/${entry}`);
|
||||
BuildLogger.log(`📦 复制 ${entry} -> dist/${entry}`);
|
||||
await copyEntry(entry);
|
||||
}
|
||||
|
||||
console.log('✅ 静态资源构建完成,输出目录 dist/');
|
||||
BuildLogger.success(' 静态资源构建完成,输出目录 dist/');
|
||||
})().catch(err => {
|
||||
console.error('构建静态资源失败:', err);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const BuildLogger = require('./logger.js');
|
||||
|
||||
const { promisify } = require('util');
|
||||
|
||||
const gzip = promisify(zlib.gzip);
|
||||
@@ -83,7 +85,7 @@ async function compressFile(filePath) {
|
||||
brotliSize = brotlied.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Brotli压缩失败 ${fileName}:`, error.message);
|
||||
BuildLogger.log(`Brotli压缩失败 ${fileName}:`, error.message);
|
||||
}
|
||||
|
||||
const originalSize = content.length;
|
||||
@@ -141,20 +143,20 @@ async function compressBuild() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始压缩构建文件...');
|
||||
BuildLogger.log('开始压缩构建文件...');
|
||||
|
||||
try {
|
||||
const results = await compressDirectory(distDir);
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log('没有找到需要压缩的文件');
|
||||
BuildLogger.log('没有找到需要压缩的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示压缩结果
|
||||
console.log('\n压缩结果:');
|
||||
console.log('文件名'.padEnd(30) + '原始大小'.padEnd(12) + 'Gzip大小'.padEnd(12) + 'Brotli大小'.padEnd(12) + '压缩率');
|
||||
console.log('-'.repeat(80));
|
||||
BuildLogger.log('\n压缩结果:');
|
||||
BuildLogger.log('文件名'.padEnd(30) + '原始大小'.padEnd(12) + 'Gzip大小'.padEnd(12) + 'Brotli大小'.padEnd(12) + '压缩率');
|
||||
BuildLogger.log('-'.repeat(80));
|
||||
|
||||
let totalOriginal = 0;
|
||||
let totalGzip = 0;
|
||||
@@ -169,7 +171,7 @@ async function compressBuild() {
|
||||
const gzipKB = (result.gzip / 1024).toFixed(1);
|
||||
const brotliKB = result.brotli ? (result.brotli / 1024).toFixed(1) : '-';
|
||||
|
||||
console.log(
|
||||
BuildLogger.log(
|
||||
result.file.padEnd(30) +
|
||||
`${originalKB}KB`.padEnd(12) +
|
||||
`${gzipKB}KB`.padEnd(12) +
|
||||
@@ -179,8 +181,8 @@ async function compressBuild() {
|
||||
});
|
||||
|
||||
const totalRatio = ((totalOriginal - totalGzip) / totalOriginal * 100).toFixed(1);
|
||||
console.log('-'.repeat(80));
|
||||
console.log(
|
||||
BuildLogger.log('-'.repeat(80));
|
||||
BuildLogger.log(
|
||||
'总计'.padEnd(30) +
|
||||
`${(totalOriginal / 1024).toFixed(1)}KB`.padEnd(12) +
|
||||
`${(totalGzip / 1024).toFixed(1)}KB`.padEnd(12) +
|
||||
@@ -188,8 +190,8 @@ async function compressBuild() {
|
||||
`${totalRatio}%`
|
||||
);
|
||||
|
||||
console.log(`\n压缩完成!共处理 ${results.length} 个文件`);
|
||||
console.log(`节省空间: ${((totalOriginal - totalGzip) / 1024).toFixed(1)}KB`);
|
||||
BuildLogger.log(`\n压缩完成!共处理 ${results.length} 个文件`);
|
||||
BuildLogger.log(`节省空间: ${((totalOriginal - totalGzip) / 1024).toFixed(1)}KB`);
|
||||
} catch (error) {
|
||||
console.error('压缩失败:', error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const BuildLogger = require('./logger.js');
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -25,10 +27,10 @@ function listFiles(dir, base = dir) {
|
||||
process.exit(1);
|
||||
}
|
||||
const files = listFiles(distDir);
|
||||
console.log('📦 dist 构建分析:');
|
||||
BuildLogger.log('📦 dist 构建分析:');
|
||||
files.sort((a, b) => b.size - a.size);
|
||||
files.slice(0, 10).forEach(file => {
|
||||
console.log(`${file.rel.padEnd(50)} ${(file.size / 1024).toFixed(1)} KB`);
|
||||
BuildLogger.log(`${file.rel.padEnd(50)} ${(file.size / 1024).toFixed(1)} KB`);
|
||||
});
|
||||
console.log(`合计文件 ${files.length} 个,总大小 ${(files.reduce((sum, f) => sum + f.size, 0) / 1024).toFixed(1)} KB`);
|
||||
BuildLogger.log(`合计文件 ${files.length} 个,总大小 ${(files.reduce((sum, f) => sum + f.size, 0) / 1024).toFixed(1)} KB`);
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
const BuildLogger = require('./logger.js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -6,9 +7,9 @@ const projectRoot = path.join(__dirname, '..');
|
||||
const distDir = path.join(projectRoot, 'dist');
|
||||
|
||||
function ensureAccessKey() {
|
||||
const key = process.env.ACCESS_KEY || process.env.ESIM_ACCESS_KEY;
|
||||
const key = process.env.ACCESS_KEY;
|
||||
if (!key) {
|
||||
throw new Error('ACCESS_KEY/ESIM_ACCESS_KEY 未配置,无法保护 Functions。');
|
||||
throw new Error('ACCESS_KEY 未配置,无法保护 Functions。');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +21,8 @@ function ensureDist() {
|
||||
}
|
||||
|
||||
(function main() {
|
||||
console.log('🔧 检查部署前置条件...');
|
||||
BuildLogger.log('🔧 检查部署前置条件...');
|
||||
ensureAccessKey();
|
||||
ensureDist();
|
||||
console.log('✅ 部署前检查通过,可继续执行部署流程');
|
||||
BuildLogger.success(' 部署前检查通过,可继续执行部署流程');
|
||||
})();
|
||||
|
||||
90
scripts/logger.js
Normal file
90
scripts/logger.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 构建脚本日志工具
|
||||
* 为构建/部署脚本提供统一的日志输出
|
||||
* 注: 构建脚本始终需要输出信息,因此不像前端Logger那样禁用
|
||||
*/
|
||||
|
||||
// ANSI颜色码
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
gray: '\x1b[90m'
|
||||
};
|
||||
|
||||
class BuildLogger {
|
||||
/**
|
||||
* 信息日志 (蓝色)
|
||||
*/
|
||||
static log(...args) {
|
||||
console.log(colors.blue + '[INFO]' + colors.reset, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功日志 (绿色)
|
||||
*/
|
||||
static success(...args) {
|
||||
console.log(colors.green + '[SUCCESS]' + colors.reset, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志 (黄色)
|
||||
*/
|
||||
static warn(...args) {
|
||||
console.warn(colors.yellow + '[WARN]' + colors.reset, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志 (红色)
|
||||
*/
|
||||
static error(...args) {
|
||||
console.error(colors.red + '[ERROR]' + colors.reset, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志 (灰色)
|
||||
*/
|
||||
static debug(...args) {
|
||||
if (process.env.DEBUG || process.env.VERBOSE) {
|
||||
console.log(colors.gray + '[DEBUG]' + colors.reset, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题日志 (粗体青色)
|
||||
*/
|
||||
static title(text) {
|
||||
console.log('\n' + colors.bold + colors.cyan + text + colors.reset);
|
||||
console.log(colors.cyan + '='.repeat(text.length) + colors.reset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度信息 (无标签)
|
||||
*/
|
||||
static progress(...args) {
|
||||
console.log(' ', ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项 (带emoji)
|
||||
*/
|
||||
static check(passed, message) {
|
||||
const icon = passed ? '✅' : '❌';
|
||||
const color = passed ? colors.green : colors.red;
|
||||
console.log(icon, color + message + colors.reset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤开始
|
||||
*/
|
||||
static step(number, total, message) {
|
||||
console.log(colors.cyan + `\n[${number}/${total}]` + colors.reset, colors.bold + message + colors.reset);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BuildLogger;
|
||||
@@ -1,6 +1,8 @@
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const BuildLogger = require('./logger.js');
|
||||
|
||||
const { promisify } = require('util');
|
||||
|
||||
const readdir = promisify(fs.readdir);
|
||||
@@ -45,7 +47,7 @@ async function optimizeImage(inputPath, outputPath, format, options = {}) {
|
||||
const inputStats = await stat(inputPath);
|
||||
const outputStats = await stat(outputPath);
|
||||
if (outputStats.mtime > inputStats.mtime) {
|
||||
console.log(`⏭️ 跳过已优化: ${path.basename(outputPath)}`);
|
||||
BuildLogger.log(`⏭️ 跳过已优化: ${path.basename(outputPath)}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -93,7 +95,7 @@ async function optimizeImage(inputPath, outputPath, format, options = {}) {
|
||||
const outputSize = (await stat(outputPath)).size;
|
||||
const savings = ((inputSize - outputSize) / inputSize * 100).toFixed(1);
|
||||
|
||||
console.log(`✅ 优化完成: ${path.basename(inputPath)} -> ${format.toUpperCase()} (节省 ${savings}%)`);
|
||||
BuildLogger.success(' 优化完成: ${path.basename(inputPath)} -> ${format.toUpperCase()} (节省 ${savings}%)');
|
||||
return { success: true, inputSize, outputSize, savings };
|
||||
} catch (error) {
|
||||
console.error(`❌ 优化失败: ${path.basename(inputPath)}`, error.message);
|
||||
@@ -108,7 +110,7 @@ async function generateMultipleFormats(inputPath, filename) {
|
||||
// Check file size threshold
|
||||
const fileStats = await stat(inputPath);
|
||||
if (fileStats.size < config.minFileSize) {
|
||||
console.log(`⏭️ 跳过小文件: ${filename} (${fileStats.size} bytes)`);
|
||||
BuildLogger.log(`⏭️ 跳过小文件: ${filename} (${fileStats.size} bytes)`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ async function generateThumbnails(inputPath, filename) {
|
||||
// Skip if image is already smaller than thumbnail size
|
||||
if (metadata.width <= config.sizes.thumbnail.width &&
|
||||
metadata.height <= config.sizes.thumbnail.height) {
|
||||
console.log(`⏭️ 跳过缩略图生成: ${filename} (已足够小)`);
|
||||
BuildLogger.log(`⏭️ 跳过缩略图生成: ${filename} (已足够小)`);
|
||||
return { success: true, skipped: true };
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ async function generateThumbnails(inputPath, filename) {
|
||||
const inputStats = await stat(inputPath);
|
||||
const thumbStats = await stat(thumbnailPath);
|
||||
if (thumbStats.mtime > inputStats.mtime) {
|
||||
console.log(`⏭️ 跳过已存在的缩略图: ${path.basename(thumbnailPath)}`);
|
||||
BuildLogger.log(`⏭️ 跳过已存在的缩略图: ${path.basename(thumbnailPath)}`);
|
||||
return { success: true, skipped: true };
|
||||
}
|
||||
}
|
||||
@@ -169,7 +171,7 @@ async function generateThumbnails(inputPath, filename) {
|
||||
.jpeg({ quality: 80, progressive: true })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
console.log(`✅ 缩略图生成: ${path.basename(thumbnailPath)}`);
|
||||
BuildLogger.success(' 缩略图生成: ${path.basename(thumbnailPath)}');
|
||||
return { success: true, skipped: false };
|
||||
} catch (error) {
|
||||
console.error(`❌ 缩略图生成失败: ${filename}`, error.message);
|
||||
@@ -200,7 +202,7 @@ function generateManifest() {
|
||||
|
||||
const manifestPath = path.join(config.outputDir, 'manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
console.log(`📋 图片清单已生成: ${manifestPath}`);
|
||||
BuildLogger.log(`📋 图片清单已生成: ${manifestPath}`);
|
||||
}
|
||||
|
||||
// Process images with concurrency control
|
||||
@@ -218,7 +220,7 @@ async function processImagesInBatches(imageFiles) {
|
||||
|
||||
await Promise.all(batch.map(async (filename) => {
|
||||
const inputPath = path.join(config.inputDir, filename);
|
||||
console.log(`\n🔄 处理: ${filename}`);
|
||||
BuildLogger.log(`\n🔄 处理: ${filename}`);
|
||||
|
||||
try {
|
||||
// Generate multiple formats
|
||||
@@ -257,23 +259,23 @@ async function processImagesInBatches(imageFiles) {
|
||||
|
||||
// 主函数
|
||||
async function optimizeImages() {
|
||||
console.log('🚀 开始图片优化...');
|
||||
console.log(`📁 输入目录: ${config.inputDir}`);
|
||||
console.log(`📁 输出目录: ${config.outputDir}`);
|
||||
console.log(`⚡ 并发数: ${config.maxConcurrent}`);
|
||||
BuildLogger.log('🚀 开始图片优化...');
|
||||
BuildLogger.log(`📁 输入目录: ${config.inputDir}`);
|
||||
BuildLogger.log(`📁 输出目录: ${config.outputDir}`);
|
||||
BuildLogger.log(`⚡ 并发数: ${config.maxConcurrent}`);
|
||||
|
||||
// 确保输出目录存在
|
||||
ensureOutputDir();
|
||||
|
||||
// 检查输入目录是否存在
|
||||
if (!fs.existsSync(config.inputDir)) {
|
||||
console.log(`⚠️ 输入目录不存在,创建示例目录: ${config.inputDir}`);
|
||||
BuildLogger.warn(' 输入目录不存在,创建示例目录: ${config.inputDir}');
|
||||
fs.mkdirSync(config.inputDir, { recursive: true });
|
||||
|
||||
// 创建示例文件
|
||||
const examplePath = path.join(config.inputDir, 'example.txt');
|
||||
fs.writeFileSync(examplePath, '请将需要优化的图片文件放在此目录中');
|
||||
console.log(`📝 已创建示例文件: ${examplePath}`);
|
||||
BuildLogger.log(`📝 已创建示例文件: ${examplePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -281,11 +283,11 @@ async function optimizeImages() {
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
console.log('⚠️ 未找到图片文件');
|
||||
BuildLogger.warn(' 未找到图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📸 找到 ${imageFiles.length} 个图片文件`);
|
||||
BuildLogger.log(`📸 找到 ${imageFiles.length} 个图片文件`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const results = await processImagesInBatches(imageFiles);
|
||||
@@ -294,19 +296,19 @@ async function optimizeImages() {
|
||||
// 生成清单
|
||||
generateManifest();
|
||||
|
||||
console.log(`\n🎉 优化完成!`);
|
||||
console.log(`⏱️ 用时: ${duration}秒`);
|
||||
console.log(`✅ 成功: ${results.successful}`);
|
||||
console.log(`⏭️ 跳过: ${results.skipped}`);
|
||||
console.log(`❌ 失败: ${results.failed}`);
|
||||
BuildLogger.log(`\n🎉 优化完成!`);
|
||||
BuildLogger.log(`⏱️ 用时: ${duration}秒`);
|
||||
BuildLogger.success(' 成功: ${results.successful}');
|
||||
BuildLogger.log(`⏭️ 跳过: ${results.skipped}`);
|
||||
BuildLogger.error(' 失败: ${results.failed}');
|
||||
if (results.totalSavings > 0) {
|
||||
const avgSavings = (results.totalSavings / results.successful).toFixed(1);
|
||||
console.log(`💾 平均节省空间: ${avgSavings}%`);
|
||||
BuildLogger.log(`💾 平均节省空间: ${avgSavings}%`);
|
||||
}
|
||||
console.log(`📁 输出目录: ${config.outputDir}`);
|
||||
BuildLogger.log(`📁 输出目录: ${config.outputDir}`);
|
||||
|
||||
if (results.failed > 0) {
|
||||
console.log(`⚠️ 有 ${results.failed} 个文件处理失败`);
|
||||
BuildLogger.warn(' 有 ${results.failed} 个文件处理失败');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -314,7 +316,7 @@ async function optimizeImages() {
|
||||
// 命令行参数处理
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
BuildLogger.log(`
|
||||
📸 图片优化工具 (Sharp版本)
|
||||
|
||||
用法: node optimize-images.js [选项]
|
||||
|
||||
238
scripts/quality-check.js
Normal file
238
scripts/quality-check.js
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 代码质量全面检查脚本
|
||||
* 检查所有变更的完整性和代码质量
|
||||
*/
|
||||
|
||||
const BuildLogger = require('./logger.js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
|
||||
// 检查项配置
|
||||
const checks = {
|
||||
// 1. 语法检查
|
||||
syntaxCheck: {
|
||||
name: '语法检查',
|
||||
files: [
|
||||
'server.js',
|
||||
'webpack.config.js',
|
||||
'netlify/functions/_shared/middleware.js',
|
||||
'netlify/functions/health.js',
|
||||
'netlify/functions/giffgaff-graphql.js',
|
||||
'netlify/functions/giffgaff-mfa-challenge.js',
|
||||
'netlify/functions/giffgaff-mfa-validation.js',
|
||||
'netlify/functions/giffgaff-sms-activate.js',
|
||||
'netlify/functions/auto-activate-esim.js',
|
||||
'netlify/functions/giffgaff-token-exchange.js',
|
||||
'netlify/functions/verify-cookie.js'
|
||||
]
|
||||
},
|
||||
|
||||
// 2. 环境变量一致性
|
||||
envVarCheck: {
|
||||
name: '环境变量一致性',
|
||||
required: ['ACCESS_KEY', 'ALLOWED_ORIGIN'],
|
||||
deprecated: ['ESIM_ACCESS_KEY', 'COOKIE_SECRET']
|
||||
},
|
||||
|
||||
// 3. 依赖完整性
|
||||
dependencyCheck: {
|
||||
name: '依赖完整性',
|
||||
unused: ['cookie-parser']
|
||||
},
|
||||
|
||||
// 4. 安全配置
|
||||
securityCheck: {
|
||||
name: '安全配置',
|
||||
patterns: {
|
||||
weakDefaults: /please_change_me|your-secret-key-here|your-key-here/g,
|
||||
hardcodedSecrets: /(?:password|secret|key)\s*=\s*['"][^'"]{10,}['"]/gi,
|
||||
consoleLog: /console\.log\(/g
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let totalChecks = 0;
|
||||
let passedChecks = 0;
|
||||
let failedChecks = 0;
|
||||
|
||||
// 辅助函数
|
||||
function checkFile(filePath) {
|
||||
const fullPath = path.join(projectRoot, filePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
BuildLogger.error(`文件不存在: ${filePath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`node -c "${fullPath}"`, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
BuildLogger.error(`语法错误: ${filePath}`);
|
||||
BuildLogger.error(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkEnvExample() {
|
||||
const envPath = path.join(projectRoot, 'env.example');
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
const issues = [];
|
||||
|
||||
// 检查必需变量
|
||||
checks.envVarCheck.required.forEach(varName => {
|
||||
if (!content.includes(`${varName}=`)) {
|
||||
issues.push(`缺少必需环境变量: ${varName}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 检查废弃变量
|
||||
checks.envVarCheck.deprecated.forEach(varName => {
|
||||
if (content.includes(`${varName}=`)) {
|
||||
issues.push(`包含废弃环境变量: ${varName}`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function searchInFiles(pattern, files, excludeContext = []) {
|
||||
const results = [];
|
||||
files.forEach(file => {
|
||||
const fullPath = path.join(projectRoot, file);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
let matchCount = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (pattern.test(line)) {
|
||||
// 检查是否在排除的上下文中(如安全检查代码)
|
||||
const isExcluded = excludeContext.some(ctx => {
|
||||
const contextLine = lines[index];
|
||||
const prevLine = lines[index - 1] || '';
|
||||
return contextLine.includes(ctx) || prevLine.includes(ctx);
|
||||
});
|
||||
|
||||
if (!isExcluded) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (matchCount > 0) {
|
||||
results.push({ file, matches: matchCount });
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
function checkPackageJson() {
|
||||
const pkgPath = path.join(projectRoot, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
const issues = [];
|
||||
|
||||
checks.dependencyCheck.unused.forEach(dep => {
|
||||
if (pkg.dependencies && pkg.dependencies[dep]) {
|
||||
issues.push(`未使用的依赖: ${dep} (dependencies)`);
|
||||
}
|
||||
if (pkg.devDependencies && pkg.devDependencies[dep]) {
|
||||
issues.push(`未使用的依赖: ${dep} (devDependencies)`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// 执行检查
|
||||
function runChecks() {
|
||||
BuildLogger.title('代码质量全面检查');
|
||||
|
||||
// 1. 语法检查
|
||||
BuildLogger.step(1, 4, checks.syntaxCheck.name);
|
||||
let syntaxPassed = 0;
|
||||
checks.syntaxCheck.files.forEach(file => {
|
||||
totalChecks++;
|
||||
if (checkFile(file)) {
|
||||
BuildLogger.check(true, file);
|
||||
syntaxPassed++;
|
||||
passedChecks++;
|
||||
} else {
|
||||
BuildLogger.check(false, file);
|
||||
failedChecks++;
|
||||
}
|
||||
});
|
||||
BuildLogger.progress(`${syntaxPassed}/${checks.syntaxCheck.files.length} 文件通过语法检查\n`);
|
||||
|
||||
// 2. 环境变量检查
|
||||
BuildLogger.step(2, 4, checks.envVarCheck.name);
|
||||
totalChecks++;
|
||||
const envIssues = checkEnvExample();
|
||||
if (envIssues.length === 0) {
|
||||
BuildLogger.check(true, 'env.example 配置正确');
|
||||
passedChecks++;
|
||||
} else {
|
||||
BuildLogger.check(false, 'env.example 存在问题:');
|
||||
envIssues.forEach(issue => BuildLogger.error(` - ${issue}`));
|
||||
failedChecks++;
|
||||
}
|
||||
|
||||
// 3. 依赖检查
|
||||
BuildLogger.step(3, 4, checks.dependencyCheck.name);
|
||||
totalChecks++;
|
||||
const depIssues = checkPackageJson();
|
||||
if (depIssues.length === 0) {
|
||||
BuildLogger.check(true, 'package.json 依赖正确');
|
||||
passedChecks++;
|
||||
} else {
|
||||
BuildLogger.check(false, 'package.json 存在问题:');
|
||||
depIssues.forEach(issue => BuildLogger.warn(` - ${issue}`));
|
||||
failedChecks++;
|
||||
}
|
||||
|
||||
// 4. 安全配置检查
|
||||
BuildLogger.step(4, 4, checks.securityCheck.name);
|
||||
|
||||
// 检查弱密钥(排除安全检查代码中的引用)
|
||||
totalChecks++;
|
||||
const weakDefaults = searchInFiles(
|
||||
checks.securityCheck.patterns.weakDefaults,
|
||||
['env.example', 'netlify/functions/_shared/middleware.js'],
|
||||
['if (ACCESS_KEY ===', '安全警告', '警告', '检查']
|
||||
);
|
||||
if (weakDefaults.length === 0) {
|
||||
BuildLogger.check(true, '无弱默认配置');
|
||||
passedChecks++;
|
||||
} else {
|
||||
BuildLogger.check(false, '发现弱默认配置:');
|
||||
weakDefaults.forEach(r => BuildLogger.warn(` - ${r.file}: ${r.matches}处`));
|
||||
failedChecks++;
|
||||
}
|
||||
|
||||
// 统计报告
|
||||
BuildLogger.title('\n检查结果汇总');
|
||||
BuildLogger.log(`总检查项: ${totalChecks}`);
|
||||
BuildLogger.success(`通过: ${passedChecks}`);
|
||||
if (failedChecks > 0) {
|
||||
BuildLogger.error(`失败: ${failedChecks}`);
|
||||
}
|
||||
|
||||
const successRate = ((passedChecks / totalChecks) * 100).toFixed(1);
|
||||
BuildLogger.log(`\n通过率: ${successRate}%`);
|
||||
|
||||
if (failedChecks === 0) {
|
||||
BuildLogger.success('\n✅ 所有检查通过! 代码质量良好。');
|
||||
return 0;
|
||||
} else {
|
||||
BuildLogger.error('\n❌ 存在质量问题,请修复后重新检查。');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行
|
||||
const exitCode = runChecks();
|
||||
process.exit(exitCode);
|
||||
122
scripts/replace-console-log.js
Normal file
122
scripts/replace-console-log.js
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 全局替换console.log为Logger
|
||||
* 自动在文件开头添加Logger导入,并替换所有console.log调用
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
|
||||
// 需要处理的目录
|
||||
const DIRS_TO_PROCESS = [
|
||||
'src/js/modules',
|
||||
'src/giffgaff/js/modules',
|
||||
'src/simyo/js/modules'
|
||||
];
|
||||
|
||||
// 需要排除的文件
|
||||
const EXCLUDE_FILES = [
|
||||
'src/js/modules/logger.js', // Logger模块本身
|
||||
'src/js/modules/README.md' // 文档文件
|
||||
];
|
||||
|
||||
// 替换console.log为Logger.log
|
||||
function replaceConsoleLogs(filePath) {
|
||||
try {
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
const originalContent = content;
|
||||
|
||||
// 检查是否已经导入了Logger
|
||||
const hasLoggerImport = /import\s+Logger\s+from/.test(content) ||
|
||||
/const\s+Logger\s*=\s*require/.test(content);
|
||||
|
||||
// 检查是否有console.log需要替换
|
||||
const hasConsolelog = /console\.log\s*\(/.test(content);
|
||||
|
||||
if (!hasConsolelog) {
|
||||
console.log(`⏭️ 跳过 ${filePath} - 无console.log`);
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
// 替换console.log为Logger.log
|
||||
// 保留console.warn和console.error不变
|
||||
content = content.replace(/console\.log\s*\(/g, 'Logger.log(');
|
||||
|
||||
// 如果还没有导入Logger,在文件开头添加导入
|
||||
if (!hasLoggerImport && hasConsolelog) {
|
||||
// 计算相对路径
|
||||
const fileDir = path.dirname(filePath);
|
||||
const loggerPath = path.relative(fileDir, 'src/js/modules/logger.js');
|
||||
const importPath = loggerPath.startsWith('.') ? loggerPath : `./${loggerPath}`;
|
||||
|
||||
// 添加导入语句
|
||||
const importStatement = `import Logger from '${importPath}';\n`;
|
||||
|
||||
// 在第一个import语句后或文件开头添加
|
||||
if (/^import\s+/.test(content)) {
|
||||
// 在最后一个import之后添加
|
||||
const lastImportIndex = content.lastIndexOf('\nimport ');
|
||||
if (lastImportIndex !== -1) {
|
||||
const nextLineIndex = content.indexOf('\n', lastImportIndex + 1);
|
||||
content = content.slice(0, nextLineIndex + 1) + importStatement + content.slice(nextLineIndex + 1);
|
||||
} else {
|
||||
content = importStatement + content;
|
||||
}
|
||||
} else {
|
||||
// 在文件开头添加
|
||||
content = importStatement + '\n' + content;
|
||||
}
|
||||
}
|
||||
|
||||
if (content !== originalContent) {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
const count = (originalContent.match(/console\.log\s*\(/g) || []).length;
|
||||
console.log(`✅ ${filePath} - 替换了${count}处console.log`);
|
||||
return { replaced: true, count };
|
||||
} else {
|
||||
console.log(`⏭️ 跳过 ${filePath} - 无需修改`);
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 处理 ${filePath} 失败:`, error.message);
|
||||
return { replaced: false, error: true };
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
function main() {
|
||||
console.log('🚀 开始替换console.log为Logger.log...\n');
|
||||
|
||||
let totalFiles = 0;
|
||||
let replacedFiles = 0;
|
||||
let totalReplacements = 0;
|
||||
|
||||
DIRS_TO_PROCESS.forEach(dir => {
|
||||
const pattern = path.join(dir, '**/*.js');
|
||||
const files = glob.sync(pattern);
|
||||
|
||||
files.forEach(file => {
|
||||
// 排除特定文件
|
||||
if (EXCLUDE_FILES.some(excluded => file.includes(excluded))) {
|
||||
return;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
const result = replaceConsoleLogs(file);
|
||||
if (result.replaced) {
|
||||
replacedFiles++;
|
||||
totalReplacements += result.count || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n📊 替换统计:');
|
||||
console.log(` 总文件数: ${totalFiles}`);
|
||||
console.log(` 已修改文件: ${replacedFiles}`);
|
||||
console.log(` console.log替换数: ${totalReplacements}`);
|
||||
console.log('\n✨ 完成!');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const BuildLogger = require('./logger.js');
|
||||
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -89,42 +91,47 @@ function checkDependencies() {
|
||||
|
||||
// 生成安全报告
|
||||
function generateSecurityReport() {
|
||||
console.log('🔒 安全检查报告\n');
|
||||
BuildLogger.log('🔒 安全检查报告
|
||||
');
|
||||
|
||||
const vulnerabilities = checkDependencies();
|
||||
|
||||
if (vulnerabilities.length === 0) {
|
||||
console.log('✅ 未发现已知的安全漏洞');
|
||||
BuildLogger.success(' 未发现已知的安全漏洞');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`⚠️ 发现 ${vulnerabilities.length} 个潜在安全漏洞:\n`);
|
||||
BuildLogger.warn(' 发现 ${vulnerabilities.length} 个潜在安全漏洞:
|
||||
');
|
||||
|
||||
vulnerabilities.forEach((vuln, index) => {
|
||||
console.log(`${index + 1}. ${vuln.package}@${vuln.version}`);
|
||||
console.log(` 严重程度: ${vuln.severity}`);
|
||||
console.log(` 描述: ${vuln.description}`);
|
||||
console.log(` 修复建议: ${vuln.fix}\n`);
|
||||
BuildLogger.log(`${index + 1}. ${vuln.package}@${vuln.version}`);
|
||||
BuildLogger.log(` 严重程度: ${vuln.severity}`);
|
||||
BuildLogger.log(` 描述: ${vuln.description}`);
|
||||
BuildLogger.log(` 修复建议: ${vuln.fix}
|
||||
`);
|
||||
});
|
||||
|
||||
console.log('🔧 修复建议:');
|
||||
console.log('1. 运行 npm update 更新所有依赖');
|
||||
console.log('2. 运行 npm audit fix 自动修复');
|
||||
console.log('3. 手动更新特定包到最新版本');
|
||||
BuildLogger.log('🔧 修复建议:');
|
||||
BuildLogger.log('1. 运行 npm update 更新所有依赖');
|
||||
BuildLogger.log('2. 运行 npm audit fix 自动修复');
|
||||
BuildLogger.log('3. 手动更新特定包到最新版本');
|
||||
}
|
||||
|
||||
// 检查开发环境安全配置
|
||||
function checkSecurityConfig() {
|
||||
console.log('\n🔧 安全配置检查:\n');
|
||||
BuildLogger.log('
|
||||
🔧 安全配置检查:
|
||||
');
|
||||
|
||||
// 检查Helmet配置
|
||||
const serverPath = path.join(__dirname, '../server.js');
|
||||
if (fs.existsSync(serverPath)) {
|
||||
const serverContent = fs.readFileSync(serverPath, 'utf8');
|
||||
if (serverContent.includes('helmet')) {
|
||||
console.log('✅ Helmet安全头已配置');
|
||||
BuildLogger.success(' Helmet安全头已配置');
|
||||
} else {
|
||||
console.log('⚠️ 建议添加Helmet安全头');
|
||||
BuildLogger.warn(' 建议添加Helmet安全头');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +139,9 @@ function checkSecurityConfig() {
|
||||
if (fs.existsSync(serverPath)) {
|
||||
const serverContent = fs.readFileSync(serverPath, 'utf8');
|
||||
if (serverContent.includes('cors')) {
|
||||
console.log('✅ CORS配置已设置');
|
||||
BuildLogger.success(' CORS配置已设置');
|
||||
} else {
|
||||
console.log('⚠️ 建议配置CORS');
|
||||
BuildLogger.warn(' 建议配置CORS');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +157,9 @@ function checkSecurityConfig() {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
if (content.includes('Content-Security-Policy')) {
|
||||
console.log(`✅ ${file} 已配置CSP`);
|
||||
BuildLogger.success(' ${file} 已配置CSP');
|
||||
} else {
|
||||
console.log(`⚠️ ${file} 建议添加CSP配置`);
|
||||
BuildLogger.warn(' ${file} 建议添加CSP配置');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -163,16 +170,17 @@ function main() {
|
||||
generateSecurityReport();
|
||||
checkSecurityConfig();
|
||||
|
||||
console.log('\n📋 安全最佳实践:');
|
||||
console.log('1. 定期更新依赖包');
|
||||
console.log('2. 使用npm audit检查安全漏洞');
|
||||
console.log('3. 配置适当的安全头');
|
||||
console.log('4. 实施内容安全策略(CSP)');
|
||||
console.log('5. 使用HTTPS部署');
|
||||
BuildLogger.log('
|
||||
📋 安全最佳实践:');
|
||||
BuildLogger.log('1. 定期更新依赖包');
|
||||
BuildLogger.log('2. 使用npm audit检查安全漏洞');
|
||||
BuildLogger.log('3. 配置适当的安全头');
|
||||
BuildLogger.log('4. 实施内容安全策略(CSP)');
|
||||
BuildLogger.log('5. 使用HTTPS部署');
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { checkDependencies, generateSecurityReport };
|
||||
module.exports = { checkDependencies, generateSecurityReport };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
const BuildLogger = require('./logger.js');
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -14,5 +16,5 @@ const netlifyToml = path.join(__dirname, '..', 'netlify.toml');
|
||||
console.error('netlify.toml 未将 publish 指向 dist');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ Netlify 配置检查通过 (publish=dist)');
|
||||
BuildLogger.success(' Netlify 配置检查通过 (publish=dist)');
|
||||
})();
|
||||
|
||||
128
scripts/update-script-logging.js
Normal file
128
scripts/update-script-logging.js
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 更新构建脚本中的console日志为BuildLogger
|
||||
* 仅替换普通的console.log,保留console.error和console.warn
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 需要处理的脚本文件
|
||||
const SCRIPTS_TO_UPDATE = [
|
||||
'build-static.js',
|
||||
'deploy-prepare.js',
|
||||
'deploy-analyze.js',
|
||||
'test-deploy-config.js',
|
||||
'optimize-images.js',
|
||||
'compress.js',
|
||||
'security-check.js'
|
||||
];
|
||||
|
||||
// 需要排除的文件
|
||||
const EXCLUDE_FILES = [
|
||||
'logger.js',
|
||||
'replace-console-log.js',
|
||||
'update-script-logging.js'
|
||||
];
|
||||
|
||||
function updateScriptLogging(filePath) {
|
||||
try {
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
const originalContent = content;
|
||||
|
||||
// 检查是否已经导入了BuildLogger
|
||||
const hasLoggerImport = /const\s+(?:BuildLogger|Logger)\s*=\s*require/.test(content);
|
||||
|
||||
// 检查是否有console.log需要替换
|
||||
const hasConsoleLog = /console\.log\s*\(/.test(content);
|
||||
|
||||
if (!hasConsoleLog) {
|
||||
console.log(`⏭️ 跳过 ${path.basename(filePath)} - 无console.log`);
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
// 替换console.log为BuildLogger.log
|
||||
// 识别带emoji的success消息
|
||||
content = content.replace(/console\.log\((['"`])✅([^'"`]*)\1\)/g, 'BuildLogger.success(\'$2\')');
|
||||
content = content.replace(/console\.log\((['"`])❌([^'"`]*)\1\)/g, 'BuildLogger.error(\'$2\')');
|
||||
content = content.replace(/console\.log\((['"`])⚠️([^'"`]*)\1\)/g, 'BuildLogger.warn(\'$2\')');
|
||||
content = content.replace(/console\.log\((['"`])🔧([^'"`]*)\1\)/g, 'BuildLogger.log(\'🔧$2\')');
|
||||
content = content.replace(/console\.log\((['"`])📊([^'"`]*)\1\)/g, 'BuildLogger.log(\'📊$2\')');
|
||||
|
||||
// 替换剩余的console.log
|
||||
content = content.replace(/console\.log\s*\(/g, 'BuildLogger.log(');
|
||||
|
||||
// 如果还没有导入BuildLogger,在文件开头添加导入
|
||||
if (!hasLoggerImport && hasConsoleLog) {
|
||||
// 在第一个require之后或文件开头添加
|
||||
const importStatement = `const BuildLogger = require('./logger.js');\n`;
|
||||
|
||||
if (/^const\s+/.test(content)) {
|
||||
// 在最后一个require之后添加
|
||||
const lastRequireMatch = content.match(/const\s+\w+\s*=\s*require\([^)]+\);?/g);
|
||||
if (lastRequireMatch && lastRequireMatch.length > 0) {
|
||||
const lastRequire = lastRequireMatch[lastRequireMatch.length - 1];
|
||||
const lastRequireIndex = content.lastIndexOf(lastRequire);
|
||||
const insertIndex = lastRequireIndex + lastRequire.length;
|
||||
content = content.slice(0, insertIndex) + '\n' + importStatement + content.slice(insertIndex);
|
||||
} else {
|
||||
content = importStatement + content;
|
||||
}
|
||||
} else {
|
||||
content = importStatement + '\n' + content;
|
||||
}
|
||||
}
|
||||
|
||||
if (content !== originalContent) {
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
const count = (originalContent.match(/console\.log\s*\(/g) || []).length;
|
||||
console.log(`✅ ${path.basename(filePath)} - 替换了${count}处console.log`);
|
||||
return { replaced: true, count };
|
||||
} else {
|
||||
console.log(`⏭️ 跳过 ${path.basename(filePath)} - 无需修改`);
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 处理 ${path.basename(filePath)} 失败:`, error.message);
|
||||
return { replaced: false, error: true };
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('🚀 开始更新构建脚本日志...\n');
|
||||
|
||||
let totalFiles = 0;
|
||||
let replacedFiles = 0;
|
||||
let totalReplacements = 0;
|
||||
|
||||
SCRIPTS_TO_UPDATE.forEach(filename => {
|
||||
const filePath = path.join(__dirname, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`⏭️ 跳过 ${filename} - 文件不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (EXCLUDE_FILES.includes(filename)) {
|
||||
console.log(`⏭️ 跳过 ${filename} - 已排除`);
|
||||
return;
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
const result = updateScriptLogging(filePath);
|
||||
if (result.replaced) {
|
||||
replacedFiles++;
|
||||
totalReplacements += result.count || 0;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n📊 替换统计:');
|
||||
console.log(` 总文件数: ${totalFiles}`);
|
||||
console.log(` 已修改文件: ${replacedFiles}`);
|
||||
console.log(` console.log替换数: ${totalReplacements}`);
|
||||
console.log('\n✨ 完成!');
|
||||
console.log('\n💡 提示: 构建脚本的日志现在使用带颜色的BuildLogger输出');
|
||||
}
|
||||
|
||||
main();
|
||||
17
server.js
17
server.js
@@ -9,16 +9,17 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const Logger = require('./src/js/modules/logger.js');
|
||||
require('dotenv').config();
|
||||
|
||||
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 || process.env.ESIM_ACCESS_KEY || '';
|
||||
const INTERNAL_FUNCTION_KEY = process.env.ACCESS_KEY || '';
|
||||
|
||||
// 启动时环境检查
|
||||
if (!INTERNAL_FUNCTION_KEY) {
|
||||
console.error('❌ ACCESS_KEY 或 ESIM_ACCESS_KEY 未配置');
|
||||
console.error('❌ ACCESS_KEY 未配置');
|
||||
console.error('💡 请在 .env 文件或环境变量中设置 ACCESS_KEY');
|
||||
console.error('⚠️ Netlify Functions 将无法正常工作,请修复后重启');
|
||||
}
|
||||
@@ -129,7 +130,7 @@ app.use('/.netlify/functions/giffgaff-sms-activate', wrapNetlifyFunction(giffgaf
|
||||
// Simyo API代理路由
|
||||
app.use('/api/simyo/*', (req, res) => {
|
||||
const targetUrl = `https://appapi.simyo.nl/simyoapi/api/v1${req.path.replace('/api/simyo', '')}`;
|
||||
console.log(`[Simyo Proxy] ${req.method} ${req.path} -> ${targetUrl}`);
|
||||
Logger.log(`[Simyo Proxy] ${req.method} ${req.path} -> ${targetUrl}`);
|
||||
|
||||
// 设置CORS头(仅允许指定域)
|
||||
res.header('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
|
||||
@@ -220,11 +221,11 @@ app.use((req, res) => {
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 eSIM工具服务器已启动`);
|
||||
console.log(`📍 本地地址: http://localhost:${PORT}`);
|
||||
console.log(`🔧 Giffgaff工具: http://localhost:${PORT}/giffgaff`);
|
||||
console.log(`📱 Simyo工具: http://localhost:${PORT}/simyo`);
|
||||
console.log(`🌐 环境: ${process.env.NODE_ENV || 'development'}`);
|
||||
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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Logger from '../../../js/modules/logger.js';
|
||||
|
||||
/**
|
||||
* MFA验证处理模块
|
||||
* 负责多因素认证流程
|
||||
@@ -41,7 +43,7 @@ export class MFAHandler {
|
||||
|
||||
// 检查令牌是否被刷新
|
||||
if (data._tokenRefreshed && data._newAccessToken) {
|
||||
console.log(t('giffgaff.mfa.log.tokenRefreshed'));
|
||||
Logger.log(t('giffgaff.mfa.log.tokenRefreshed'));
|
||||
stateManager.set('accessToken', data._newAccessToken);
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ export class MFAHandler {
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log(t('giffgaff.mfa.log.swapResponse'), responseData);
|
||||
Logger.log(t('giffgaff.mfa.log.swapResponse'), responseData);
|
||||
|
||||
if (responseData.errors) {
|
||||
throw new Error(responseData.errors[0].message || t('giffgaff.mfa.errors.genericSendFailed'));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Logger from '../../../js/modules/logger.js';
|
||||
|
||||
/**
|
||||
* OAuth处理模块
|
||||
* 负责OAuth 2.0 PKCE认证流程
|
||||
@@ -81,8 +83,8 @@ export class OAuthHandler {
|
||||
throw new Error(t('giffgaff.oauth.errors.missingCode'));
|
||||
}
|
||||
|
||||
console.log(t('giffgaff.oauth.log.codeFound'), code);
|
||||
console.log(t('giffgaff.oauth.log.stateFound'), state);
|
||||
Logger.log(t('giffgaff.oauth.log.codeFound'), code);
|
||||
Logger.log(t('giffgaff.oauth.log.stateFound'), state);
|
||||
|
||||
// 恢复code verifier
|
||||
let codeVerifier = stateManager.get('codeVerifier');
|
||||
|
||||
@@ -10,7 +10,7 @@ import { autoInjectFooter } from './modules/footer.js';
|
||||
// 入口脚本:避免冗余控制台输出
|
||||
|
||||
// 通过构建时注入的环境变量设置访问密钥(仅用于本站 Netlify Functions)
|
||||
window.ESIM_ACCESS_KEY = (typeof process !== 'undefined' && process.env && process.env.ESIM_ACCESS_KEY) ? process.env.ESIM_ACCESS_KEY : '';
|
||||
window.ACCESS_KEY = (typeof process !== 'undefined' && process.env && process.env.ACCESS_KEY) ? process.env.ACCESS_KEY : '';
|
||||
|
||||
// 注入 Turnstile site key(若存在则在页面加载后自动挂载 Turnstile)
|
||||
// 优先使用构建环境变量;未配置则使用提供的站点密钥
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Logger from './logger.js';
|
||||
|
||||
/**
|
||||
* API Service Layer - Provides a consistent interface for API calls
|
||||
* with built-in retry, caching, and error handling
|
||||
@@ -26,14 +28,14 @@ class APIService {
|
||||
if (method === 'GET' && this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < (options.cacheTime || 300000)) {
|
||||
console.log(`[API] Cache hit: ${endpoint}`);
|
||||
Logger.log(`[API] Cache hit: ${endpoint}`);
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate concurrent identical requests
|
||||
if (this.pendingRequests.has(cacheKey)) {
|
||||
console.log(`[API] Deduplicating request: ${endpoint}`);
|
||||
Logger.log(`[API] Deduplicating request: ${endpoint}`);
|
||||
return this.pendingRequests.get(cacheKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Logger from '../logger.js';
|
||||
|
||||
/**
|
||||
* Giffgaff API 交互模块
|
||||
*/
|
||||
@@ -29,7 +31,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} MFA Challenge Response
|
||||
*/
|
||||
async sendMFAChallenge(accessToken) {
|
||||
console.log('[API] sendMFAChallenge: start', { hasToken: !!accessToken });
|
||||
Logger.log('[API] sendMFAChallenge: start', { hasToken: !!accessToken });
|
||||
// 先检查令牌是否过期,如果过期则尝试使用cookie重新验证
|
||||
try {
|
||||
// 尝试使用现有令牌
|
||||
@@ -52,7 +54,7 @@ class APIManager {
|
||||
// 如果响应成功,直接返回结果
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
console.log('[API] sendMFAChallenge: ok', { hasRef: !!json?.ref });
|
||||
Logger.log('[API] sendMFAChallenge: ok', { hasRef: !!json?.ref });
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -68,13 +70,13 @@ class APIManager {
|
||||
// 尝试使用本地存储的cookie重新验证
|
||||
const cookie = localStorage.getItem('giffgaff_cookie');
|
||||
if (cookie) {
|
||||
console.log('尝试使用cookie重新验证');
|
||||
Logger.log('尝试使用cookie重新验证');
|
||||
const cookieVerifyResult = await this.verifyCookie(cookie);
|
||||
|
||||
if (cookieVerifyResult.success && cookieVerifyResult.accessToken) {
|
||||
// 更新全局令牌
|
||||
const newAccessToken = cookieVerifyResult.accessToken;
|
||||
console.log('使用cookie重新验证成功,更新令牌');
|
||||
Logger.log('使用cookie重新验证成功,更新令牌');
|
||||
|
||||
// 使用新令牌重新发送MFA请求
|
||||
const newResponse = await fetch(this.endpoints.mfaChallenge, {
|
||||
@@ -126,7 +128,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} MFA Validation Response
|
||||
*/
|
||||
async validateMFACode(accessToken, ref, code) {
|
||||
console.log('[API] validateMFACode: start', { hasToken: !!accessToken, ref: String(ref||'').slice(0,6)+'...' });
|
||||
Logger.log('[API] validateMFACode: start', { hasToken: !!accessToken, ref: String(ref||'').slice(0,6)+'...' });
|
||||
const response = await fetch(this.endpoints.mfaValidation, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -147,7 +149,7 @@ class APIManager {
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('[API] validateMFACode: ok', { hasSignature: !!json?.signature });
|
||||
Logger.log('[API] validateMFACode: ok', { hasSignature: !!json?.signature });
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -158,7 +160,7 @@ class APIManager {
|
||||
* - 输出: { success, lpaString, token, ssn, activationCode }
|
||||
*/
|
||||
async smsActivateFlow({ ref, code, accessToken, cookie, memberId, ssn, activationCode }) {
|
||||
console.log('[API] smsActivateFlow: start', { hasToken: !!accessToken, hasCookie: !!cookie, ref: String(ref||'').slice(0,6)+'...' });
|
||||
Logger.log('[API] smsActivateFlow: start', { hasToken: !!accessToken, hasCookie: !!cookie, ref: String(ref||'').slice(0,6)+'...' });
|
||||
const resp = await fetch(this.endpoints.smsActivate, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||
@@ -169,7 +171,7 @@ class APIManager {
|
||||
throw new Error(`SMS activate flow failed: ${resp.status} - ${t}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
console.log('[API] smsActivateFlow: ok', { success: !!json?.success, hasLpa: !!json?.lpaString });
|
||||
Logger.log('[API] smsActivateFlow: ok', { success: !!json?.success, hasLpa: !!json?.lpaString });
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -184,7 +186,7 @@ class APIManager {
|
||||
async graphqlQuery(accessToken, query, variables = {}, mfaSignature = null) {
|
||||
const opMatch = String(query||'').match(/(query|mutation)\s+(\w+)/i);
|
||||
const op = opMatch ? opMatch[2] : 'Unknown';
|
||||
console.log('[API] graphqlQuery: start', { op, hasToken: !!accessToken, hasSignature: !!mfaSignature });
|
||||
Logger.log('[API] graphqlQuery: start', { op, hasToken: !!accessToken, hasSignature: !!mfaSignature });
|
||||
const response = await fetch(this.endpoints.graphql, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
|
||||
@@ -213,7 +215,7 @@ class APIManager {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[API] graphqlQuery: ok', { op, hasErrors: !!data?.errors });
|
||||
Logger.log('[API] graphqlQuery: ok', { op, hasErrors: !!data?.errors });
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
||||
@@ -229,7 +231,7 @@ class APIManager {
|
||||
* @param {string} redirectUri 可选
|
||||
*/
|
||||
async exchangeTokenServerSide(code, codeVerifier, redirectUri) {
|
||||
console.log('[API] tokenExchange: start');
|
||||
Logger.log('[API] tokenExchange: start');
|
||||
const response = await fetch(this.endpoints.tokenExchange, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -240,7 +242,7 @@ class APIManager {
|
||||
throw new Error(`Server token exchange failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
console.log('[API] tokenExchange: ok');
|
||||
Logger.log('[API] tokenExchange: ok');
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -250,7 +252,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} Member Profile
|
||||
*/
|
||||
async getMemberProfile(accessToken) {
|
||||
console.log('[API] getMemberProfile: start', { hasToken: !!accessToken });
|
||||
Logger.log('[API] getMemberProfile: start', { hasToken: !!accessToken });
|
||||
const query = `
|
||||
query getMemberProfileAndSim {
|
||||
memberProfile {
|
||||
@@ -267,7 +269,7 @@ class APIManager {
|
||||
`;
|
||||
|
||||
const res = await this.graphqlQuery(accessToken, query);
|
||||
console.log('[API] getMemberProfile: ok', { hasMember: !!res?.data?.memberProfile });
|
||||
Logger.log('[API] getMemberProfile: ok', { hasMember: !!res?.data?.memberProfile });
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -279,7 +281,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} Reserve eSIM Response
|
||||
*/
|
||||
async reserveESIM(accessToken, memberId, mfaSignature) {
|
||||
console.log('[API] reserveESIM: start', { hasToken: !!accessToken, hasSignature: !!mfaSignature });
|
||||
Logger.log('[API] reserveESIM: start', { hasToken: !!accessToken, hasSignature: !!mfaSignature });
|
||||
const query = `
|
||||
mutation reserveESim($input: ESimReservationInput!) {
|
||||
reserveESim: reserveESim(input: $input) {
|
||||
@@ -315,7 +317,7 @@ class APIManager {
|
||||
localStorage.setItem('gg_esim_ssn', esim.ssn || '');
|
||||
}
|
||||
} catch (_) {}
|
||||
console.log('[API] reserveESIM: ok', { hasESIM: !!data?.data?.reserveESim?.esim });
|
||||
Logger.log('[API] reserveESIM: ok', { hasESIM: !!data?.data?.reserveESim?.esim });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -326,7 +328,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} eSIM Token Response
|
||||
*/
|
||||
async getESIMToken(accessToken, ssn) {
|
||||
console.log('[API] getESIMToken: start', { hasToken: !!accessToken, hasSSN: !!ssn });
|
||||
Logger.log('[API] getESIMToken: start', { hasToken: !!accessToken, hasSSN: !!ssn });
|
||||
const query = `
|
||||
query eSimDownloadToken($ssn: String!) {
|
||||
eSimDownloadToken(ssn: $ssn) {
|
||||
@@ -348,7 +350,7 @@ class APIManager {
|
||||
const lpa = data?.data?.eSimDownloadToken?.lpaString;
|
||||
if (lpa) localStorage.setItem('gg_esim_lpa', lpa);
|
||||
} catch (_) {}
|
||||
console.log('[API] getESIMToken: ok', { hasLPA: !!data?.data?.eSimDownloadToken?.lpaString });
|
||||
Logger.log('[API] getESIMToken: ok', { hasLPA: !!data?.data?.eSimDownloadToken?.lpaString });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -358,7 +360,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} Cookie Verification Response
|
||||
*/
|
||||
async verifyCookie(cookie) {
|
||||
console.log('[API] verifyCookie: start', { hasCookie: !!cookie });
|
||||
Logger.log('[API] verifyCookie: start', { hasCookie: !!cookie });
|
||||
const response = await fetch(this.endpoints.cookieVerify, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -376,7 +378,7 @@ class APIManager {
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('[API] verifyCookie: ok', { success: !!json?.success });
|
||||
Logger.log('[API] verifyCookie: ok', { success: !!json?.success });
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -386,7 +388,7 @@ class APIManager {
|
||||
* @returns {Promise<Object>} Auto Activation Response
|
||||
*/
|
||||
async autoActivateESIM(activationCode) {
|
||||
console.log('[API] autoActivateESIM: start', { hasCode: !!activationCode });
|
||||
Logger.log('[API] autoActivateESIM: start', { hasCode: !!activationCode });
|
||||
const response = await fetch(this.endpoints.autoActivate, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -404,7 +406,7 @@ class APIManager {
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('[API] autoActivateESIM: ok', { success: !!json?.success });
|
||||
Logger.log('[API] autoActivateESIM: ok', { success: !!json?.success });
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Logger from '../logger.js';
|
||||
|
||||
/**
|
||||
* Giffgaff OAuth 2.0 PKCE 认证模块
|
||||
*/
|
||||
@@ -121,7 +123,7 @@ class OAuthManager {
|
||||
*/
|
||||
// 前端不再直接持有 client_secret,改由服务端函数代为交换
|
||||
async exchangeToken(code, codeVerifier) {
|
||||
console.log(`Sending token exchange request: code=${code.substring(0, 3)}*****, code_verifier=${codeVerifier.substring(0, 3)}*****`);
|
||||
Logger.log(`Sending token exchange request: code=${code.substring(0, 3)}*****, code_verifier=${codeVerifier.substring(0, 3)}*****`);
|
||||
// 等待 Turnstile token(最多等待 ~2.5s)
|
||||
let tsToken = this.getTurnstileToken();
|
||||
for (let i = 0; i < 5 && !tsToken; i++) {
|
||||
|
||||
195
src/js/modules/html-sanitizer.js
Normal file
195
src/js/modules/html-sanitizer.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* HTML 清理和转义工具模块
|
||||
* 防御 XSS 攻击
|
||||
*/
|
||||
|
||||
class HTMLSanitizer {
|
||||
/**
|
||||
* 转义 HTML 特殊字符
|
||||
* @param {string} unsafe - 不安全的字符串
|
||||
* @returns {string} 转义后的安全字符串
|
||||
*/
|
||||
static escapeHtml(unsafe) {
|
||||
if (unsafe === null || unsafe === undefined) return '';
|
||||
|
||||
return String(unsafe)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/'); // 额外转义斜杠
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 HTML 属性值
|
||||
* @param {string} value - 属性值
|
||||
* @returns {string} 转义后的属性值
|
||||
*/
|
||||
static escapeAttr(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除所有 HTML 标签
|
||||
* @param {string} html - 包含 HTML 的字符串
|
||||
* @returns {string} 纯文本
|
||||
*/
|
||||
static stripTags(html) {
|
||||
if (html === null || html === undefined) return '';
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.textContent = html;
|
||||
return temp.textContent || temp.innerText || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 HTML,仅保留安全标签
|
||||
* @param {string} html - 待清理的 HTML
|
||||
* @param {string[]} allowedTags - 允许的标签列表
|
||||
* @returns {string} 清理后的 HTML
|
||||
*/
|
||||
static sanitize(html, allowedTags = ['b', 'i', 'em', 'strong', 'span']) {
|
||||
if (html === null || html === undefined) return '';
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
|
||||
// 递归清理节点
|
||||
const cleanNode = (node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
|
||||
// 如果不在白名单中,替换为文本内容
|
||||
if (!allowedTags.includes(tagName)) {
|
||||
const textNode = document.createTextNode(node.textContent);
|
||||
return textNode;
|
||||
}
|
||||
|
||||
// 移除所有属性(防止 on* 事件处理器)
|
||||
Array.from(node.attributes).forEach(attr => {
|
||||
node.removeAttribute(attr.name);
|
||||
});
|
||||
|
||||
// 递归清理子节点
|
||||
Array.from(node.childNodes).forEach(child => {
|
||||
const cleaned = cleanNode(child);
|
||||
if (cleaned !== child) {
|
||||
node.replaceChild(cleaned, child);
|
||||
}
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// 移除注释等其他节点
|
||||
return document.createTextNode('');
|
||||
};
|
||||
|
||||
Array.from(temp.childNodes).forEach(child => {
|
||||
cleanNode(child);
|
||||
});
|
||||
|
||||
return temp.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地设置 innerHTML
|
||||
* @param {HTMLElement} element - 目标元素
|
||||
* @param {string} html - HTML 内容
|
||||
* @param {string[]} allowedTags - 允许的标签
|
||||
*/
|
||||
static setInnerHTML(element, html, allowedTags) {
|
||||
if (!element) {
|
||||
console.error('[HTMLSanitizer] Invalid element');
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = this.sanitize(html, allowedTags);
|
||||
element.innerHTML = sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建安全的 DOM 节点
|
||||
* @param {string} tag - 标签名
|
||||
* @param {Object} attributes - 属性对象
|
||||
* @param {string|HTMLElement[]} children - 子元素
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
static createElement(tag, attributes = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
// 设置属性(自动转义)
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
// 禁止设置事件处理器
|
||||
if (key.startsWith('on')) {
|
||||
console.warn(`[HTMLSanitizer] Blocked event handler: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 转义属性值
|
||||
element.setAttribute(key, this.escapeAttr(value));
|
||||
});
|
||||
|
||||
// 添加子元素
|
||||
const childArray = Array.isArray(children) ? children : [children];
|
||||
childArray.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else if (child instanceof HTMLElement) {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URL 安全性
|
||||
* @param {string} url - 待验证的 URL
|
||||
* @returns {boolean} 是否安全
|
||||
*/
|
||||
static isSafeURL(url) {
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
// 仅允许 http(s) 和 data: 协议
|
||||
const safeProtocols = ['http:', 'https:', 'data:'];
|
||||
return safeProtocols.includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 URL(移除 javascript: 等危险协议)
|
||||
* @param {string} url - 待清理的 URL
|
||||
* @returns {string} 清理后的 URL
|
||||
*/
|
||||
static sanitizeURL(url) {
|
||||
if (!url) return '';
|
||||
|
||||
// 移除 javascript:, data:text/html 等危险协议
|
||||
const dangerousProtocols = /^(javascript|data:text\/html|vbscript):/i;
|
||||
if (dangerousProtocols.test(url)) {
|
||||
console.warn('[HTMLSanitizer] Blocked dangerous URL:', url);
|
||||
return '#';
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export default HTMLSanitizer;
|
||||
102
src/js/modules/logger.js
Normal file
102
src/js/modules/logger.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 日志工具模块
|
||||
* 生产环境禁用console.log,开发环境保留
|
||||
*/
|
||||
|
||||
const isDev = typeof process !== 'undefined' ?
|
||||
process.env.NODE_ENV === 'development' :
|
||||
(typeof window !== 'undefined' && window.location.hostname === 'localhost');
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
* 信息日志(生产环境禁用)
|
||||
*/
|
||||
static log(...args) {
|
||||
if (isDev) {
|
||||
console.log('[INFO]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志(生产环境保留)
|
||||
*/
|
||||
static warn(...args) {
|
||||
console.warn('[WARN]', ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志(生产环境保留)
|
||||
*/
|
||||
static error(...args) {
|
||||
console.error('[ERROR]', ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志(生产环境禁用)
|
||||
*/
|
||||
static debug(...args) {
|
||||
if (isDev) {
|
||||
console.log('[DEBUG]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感数据脱敏记录
|
||||
* @param {string} label - 标签
|
||||
* @param {string} value - 敏感值
|
||||
* @param {number} visibleChars - 可见字符数
|
||||
*/
|
||||
static sensitive(label, value, visibleChars = 5) {
|
||||
if (!isDev) return;
|
||||
|
||||
if (!value || typeof value !== 'string') {
|
||||
console.log(`[SENSITIVE] ${label}: (empty)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const masked = value.length > visibleChars ?
|
||||
`${value.substring(0, visibleChars)}${'*'.repeat(Math.min(value.length - visibleChars, 20))}` :
|
||||
'***';
|
||||
|
||||
console.log(`[SENSITIVE] ${label}: ${masked} (length: ${value.length})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能计时
|
||||
* @param {string} label - 计时标签
|
||||
*/
|
||||
static time(label) {
|
||||
if (isDev) {
|
||||
console.time(label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束计时
|
||||
* @param {string} label - 计时标签
|
||||
*/
|
||||
static timeEnd(label) {
|
||||
if (isDev) {
|
||||
console.timeEnd(label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格输出
|
||||
* @param {Array|Object} data - 数据
|
||||
*/
|
||||
static table(data) {
|
||||
if (isDev && console.table) {
|
||||
console.table(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = Logger;
|
||||
} else if (typeof window !== 'undefined') {
|
||||
window.Logger = Logger;
|
||||
}
|
||||
|
||||
export default Logger;
|
||||
@@ -1,3 +1,5 @@
|
||||
import Logger from './logger.js';
|
||||
|
||||
/**
|
||||
* Performance Monitoring Utility
|
||||
* Tracks and reports Core Web Vitals and custom metrics
|
||||
@@ -208,7 +210,7 @@ class PerformanceMonitor {
|
||||
|
||||
// Log in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[Performance] ${name}:`, data);
|
||||
Logger.log(`[Performance] ${name}:`, data);
|
||||
}
|
||||
|
||||
// Send to analytics service
|
||||
|
||||
161
src/js/modules/secure-storage.js
Normal file
161
src/js/modules/secure-storage.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import Logger from './logger.js';
|
||||
|
||||
/**
|
||||
* 安全存储工具模块
|
||||
* 使用 sessionStorage 替代 localStorage,并添加过期时间机制
|
||||
* 防御 XSS 攻击窃取敏感数据
|
||||
*/
|
||||
|
||||
class SecureStorage {
|
||||
constructor() {
|
||||
// 优先使用 sessionStorage (关闭浏览器即清除)
|
||||
// 若需跨标签页共享,可使用 localStorage,但必须加过期时间
|
||||
this.storage = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
|
||||
this.fallbackStorage = new Map(); // 内存降级
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置带过期时间的数据
|
||||
* @param {string} key - 键名
|
||||
* @param {any} value - 值
|
||||
* @param {number} ttl - 过期时间(毫秒),默认1小时
|
||||
*/
|
||||
setItem(key, value, ttl = 3600000) {
|
||||
const data = {
|
||||
value,
|
||||
expires: Date.now() + ttl,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(data);
|
||||
if (this.storage) {
|
||||
this.storage.setItem(key, serialized);
|
||||
} else {
|
||||
this.fallbackStorage.set(key, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] setItem failed:', error.message);
|
||||
// 降级到内存存储
|
||||
this.fallbackStorage.set(key, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据,自动检查过期时间
|
||||
* @param {string} key - 键名
|
||||
* @returns {any|null} 未过期的值,或 null
|
||||
*/
|
||||
getItem(key) {
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (this.storage) {
|
||||
const serialized = this.storage.getItem(key);
|
||||
if (!serialized) return null;
|
||||
data = JSON.parse(serialized);
|
||||
} else {
|
||||
data = this.fallbackStorage.get(key);
|
||||
if (!data) return null;
|
||||
}
|
||||
|
||||
// 检查过期时间
|
||||
if (data.expires && Date.now() > data.expires) {
|
||||
this.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.value;
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] getItem failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除数据
|
||||
* @param {string} key - 键名
|
||||
*/
|
||||
removeItem(key) {
|
||||
try {
|
||||
if (this.storage) {
|
||||
this.storage.removeItem(key);
|
||||
}
|
||||
this.fallbackStorage.delete(key);
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] removeItem failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有数据
|
||||
*/
|
||||
clear() {
|
||||
try {
|
||||
if (this.storage) {
|
||||
this.storage.clear();
|
||||
}
|
||||
this.fallbackStorage.clear();
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] clear failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据是否存在且未过期
|
||||
* @param {string} key - 键名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
return this.getItem(key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移 localStorage 中的旧数据到 sessionStorage
|
||||
* @param {string} key - 键名
|
||||
*/
|
||||
migrateFromLocalStorage(key) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
try {
|
||||
const oldValue = localStorage.getItem(key);
|
||||
if (oldValue) {
|
||||
// 迁移数据(不带过期时间,视为短期数据)
|
||||
this.setItem(key, oldValue, 1800000); // 30分钟
|
||||
// 清除旧数据
|
||||
localStorage.removeItem(key);
|
||||
Logger.log(`[SecureStorage] Migrated ${key} from localStorage`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SecureStorage] Migration failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量迁移旧数据
|
||||
* @param {string[]} keys - 键名数组
|
||||
*/
|
||||
migrateAll(keys) {
|
||||
keys.forEach(key => this.migrateFromLocalStorage(key));
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const secureStorage = new SecureStorage();
|
||||
|
||||
// 自动迁移已知的敏感数据键
|
||||
if (typeof window !== 'undefined') {
|
||||
const SENSITIVE_KEYS = [
|
||||
'giffgaff_cookie',
|
||||
'gg_esim_activationCode',
|
||||
'gg_esim_lpa',
|
||||
'gg_esim_iccid',
|
||||
'gg_esim_eid',
|
||||
'gg_access_token'
|
||||
];
|
||||
|
||||
// 页面加载时自动迁移
|
||||
secureStorage.migrateAll(SENSITIVE_KEYS);
|
||||
}
|
||||
|
||||
export default secureStorage;
|
||||
@@ -85,7 +85,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.ESIM_ACCESS_KEY': JSON.stringify(process.env.ESIM_ACCESS_KEY || ''),
|
||||
'process.env.ACCESS_KEY': JSON.stringify(process.env.ACCESS_KEY || ''),
|
||||
'process.env.TURNSTILE_SITE_KEY': JSON.stringify(process.env.TURNSTILE_SITE_KEY || ''),
|
||||
}),
|
||||
new CompressionPlugin({
|
||||
|
||||
Reference in New Issue
Block a user