From bb80d3dc0a2ce0c60f9a2788839d7e59c41895f2 Mon Sep 17 00:00:00 2001 From: Neo Vern <22141172+Silentely@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:04:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sentry):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88=E5=8A=9F=E8=83=BD=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(sentry): 添加用户反馈功能 - 升级 CDN bundle 以支持 feedback 模块 - 添加 feedbackIntegration 内置 Widget(主动反馈) - 实现错误后自动弹窗(Crash-Report Modal) - 添加冷却机制防止频繁弹窗(60秒冷却 + 同一错误不重复) * ♻️ refactor(sentry): 修复审查发现的问题 - 消除指纹计算重复(DRY 违反) - 统一使用 const/let 替代 var - 复用已有 showReportDialog 函数 - 添加 beforeSend UI 副作用设计说明注释 * 🐛 fix(sentry): 修复 SentryMock 缺失方法 - 添加 feedbackIntegration 和 replayIntegration 到 SentryMock - 修复 CDN 加载失败时 TypeError - 删除多余空行 * 🐛 fix(sentry): 修复代码质量问题 - sentry-loader.js 添加 replayIntegration 和 feedbackIntegration 配置 - sentry-loader.js 添加错误后自动弹窗逻辑(冷却机制 + try-catch) - sentry-init.js 添加 showReportDialog try-catch 保护 - 弹窗延迟从 100ms 改为 500ms(弱网兼容) * ✨ feat(sentry): 修复所有代码质量问题 - H1: CSP 添加 *.sentry.io 到 script-src(4 个文件) - M1: Feedback Widget 和自动弹窗添加中文文案 - M2: 同步两套初始化路径配置(tracing、脱敏、ignoreErrors) - M3: sentry-entry.js 添加 feedbackIntegration 导出 - M4: 新增专项测试(13 个测试用例) - L1: SentryMock 补齐 showReportDialog/lastEventId - L2: 空事件对象防御 * ✨ feat(sentry): 修复 PR 审查发现的全部问题 功能正确性: - query_string 添加类型检查(字符串/对象兼容) - 指纹计算移到脱敏前(避免不同错误被误判为重复) - 冷却状态更新移到 setTimeout 内部(避免空转) - subtitleLine2 改为 subtitle2(修正字段名) CSP 修复: - script-src 移除 *.sentry.io(减少攻击面) - frame-src 添加 *.sentry.io(支持弹窗 iframe) - 添加 worker-src/child-src blob:(支持 Session Replay) - server.js helmet CSP 同步更新 - netlify.toml 注释与实际策略对齐 配置/架构: - sentry-entry.js 添加 lastEventId 导出 - tracePropagationTargets 添加 CORS 风险注释 - CDN 架构添加说明注释 代码质量: - URL 参数脱敏支持大小写不敏感 - 移除过宽的 ignoreErrors 规则 - 测试文件添加 use strict - 添加 i18n 和漂移风险注释 * fix: apply CodeRabbit auto-fixes (#85) Fixed 3 file(s) based on 5 unresolved review comments. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit * ✨ feat(sentry): 修复新一轮审查意见 CSP 修复: - script-src 恢复 *.sentry.io(showReportDialog 需要) - netlify.toml Header CSP 同步 Cloudflare/Google - server.js helmet CSP 同步 冷却竞态修复: - 状态预留移到 setTimeout 之前(防止 500ms 内重复弹窗) - 弹窗失败时回滚冷却状态 脱敏修复: - query_string 对象分支改为大小写不敏感 - sanitizeQueryString 迭代改为安全模式(先收集键再遍历) --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit --- .gitignore | 2 + index.html | 2 +- netlify.toml | 6 +- server.js | 9 +- src/giffgaff/giffgaff_modular.html | 2 +- src/js/modules/sentry-init.js | 152 +++++++++++- src/js/sentry-entry.js | 6 + src/js/sentry-loader.js | 140 ++++++++++- src/simyo/simyo_modular.html | 2 +- tests/modules/sentry-feedback.test.js | 332 ++++++++++++++++++++++++++ 10 files changed, 631 insertions(+), 22 deletions(-) create mode 100644 tests/modules/sentry-feedback.test.js diff --git a/.gitignore b/.gitignore index 68b533d..bd8652c 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,8 @@ dist/ *.bak *.orig *.tmp +docs/superpowers/ +.sdd/ # Editor files *.sublime-project diff --git a/index.html b/index.html index 4ab41cb..7fb532a 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ - + eSIM Tools - Giffgaff & Simyo eSIM 在线管理工具 | 设备更换、激活与二维码生成 diff --git a/netlify.toml b/netlify.toml index 77eb8be..5684df7 100644 --- a/netlify.toml +++ b/netlify.toml @@ -172,9 +172,9 @@ X-XSS-Protection = "1; mode=block" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" - # 生产 CSP:移除 unsafe-inline,基于 nonce/hash 的策略(页面模板需注入 nonce) - # 如暂无模板注入机制,可先允许外链受信源并避免内联脚本 - Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google.com https://www.gstatic.com https://browser.sentry-cdn.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://*.sentry.io; font-src 'self' data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com; manifest-src 'self';" + # 生产 CSP:包含 unsafe-inline 是因为部分页面仍有内联样式和脚本(如内联 CSS、runtime 兜底脚本), + # 迁移至 nonce/hash 策略需模板注入机制支持,当前暂保留 unsafe-inline + Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.googletagmanager.com https://www.google.com https://www.gstatic.com https://browser.sentry-cdn.com https://*.sentry.io https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://browser.sentry-cdn.com https://www.google-analytics.com https://analytics.google.com https://stats.g.doubleclick.net https://www.googletagmanager.com https://qrcode.show https://api.qrserver.com https://appapi.simyo.nl https://api.giffgaff.com https://id.giffgaff.com https://publicapi.giffgaff.com https://www.google.com https://www.gstatic.com https://challenges.cloudflare.com https://*.sentry.io; font-src 'self' data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com; frame-src 'self' https://challenges.cloudflare.com https://www.google.com https://*.sentry.io; worker-src 'self' blob:; child-src 'self' blob:; manifest-src 'self';" # Agent 发现 Link 响应头 (RFC 8288) [[headers]] diff --git a/server.js b/server.js index d732488..22c919e 100644 --- a/server.js +++ b/server.js @@ -59,11 +59,14 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://*.sentry.io"], styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.googleapis.com"], imgSrc: ["'self'", "data:", "https:", "http:"], - connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://*.sentry.io"], - fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"] + connectSrc: ["'self'", "https://appapi.simyo.nl", "https://api.giffgaff.com", "https://id.giffgaff.com", "https://publicapi.giffgaff.com", "https://cdn.jsdelivr.net", "https://browser.sentry-cdn.com", "https://*.sentry.io"], + fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://fonts.gstatic.com"], + frameSrc: ["'self'", "https://*.sentry.io"], + workerSrc: ["'self'", "blob:"], + childSrc: ["'self'", "blob:"] } } })); diff --git a/src/giffgaff/giffgaff_modular.html b/src/giffgaff/giffgaff_modular.html index ef1cf38..d7bb0c9 100644 --- a/src/giffgaff/giffgaff_modular.html +++ b/src/giffgaff/giffgaff_modular.html @@ -20,7 +20,7 @@ - + Giffgaff eSIM 工具 diff --git a/src/js/modules/sentry-init.js b/src/js/modules/sentry-init.js index e2bfe26..06ffb99 100644 --- a/src/js/modules/sentry-init.js +++ b/src/js/modules/sentry-init.js @@ -317,6 +317,52 @@ const isDev = (() => { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.'); })(); +// =================================== +// 错误反馈弹窗冷却机制 +// 避免同一错误或短时间内反复弹窗打扰用户 +// =================================== + +const REPORT_DIALOG_COOLDOWN_MS = 60000; // 60 秒冷却时间 +let lastReportDialogTime = 0; +let lastReportDialogFingerprint = ''; + +/** + * 判断是否应该显示反馈弹窗 + * @param {Object} event - Sentry 事件对象 + * @returns {{ shouldShow: boolean, fingerprint: string }} + */ +function shouldShowReportDialog(event) { + const empty = { shouldShow: false, fingerprint: '' }; + + // 空事件防御 + if (!event || typeof event !== 'object') return empty; + + // 仅生产环境弹窗 + if (isDev) return empty; + + // 仅对异常事件弹窗(captureMessage 等不弹) + if (!event.exception || !event.exception.values || !event.exception.values.length) { + return empty; + } + + // 生成错误指纹:取第一个异常的 type + value 组合 + const firstException = event.exception.values[0]; + const fingerprint = (firstException.type || '') + ':' + (firstException.value || ''); + + // 冷却检查:避免短时间内反复弹窗 + const now = Date.now(); + if (now - lastReportDialogTime < REPORT_DIALOG_COOLDOWN_MS) { + return { shouldShow: false, fingerprint }; + } + + // 同一错误不重复弹窗 + if (fingerprint === lastReportDialogFingerprint) { + return { shouldShow: false, fingerprint }; + } + + return { shouldShow: true, fingerprint }; +} + // =================================== // Sentry 配置 // =================================== @@ -362,15 +408,42 @@ function initSentry() { // 性能监控 integrations: [ - window.Sentry.browserTracingIntegration(), + window.Sentry.browserTracingIntegration({ + traceFetch: true, + traceXHR: true, + enableLongTask: true, + }), // Session Replay - 记录用户操作以重现错误场景 window.Sentry.replayIntegration({ maxReplayDuration: 60000, maskAllText: true, maskAllInputs: true, }), + // User Feedback Widget - 用户随时可提交反馈 + window.Sentry.feedbackIntegration({ + colorScheme: 'system', + enableScreenshot: true, + triggerLabel: '反馈', + formTitle: '发送反馈', + submitButtonLabel: '提交', + cancelButtonLabel: '取消', + nameLabel: '名称', + emailLabel: '邮箱', + messageLabel: '描述', + successMessageText: '感谢您的反馈!', + }), ], tracesSampleRate: 0.1, + + // 追踪传播目标 - 指定哪些请求应该添加追踪头 + tracePropagationTargets: [ + 'localhost', + /^https:\/\/esim\.cosr\.eu\.org/, + /^https:\/\/.*\.giffgaff\.com/, + /^https:\/\/.*\.simyo\.nl/, + /^https:\/\/qrcode\.show/, + /^https:\/\/api\.qrserver\.com/, + ], // Session Replay 采样率 replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, @@ -408,7 +481,7 @@ function initSentry() { // 第三方脚本噪音 /turnstile/i, // 浏览器扩展注入的 SweetAlert 冲突(t.swal=e() 模式) - /swal/i, + /t\.swal=e\(\)/i, // Google Tag Manager 噪音 (ESIM-TOOLS-18) /gtag\/js/, /gtm\.js/, @@ -474,8 +547,43 @@ function initSentry() { } } - // 2. 删除敏感请求数据 + // 2. 脱敏 URL 中的敏感查询参数 + 删除敏感请求数据 + function sanitizeQueryString(url) { + if (!url) return url; + const sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret']; + try { + const urlObj = new URL(url, window.location.origin); + // 先收集所有键再遍历,避免迭代时修改 searchParams 底层列表 + const keys = Array.from(urlObj.searchParams.keys()); + keys.forEach(key => { + if (sensitiveParams.includes(key.toLowerCase())) { + urlObj.searchParams.set(key, '***'); + } + }); + return urlObj.toString(); + } catch (e) { + return url.replace(/([?&])(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1$2=***'); + } + } + if (event.request) { + if (event.request.query_string) { + if (typeof event.request.query_string === 'string') { + event.request.query_string = event.request.query_string + .replace(/(token|key|password|code|state|access_token|refresh_token|auth|secret)=[^&]*/gi, '$1=***'); + } else if (typeof event.request.query_string === 'object') { + const sensitiveParams = ['token', 'key', 'password', 'code', 'state', 'access_token', 'refresh_token', 'auth', 'secret']; + // Case-insensitive matching while preserving original keys + for (const [key] of Object.entries(event.request.query_string)) { + if (sensitiveParams.includes(key.toLowerCase())) { + event.request.query_string[key] = '***'; + } + } + } + } + if (event.request.url) { + event.request.url = sanitizeQueryString(event.request.url); + } delete event.request.cookies; if (event.request.headers) { delete event.request.headers['authorization']; @@ -484,7 +592,18 @@ function initSentry() { } } - // 3. 脱敏异常消息中的敏感信息 + // 3. 弹窗决策:在脱敏前基于原始异常值计算指纹,避免脱敏后不同错误产生相同指纹 + // 注:在 beforeSend 中触发 UI 交互是刻意设计,因为需要 event_id 关联反馈 + let reportCheck = { shouldShow: false, fingerprint: '' }; + if (event.exception?.values) { + // 保存原始异常值用于指纹计算 + const originalValues = event.exception.values.map(e => e.value); + reportCheck = shouldShowReportDialog(event); + // 恢复原始值,确保后续脱敏基于原始数据 + event.exception.values.forEach((e, i) => { e.value = originalValues[i]; }); + } + + // 4. 脱敏异常消息中的敏感信息 if (event.exception?.values) { event.exception.values.forEach(exception => { if (exception.value) { @@ -501,6 +620,25 @@ function initSentry() { }); } + // 5. 错误后自动弹出反馈对话框(仅生产环境、异常事件、冷却期内不重复) + if (reportCheck.shouldShow && event.event_id) { + const eventId = event.event_id; + const fingerprint = reportCheck.fingerprint; + // 立即更新冷却状态,避免多个错误同时触发弹窗 + lastReportDialogFingerprint = fingerprint; + lastReportDialogTime = Date.now(); + // 延迟弹窗,确保 Sentry 事件已发送完成 + setTimeout(function() { + try { + showReportDialog({ eventId: eventId, title: '问题反馈', subtitle: '抱歉,发生了错误', subtitle2: '您的反馈将帮助我们改进服务', labelName: '名称', labelEmail: '邮箱', labelComments: '问题描述(选填)', labelClose: '关闭', labelSubmit: '提交反馈', successMessage: '感谢您的反馈!' }); + } catch (e) { + // 弹窗失败时回滚冷却状态,允许后续重试 + lastReportDialogFingerprint = ''; + lastReportDialogTime = 0; + } + }, 500); + } + return event; }, }); @@ -531,6 +669,10 @@ const SentryMock = { setContext: () => {}, withScope: (callback) => callback({ setContext: () => {}, setTag: () => {} }), browserTracingIntegration: () => ({}), + feedbackIntegration: () => ({}), + replayIntegration: () => ({}), + showReportDialog: () => {}, + lastEventId: () => null, }; // =================================== @@ -634,7 +776,7 @@ export function showReportDialog(options = {}) { eventId: options.eventId || lastEventId(), title: options.title || '报告问题', subtitle: options.subtitle || '告诉我们发生了什么', - subtitleLine2: options.subtitleLine2 || '您的反馈将帮助我们改进', + subtitle2: options.subtitle2 || '您的反馈将帮助我们改进', labelName: options.labelName || '名称', labelEmail: options.labelEmail || '邮箱', labelComments: options.labelComments || '问题描述', diff --git a/src/js/sentry-entry.js b/src/js/sentry-entry.js index ae5fac2..2f53d21 100644 --- a/src/js/sentry-entry.js +++ b/src/js/sentry-entry.js @@ -8,6 +8,7 @@ import { init, browserTracingIntegration, replayIntegration, + feedbackIntegration, captureException, captureMessage, addBreadcrumb, @@ -16,6 +17,7 @@ import { setContext, withScope, showReportDialog, + lastEventId, getGlobalScope, flush, close, @@ -27,6 +29,7 @@ if (typeof window !== 'undefined') { init, browserTracingIntegration, replayIntegration, + feedbackIntegration, captureException, captureMessage, addBreadcrumb, @@ -35,6 +38,7 @@ if (typeof window !== 'undefined') { setContext, withScope, showReportDialog, + lastEventId, getGlobalScope, flush, close, @@ -48,6 +52,7 @@ export { init, browserTracingIntegration, replayIntegration, + feedbackIntegration, captureException, captureMessage, addBreadcrumb, @@ -56,6 +61,7 @@ export { setContext, withScope, showReportDialog, + lastEventId, getGlobalScope, flush, close, diff --git a/src/js/sentry-loader.js b/src/js/sentry-loader.js index 1b0bd48..8b3bbea 100644 --- a/src/js/sentry-loader.js +++ b/src/js/sentry-loader.js @@ -6,6 +6,10 @@ * 2. 自动完成初始化配置 * 3. 暴露 testSentry() 到 window 供测试 * + * 架构说明:本文件使用 CDN 版本(8.40.0)作为生产主实现路径, + * 确保在 npm 构建不可用时仍能正常加载 Sentry SDK。 + * npm bundle 版本(10.x)由 sentry-entry.js 提供,作为 Webpack 构建的备选方案。 + * * 使用方法: *