diff --git a/netlify/edge-functions/bff-proxy.js b/netlify/edge-functions/bff-proxy.js index 6d436df..a6cecb4 100644 --- a/netlify/edge-functions/bff-proxy.js +++ b/netlify/edge-functions/bff-proxy.js @@ -12,6 +12,7 @@ const BFF_ROUTES = new Map([ ['giffgaff-mfa-validation', ['POST', 'OPTIONS']], ['giffgaff-sms-activate', ['POST', 'OPTIONS']], ['auto-activate-esim', ['POST', 'OPTIONS']], + ['qrcode-generate', ['POST', 'OPTIONS']], ['verify-cookie', ['POST', 'OPTIONS']], ['public-config', ['GET', 'OPTIONS']] ]); diff --git a/netlify/functions/qrcode-generate.js b/netlify/functions/qrcode-generate.js new file mode 100644 index 0000000..ba7c9a6 --- /dev/null +++ b/netlify/functions/qrcode-generate.js @@ -0,0 +1,79 @@ +'use strict'; + +const QRCode = require('qrcode'); +const { withAuth, validateInput, AuthError } = require('./_shared/middleware'); + +const DEFAULT_QR_SIZE = 300; +const MIN_QR_SIZE = 200; +const MAX_QR_SIZE = 600; +const QR_TIMEOUT_MS = 10000; + +const qrcodeSchema = { + data: { + required: true, + type: 'string', + minLength: 1, + maxLength: 2048 + }, + size: { + required: false, + type: 'number' + } +}; + +function normalizeSize(size = DEFAULT_QR_SIZE) { + const normalizedSize = Number(size); + + if (!Number.isInteger(normalizedSize)) { + throw new AuthError('size must be an integer', 400); + } + + if (normalizedSize < MIN_QR_SIZE || normalizedSize > MAX_QR_SIZE) { + throw new AuthError(`size must be between ${MIN_QR_SIZE} and ${MAX_QR_SIZE}`, 400); + } + + return normalizedSize; +} + +function withTimeout(promise, timeoutMs) { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`QR code generation timeout after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)); +} + +exports.handler = withAuth(async (event, context, { body }) => { + if (event.httpMethod !== 'POST') { + throw new AuthError('Method Not Allowed', 405); + } + + // withAuth 不在这里使用 validateSchema,确保非 POST 请求先返回 405。 + validateInput(qrcodeSchema, body); + + const size = normalizeSize(body.size); + + try { + const qrcode = await withTimeout(QRCode.toDataURL(body.data, { + errorCorrectionLevel: 'M', + margin: 2, + type: 'image/png', + width: size + }), QR_TIMEOUT_MS); + + return { + statusCode: 200, + body: JSON.stringify({ + success: true, + qrcode + }) + }; + } catch (error) { + // 不记录 data,避免 LPA 激活信息进入日志或 Sentry。 + console.error('[qrcode-generate] QR code generation failed:', error.message); + throw error; + } +}); diff --git a/package-lock.json b/package-lock.json index 826bcd1..dae495b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "dotenv": "^16.3.1", "express": "^4.22.2", "helmet": "^7.1.0", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@babel/core": "^7.23.0", @@ -149,6 +150,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1995,6 +1997,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2018,6 +2021,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4118,6 +4122,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4139,6 +4144,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -4151,6 +4157,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4574,6 +4581,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4590,6 +4598,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/resources": "2.5.1", @@ -4607,6 +4616,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -5381,8 +5391,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6131,6 +6140,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6179,6 +6189,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6241,7 +6252,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6251,7 +6261,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -6762,6 +6771,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6864,7 +6874,6 @@ "version": "5.3.1", "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7095,7 +7104,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -7108,7 +7116,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -7681,6 +7688,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -7817,13 +7833,18 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -7952,7 +7973,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -8930,7 +8950,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9766,7 +9785,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12297,6 +12315,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12328,6 +12347,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12577,7 +12597,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13199,7 +13218,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13306,7 +13324,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13547,6 +13564,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13577,6 +13603,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14335,7 +14362,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -14351,7 +14377,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -14433,6 +14458,141 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -14489,8 +14649,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/read-cache": { "version": "1.0.0", @@ -14648,7 +14807,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14677,6 +14835,12 @@ "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -14734,6 +14898,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14949,6 +15114,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15336,7 +15507,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -15453,7 +15623,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -15849,6 +16018,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16377,6 +16547,7 @@ "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16426,6 +16597,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -16627,6 +16799,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index ddaad0a..7f53376 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "dotenv": "^16.3.1", "express": "^4.22.2", "helmet": "^7.1.0", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "qrcode": "^1.5.4" }, "devDependencies": { "@babel/core": "^7.23.0", diff --git a/server.js b/server.js index 6d75993..68bfc71 100644 --- a/server.js +++ b/server.js @@ -103,6 +103,7 @@ const giffgaffTokenExchange = require('./netlify/functions/giffgaff-token-exchan const verifyCookie = require('./netlify/functions/verify-cookie'); const giffgaffSmsActivate = require('./netlify/functions/giffgaff-sms-activate'); const autoActivateEsim = require('./netlify/functions/auto-activate-esim'); +const qrcodeGenerate = require('./netlify/functions/qrcode-generate'); const publicConfig = require('./netlify/functions/public-config'); // 包装Netlify Functions为Express路由 @@ -157,6 +158,7 @@ const functionRoutes = [ ['verify-cookie', verifyCookie], ['giffgaff-sms-activate', giffgaffSmsActivate], ['auto-activate-esim', autoActivateEsim], + ['qrcode-generate', qrcodeGenerate], ['public-config', publicConfig] ]; app.locals.bffRoutes = functionRoutes.map(([name]) => `/bff/${name}`); diff --git a/src/giffgaff/js/modules/ui-controller.js b/src/giffgaff/js/modules/ui-controller.js index 5b78dd7..7c67cfd 100644 --- a/src/giffgaff/js/modules/ui-controller.js +++ b/src/giffgaff/js/modules/ui-controller.js @@ -5,6 +5,7 @@ import { stateManager } from './state-manager.js'; import { t, tl } from '../../../js/modules/i18n.js'; +import { generateQRCodeWithFallback } from '../../../js/modules/qrcode-generator.js'; export class UIController { constructor() { @@ -416,118 +417,40 @@ export class UIController { } /** - * 生成二维码(issue #66: 添加超时处理,长时间加载失败时显示 LPA 字符串) + * 生成二维码(本地生成优先,后端 Function 降级) */ - generateQRCode(data) { + async generateQRCode(data) { const size = 300; - const QR_TIMEOUT_MS = 5000; // 单个服务商超时5秒 - // 清除之前的超时定时器,防止旧 timer 覆盖新二维码 + // 清除旧的超时定时器,避免历史调用覆盖当前二维码区域。 if (this.qrTimeoutId) { clearTimeout(this.qrTimeoutId); this.qrTimeoutId = null; } - // generation counter:防止并发调用时旧回调干扰新调用 const gen = ++this._qrGeneration; - const vendors = [ - (s, d) => `https://qrcode.show/${encodeURIComponent(d)}?size=${s}`, - (s, d) => `https://quickchart.io/qr?size=${s}&text=${encodeURIComponent(d)}`, - (s, d) => `https://api.qrserver.com/v1/create-qr-code/?size=${s}x${s}&data=${encodeURIComponent(d)}` - ]; - let vendorIdx = 0; - let isResolved = false; // 防止超时和成功回调同时触发 + try { + const result = await generateQRCodeWithFallback(data, size); + if (gen !== this._qrGeneration) return; // 防止并发调用干扰 - const container = document.createElement('div'); - container.className = 'qrcode-container'; - container.style.position = 'relative'; - container.style.display = 'inline-block'; - - const img = document.createElement('img'); - const setSrc = () => { img.src = vendors[vendorIdx](size, data); }; - setSrc(); - img.setAttribute('loading','lazy'); - img.alt = tl('eSIM二维码'); - img.className = 'img-fluid'; - img.style.border = '5px solid white'; - img.style.borderRadius = '12px'; - img.style.maxWidth = `${size}px`; - - // 超时处理:二维码长时间加载失败时,显示 LPA 字符串提示 - const startTimeout = () => { - clearTimeout(this.qrTimeoutId); - this.qrTimeoutId = setTimeout(() => { - // 如果已被新调用取代,忽略本次超时 - if (gen !== this._qrGeneration) return; - if (!isResolved) { - isResolved = true; - this.qrTimeoutId = null; - console.warn('[Giffgaff] QR code generation timed out'); - this.elements.qrcode.innerHTML = ` -
- - ${t('giffgaff.app.qr.timeout')} -
- `; - } - }, QR_TIMEOUT_MS); - }; - - startTimeout(); - - img.onload = () => { - if (gen !== this._qrGeneration) return; - if (!isResolved) { - isResolved = true; - clearTimeout(this.qrTimeoutId); - this.qrTimeoutId = null; + if (result.tooltip && typeof this.showTooltipElement === 'function' && typeof this.hideTooltipElement === 'function') { + result.container.addEventListener('mouseenter', (event) => this.showTooltipElement(result.tooltip, event)); + result.container.addEventListener('mouseleave', () => this.hideTooltipElement(result.tooltip)); } - }; - img.onerror = () => { + this.elements.qrcode.innerHTML = ''; + this.elements.qrcode.appendChild(result.container); + } catch (error) { if (gen !== this._qrGeneration) return; - if (isResolved) return; - if (vendorIdx < vendors.length - 1) { - // 回退到下一个服务,重置超时计时器 - vendorIdx += 1; - console.log('[Giffgaff] QR vendor fallback to:', vendorIdx + 1, '/', vendors.length); - startTimeout(); - setSrc(); - } else { - isResolved = true; - clearTimeout(this.qrTimeoutId); - this.qrTimeoutId = null; - this.elements.qrcode.innerHTML = ` -
- - ${t('giffgaff.app.qr.failed')} -
- `; - } - }; - - const tooltip = document.createElement('div'); - tooltip.className = 'tooltip'; - tooltip.style.padding = '0'; - tooltip.style.background = 'none'; - tooltip.style.boxShadow = 'none'; - tooltip.style.willChange = 'transform'; - - const largeImg = document.createElement('img'); - largeImg.style.width = '400px'; - largeImg.style.height = '400px'; - const setLargeSrc = () => { largeImg.src = vendors[vendorIdx](400, data); }; - setLargeSrc(); - tooltip.appendChild(largeImg); - - container.addEventListener('mouseenter', (e) => this.showTooltipElement(tooltip, e)); - container.addEventListener('mouseleave', () => this.hideTooltipElement(tooltip)); - - container.appendChild(img); - container.appendChild(tooltip); - this.elements.qrcode.innerHTML = ''; - this.elements.qrcode.appendChild(container); + console.error('[Giffgaff] QR code generation failed:', error); + this.elements.qrcode.innerHTML = ` +
+ + ${t('giffgaff.app.qr.failed')} +
+ `; + } } /** diff --git a/src/js/modules/qrcode-generator.js b/src/js/modules/qrcode-generator.js new file mode 100644 index 0000000..9293d37 --- /dev/null +++ b/src/js/modules/qrcode-generator.js @@ -0,0 +1,252 @@ +'use strict'; + +const QRCODE_CDN_URL = 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js'; +const DEFAULT_QR_SIZE = 300; +const MIN_QR_SIZE = 200; +const MAX_QR_SIZE = 600; +const MAX_QR_DATA_LENGTH = 2048; +const BACKEND_ENDPOINT = '/bff/qrcode-generate'; +const BACKEND_TIMEOUT_MS = 10000; + +let qrCodeLibraryPromise = null; + +/** + * 标准化二维码尺寸,避免生成过大图片拖慢页面。 + * @param {number} [size=300] 二维码尺寸,单位 px + * @returns {number} 安全的二维码尺寸 + * @throws {Error} 尺寸不合法时抛出 + */ +function normalizeQRCodeSize(size = DEFAULT_QR_SIZE) { + const normalizedSize = Number(size); + + if (!Number.isInteger(normalizedSize)) { + throw new Error('QR code size must be an integer'); + } + + if (normalizedSize < MIN_QR_SIZE || normalizedSize > MAX_QR_SIZE) { + throw new Error(`QR code size must be between ${MIN_QR_SIZE} and ${MAX_QR_SIZE}`); + } + + return normalizedSize; +} + +/** + * 校验二维码内容,防止空值和异常大 payload 进入生成流程。 + * @param {string} data 二维码内容 + * @returns {string} 校验后的二维码内容 + * @throws {Error} 内容不合法时抛出 + */ +function validateQRCodeData(data) { + if (typeof data !== 'string') { + throw new Error('QR code data must be a string'); + } + + if (data.length < 1 || data.length > MAX_QR_DATA_LENGTH) { + throw new Error(`QR code data length must be between 1 and ${MAX_QR_DATA_LENGTH}`); + } + + return data; +} + +/** + * 懒加载浏览器端 qrcode.js 库。 + * 首次调用会插入 CDN script,后续调用复用同一个 Promise,避免重复加载。 + * @returns {Promise} qrcode.js 暴露的 QRCode 对象 + */ +export async function loadQRCodeLibrary() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + throw new Error('QRCode local generation requires a browser environment'); + } + + if (window.QRCode) { + return window.QRCode; + } + + if (qrCodeLibraryPromise) { + return qrCodeLibraryPromise; + } + + qrCodeLibraryPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector(`script[src="${QRCODE_CDN_URL}"]`); + const script = existingScript || document.createElement('script'); + + const handleLoad = () => { + if (window.QRCode) { + resolve(window.QRCode); + return; + } + qrCodeLibraryPromise = null; + reject(new Error('QRCode library loaded but QRCode global is missing')); + }; + + const handleError = () => { + qrCodeLibraryPromise = null; + reject(new Error('Failed to load QRCode library from CDN')); + }; + + script.addEventListener('load', handleLoad, { once: true }); + script.addEventListener('error', handleError, { once: true }); + + if (!existingScript) { + script.src = QRCODE_CDN_URL; + script.async = true; + script.crossOrigin = 'anonymous'; + document.head.appendChild(script); + } + }); + + return qrCodeLibraryPromise; +} + +/** + * 创建可直接插入页面的二维码 DOM 容器。 + * 使用 img data URL 是为了兼容现有下载逻辑(调用方会 querySelector('img'))。 + * @param {string} imageUrl 二维码图片 data URL + * @param {number} size 二维码显示尺寸 + * @param {string} [largeImageUrl=imageUrl] tooltip 使用的大图 data URL + * @returns {{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement}} + */ +function createQRCodeContainer(imageUrl, size, largeImageUrl = imageUrl) { + const container = document.createElement('div'); + container.className = 'qrcode-container'; + container.style.position = 'relative'; + container.style.display = 'inline-block'; + + const image = document.createElement('img'); + image.src = imageUrl; + image.alt = 'eSIM二维码'; + image.className = 'img-fluid'; + image.setAttribute('role', 'img'); + image.setAttribute('aria-label', 'eSIM 安装二维码'); + image.setAttribute('loading', 'lazy'); + image.style.border = '5px solid white'; + image.style.borderRadius = '12px'; + image.style.maxWidth = `${size}px`; + + const tooltip = document.createElement('div'); + tooltip.className = 'tooltip'; + tooltip.style.padding = '0'; + tooltip.style.background = 'none'; + tooltip.style.boxShadow = 'none'; + tooltip.style.willChange = 'transform'; + tooltip.setAttribute('aria-hidden', 'true'); + + const largeImage = document.createElement('img'); + largeImage.src = largeImageUrl; + largeImage.alt = 'eSIM二维码放大预览'; + largeImage.style.width = '400px'; + largeImage.style.height = '400px'; + tooltip.appendChild(largeImage); + + container.appendChild(image); + container.appendChild(tooltip); + + return { container, image, tooltip }; +} + +/** + * 使用浏览器本地 qrcode.js 生成二维码。 + * @param {string} data 二维码内容 + * @param {number} [size=300] 二维码尺寸 + * @returns {Promise<{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement, source: string}>} + */ +export async function generateQRCodeLocal(data, size = DEFAULT_QR_SIZE) { + try { + const safeData = validateQRCodeData(data); + const safeSize = normalizeQRCodeSize(size); + const qrCode = await loadQRCodeLibrary(); + + const imageUrl = await qrCode.toDataURL(safeData, { + errorCorrectionLevel: 'M', + margin: 2, + type: 'image/png', + width: safeSize + }); + + const largeImageUrl = await qrCode.toDataURL(safeData, { + errorCorrectionLevel: 'M', + margin: 2, + type: 'image/png', + width: 400 + }); + + return { + ...createQRCodeContainer(imageUrl, safeSize, largeImageUrl), + source: 'local' + }; + } catch (error) { + throw new Error(`Local QR code generation failed: ${error.message}`); + } +} + +/** + * 调用后端 Function 生成二维码,作为浏览器本地生成失败时的降级方案。 + * @param {string} data 二维码内容 + * @param {number} [size=300] 二维码尺寸 + * @returns {Promise<{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement, source: string}>} + */ +export async function generateQRCodeBackend(data, size = DEFAULT_QR_SIZE) { + const safeData = validateQRCodeData(data); + const safeSize = normalizeQRCodeSize(size); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BACKEND_TIMEOUT_MS); + + try { + const response = await fetch(BACKEND_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ data: safeData, size: safeSize }), + signal: controller.signal + }); + + if (!response.ok) { + throw new Error(`Backend QR code endpoint returned HTTP ${response.status}`); + } + + const payload = await response.json(); + if (!payload?.success || typeof payload.qrcode !== 'string') { + throw new Error(payload?.error || 'Backend QR code response is invalid'); + } + + return { + ...createQRCodeContainer(payload.qrcode, safeSize), + source: 'backend' + }; + } catch (error) { + if (error.name === 'AbortError') { + throw new Error(`Backend QR code generation timed out after ${BACKEND_TIMEOUT_MS}ms`); + } + throw new Error(`Backend QR code generation failed: ${error.message}`); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * 三层降级二维码生成:本地生成 → 后端生成 → 抛错由调用方显示 LPA 手动安装提示。 + * @param {string} data 二维码内容 + * @param {number} [size=300] 二维码尺寸 + * @returns {Promise<{container: HTMLElement, image: HTMLImageElement, tooltip: HTMLElement, source: string}>} + */ +export async function generateQRCodeWithFallback(data, size = DEFAULT_QR_SIZE) { + let localError = null; + + try { + return await generateQRCodeLocal(data, size); + } catch (error) { + localError = error; + console.warn('[QRCode] Local generation failed, trying backend fallback:', error.message); + } + + try { + return await generateQRCodeBackend(data, size); + } catch (backendError) { + console.error('[QRCode] Backend fallback failed:', backendError.message); + const finalError = new Error('QR code generation failed after local and backend fallback'); + finalError.localError = localError; + finalError.backendError = backendError; + throw finalError; + } +} diff --git a/src/simyo/js/modules/ui-controller.js b/src/simyo/js/modules/ui-controller.js index b6ab4b1..6734151 100644 --- a/src/simyo/js/modules/ui-controller.js +++ b/src/simyo/js/modules/ui-controller.js @@ -5,6 +5,7 @@ import { stateManager } from './state-manager.js'; import { t, tl } from '../../../js/modules/i18n.js'; +import { generateQRCodeWithFallback } from '../../../js/modules/qrcode-generator.js'; export class UIController { constructor() { @@ -336,120 +337,40 @@ export class UIController { } /** - * 生成二维码(含超时处理,单服务 5 秒超时,回退时重置计时器) + * 生成二维码(本地生成优先,后端 Function 降级) */ - generateQRCode(data) { + async generateQRCode(data) { const size = 300; - const QR_TIMEOUT_MS = 5000; // 单个服务商超时5秒 - // 清除之前的超时定时器,防止旧 timer 覆盖新二维码 + // 清除旧的超时定时器,避免历史调用覆盖当前二维码区域。 if (this.qrTimeoutId) { clearTimeout(this.qrTimeoutId); this.qrTimeoutId = null; } - // generation counter:防止并发调用时旧回调干扰新调用 const gen = ++this._qrGeneration; - const vendors = [ - (s, d) => `https://qrcode.show/${encodeURIComponent(d)}?size=${s}`, - (s, d) => `https://quickchart.io/qr?size=${s}&text=${encodeURIComponent(d)}`, - (s, d) => `https://api.qrserver.com/v1/create-qr-code/?size=${s}x${s}&data=${encodeURIComponent(d)}` - ]; - let vendorIdx = 0; - let isResolved = false; // 防止超时和成功回调同时触发 + try { + const result = await generateQRCodeWithFallback(data, size); + if (gen !== this._qrGeneration) return; // 防止并发调用干扰 - const container = document.createElement('div'); - container.className = 'qrcode-container'; - container.style.position = 'relative'; - container.style.display = 'inline-block'; - - const img = document.createElement('img'); - const setSrc = () => { img.src = vendors[vendorIdx](size, data); }; - setSrc(); - img.alt = tl('eSIM二维码'); - img.setAttribute('role', 'img'); - img.setAttribute('aria-label', tl('eSIM 安装二维码')); - img.className = 'img-fluid'; - img.style.border = '5px solid white'; - img.style.borderRadius = '12px'; - img.style.maxWidth = `${size}px`; - img.setAttribute('loading', 'lazy'); - - // 超时处理:二维码长时间加载失败时,显示 LPA 字符串提示 - const startTimeout = () => { - clearTimeout(this.qrTimeoutId); - this.qrTimeoutId = setTimeout(() => { - // 如果已被新调用取代,忽略本次超时 - if (gen !== this._qrGeneration) return; - if (!isResolved) { - isResolved = true; - this.qrTimeoutId = null; - console.warn('[Simyo] QR code generation timed out'); - this.elements.qrcode.innerHTML = ` -
- - ${t('simyo.app.qr.timeout')} -
- `; - } - }, QR_TIMEOUT_MS); - }; - - startTimeout(); - - img.onload = () => { - if (gen !== this._qrGeneration) return; - if (!isResolved) { - isResolved = true; - clearTimeout(this.qrTimeoutId); - this.qrTimeoutId = null; + if (result.tooltip && typeof this.showTooltipElement === 'function' && typeof this.hideTooltipElement === 'function') { + result.container.addEventListener('mouseenter', (event) => this.showTooltipElement(result.tooltip, event)); + result.container.addEventListener('mouseleave', () => this.hideTooltipElement(result.tooltip)); } - }; - img.onerror = () => { + this.elements.qrcode.innerHTML = ''; + this.elements.qrcode.appendChild(result.container); + } catch (error) { if (gen !== this._qrGeneration) return; - if (isResolved) return; - if (vendorIdx < vendors.length - 1) { - // 回退到下一个服务,重置超时计时器 - vendorIdx += 1; - console.log('[Simyo] QR vendor fallback to:', vendorIdx + 1, '/', vendors.length); - startTimeout(); - setSrc(); - } else { - isResolved = true; - clearTimeout(this.qrTimeoutId); - this.qrTimeoutId = null; - this.elements.qrcode.innerHTML = ` -
- - ${t('simyo.app.qr.failed')} -
- `; - } - }; - - const tooltip = document.createElement('div'); - tooltip.className = 'tooltip'; - tooltip.style.padding = '0'; - tooltip.style.background = 'none'; - tooltip.style.boxShadow = 'none'; - tooltip.style.willChange = 'transform'; - - const largeImg = document.createElement('img'); - const setLargeSrc = () => { largeImg.src = vendors[vendorIdx](400, data); }; - setLargeSrc(); - largeImg.style.width = '400px'; - largeImg.style.height = '400px'; - tooltip.appendChild(largeImg); - - container.addEventListener('mouseenter', (e) => this.showTooltipElement(tooltip, e)); - container.addEventListener('mouseleave', () => this.hideTooltipElement(tooltip)); - - container.appendChild(img); - container.appendChild(tooltip); - this.elements.qrcode.innerHTML = ''; - this.elements.qrcode.appendChild(container); + console.error('[Simyo] QR code generation failed:', error); + this.elements.qrcode.innerHTML = ` +
+ + ${t('simyo.app.qr.failed')} +
+ `; + } } /** diff --git a/tests/modules/qrcode-generator.test.js b/tests/modules/qrcode-generator.test.js new file mode 100644 index 0000000..c24a8b5 --- /dev/null +++ b/tests/modules/qrcode-generator.test.js @@ -0,0 +1,70 @@ +/** + * qrcode-generator 模块单元测试 + */ + +describe('qrcode-generator', () => { + beforeEach(() => { + jest.resetModules(); + fetch.mockReset(); + delete window.QRCode; + }); + + it('应该使用浏览器本地 qrcode.js 生成 img 容器', async () => { + window.QRCode = { + toDataURL: jest.fn((data, options) => Promise.resolve(`data:image/png;base64,local-${options.width}`)) + }; + + const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); + const result = await generateQRCodeLocal('LPA:1$example', 300); + + expect(result.source).toBe('local'); + expect(result.container.className).toBe('qrcode-container'); + expect(result.container.querySelector('img').getAttribute('src')).toBe('data:image/png;base64,local-300'); + expect(window.QRCode.toDataURL).toHaveBeenCalledTimes(2); + }); + + it('本地生成失败时应该调用后端 BFF 降级', async () => { + window.QRCode = { + toDataURL: jest.fn(() => Promise.reject(new Error('local failed'))) + }; + fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, qrcode: 'data:image/png;base64,backend' }) + }); + + const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js'); + const result = await generateQRCodeWithFallback('LPA:1$example', 300); + + expect(result.source).toBe('backend'); + expect(fetch).toHaveBeenCalledWith('/bff/qrcode-generate', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ data: 'LPA:1$example', size: 300 }) + })); + }); + + it('本地和后端都失败时应该抛出最终错误', async () => { + window.QRCode = { + toDataURL: jest.fn(() => Promise.reject(new Error('local failed'))) + }; + fetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + const { generateQRCodeWithFallback } = await import('../../src/js/modules/qrcode-generator.js'); + + await expect(generateQRCodeWithFallback('LPA:1$example', 300)) + .rejects.toThrow('QR code generation failed after local and backend fallback'); + }); + + it('应该拒绝过长的二维码内容', async () => { + window.QRCode = { + toDataURL: jest.fn() + }; + + const { generateQRCodeLocal } = await import('../../src/js/modules/qrcode-generator.js'); + + await expect(generateQRCodeLocal('x'.repeat(2049), 300)) + .rejects.toThrow('Local QR code generation failed'); + }); +}); diff --git a/tests/security/bff-proxy.test.js b/tests/security/bff-proxy.test.js index 9859d00..ec10b6e 100644 --- a/tests/security/bff-proxy.test.js +++ b/tests/security/bff-proxy.test.js @@ -161,4 +161,25 @@ describe('BFF proxy guardrails', () => { expect(proxiedRequest.headers.get('x-esim-key')).toBe('test-access-key'); expect(proxiedRequest.headers.get('x-app-key')).toBeNull(); }); + + it('allows qrcode-generate BFF target', async () => { + const mod = await import('../../netlify/edge-functions/bff-proxy.js'); + const request = new Request('https://example.com/bff/qrcode-generate', { + method: 'POST', + headers: { + origin: 'https://esim.cosr.eu.org', + 'content-type': 'application/json' + }, + body: JSON.stringify({ data: 'LPA:1$example', size: 300 }) + }); + + const response = await mod.default(request); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledTimes(1); + const proxiedRequest = global.fetch.mock.calls[0][0]; + expect(proxiedRequest.url).toBe('https://example.com/.netlify/functions/qrcode-generate'); + expect(proxiedRequest.headers.get('x-esim-key')).toBe('test-access-key'); + }); + }); diff --git a/tests/security/qrcode-generate.test.js b/tests/security/qrcode-generate.test.js new file mode 100644 index 0000000..01bad76 --- /dev/null +++ b/tests/security/qrcode-generate.test.js @@ -0,0 +1,104 @@ +/** + * qrcode-generate Function 单元测试 + */ + +describe('qrcode-generate Function', () => { + const originalEnv = process.env; + + function loadFunction(toDataURLImpl) { + jest.resetModules(); + process.env = { + ...originalEnv, + ACCESS_KEY: 'test-access-key', + ALLOWED_ORIGIN: 'https://esim.cosr.eu.org' + }; + + jest.doMock('qrcode', () => ({ + toDataURL: jest.fn(toDataURLImpl) + }), { virtual: true }); + + return { + QRCode: require('qrcode'), + qrcodeGenerate: require('../../netlify/functions/qrcode-generate') + }; + } + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('有效请求应该返回 base64 PNG data URL', async () => { + const { QRCode, qrcodeGenerate } = loadFunction(() => Promise.resolve('data:image/png;base64,qrcode')); + + const result = await qrcodeGenerate.handler({ + httpMethod: 'POST', + headers: { + origin: 'https://esim.cosr.eu.org', + 'x-esim-key': 'test-access-key' + }, + body: JSON.stringify({ data: 'LPA:1$example', size: 300 }), + queryStringParameters: {} + }, { functionName: 'qrcode-generate' }); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual({ + success: true, + qrcode: 'data:image/png;base64,qrcode' + }); + expect(QRCode.toDataURL).toHaveBeenCalledWith('LPA:1$example', expect.objectContaining({ + type: 'image/png', + width: 300 + })); + }); + + it('缺少 data 时应该返回 400', async () => { + const { qrcodeGenerate } = loadFunction(() => Promise.resolve('data:image/png;base64,qrcode')); + + const result = await qrcodeGenerate.handler({ + httpMethod: 'POST', + headers: { + origin: 'https://esim.cosr.eu.org', + 'x-esim-key': 'test-access-key' + }, + body: JSON.stringify({ size: 300 }), + queryStringParameters: {} + }, { functionName: 'qrcode-generate' }); + + expect(result.statusCode).toBe(400); + expect(result.body).toContain('data is required'); + }); + + it('size 超出范围时应该返回 400', async () => { + const { qrcodeGenerate } = loadFunction(() => Promise.resolve('data:image/png;base64,qrcode')); + + const result = await qrcodeGenerate.handler({ + httpMethod: 'POST', + headers: { + origin: 'https://esim.cosr.eu.org', + 'x-esim-key': 'test-access-key' + }, + body: JSON.stringify({ data: 'LPA:1$example', size: 601 }), + queryStringParameters: {} + }, { functionName: 'qrcode-generate' }); + + expect(result.statusCode).toBe(400); + expect(result.body).toContain('size must be between 200 and 600'); + }); + + it('非 POST 请求应该返回 405', async () => { + const { qrcodeGenerate } = loadFunction(() => Promise.resolve('data:image/png;base64,qrcode')); + + const result = await qrcodeGenerate.handler({ + httpMethod: 'GET', + headers: { + origin: 'https://esim.cosr.eu.org', + 'x-esim-key': 'test-access-key' + }, + body: '{}', + queryStringParameters: {} + }, { functionName: 'qrcode-generate' }); + + expect(result.statusCode).toBe(405); + }); +}); diff --git a/tests/security/server-routes.test.js b/tests/security/server-routes.test.js index 198e864..ea098b3 100644 --- a/tests/security/server-routes.test.js +++ b/tests/security/server-routes.test.js @@ -31,6 +31,7 @@ describe('Local server route coverage', () => { '/bff/giffgaff-mfa-validation', '/bff/giffgaff-sms-activate', '/bff/auto-activate-esim', + '/bff/qrcode-generate', '/bff/verify-cookie', '/bff/public-config' ];