Files
eSIM-Tools/netlify/functions/giffgaff-sms-activate.js
Abner 4a0fcc94fe feat: 重构Netlify Functions架构并增强代码质量
- 新增统一的中间件模块,提供鉴权、CORS和错误处理功能
- 重构所有Functions使用withAuth中间件简化代码结构
- 添加安全存储模块替代localStorage,防御XSS攻击
- 引入HTML清理工具,自动转义特殊字符和验证URL安全性
- 创建代码质量检查脚本,验证语法、环境变量和依赖完整性
- 添加构建日志工具和重构脚本,统一替换console.log为Logger
- 引入ESLint配置,提升代码质量和一致性
- 重构Giffgaff相关API,统一错误处理和验证逻辑
- 优化构建脚本,添加压缩和图片优化功能
- 新增健康检查端点,用于服务监控和状态报告
2025-11-23 22:20:02 +08:00

252 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Netlify Function: Giffgaff SMS Activate (end-to-end)
* 输入短信验证码后,后台自动完成MFA校验 → 预订eSIM如需→ 网页激活 → 轮询获取LPA
*/
const axios = require('axios');
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
// 输入验证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 });