From 7554de7d72339ad7bc040cbf18ffba387d0360dc Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Wed, 9 Sep 2026 11:17:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=97=A5=E6=8A=A5?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E7=89=87=E6=AE=B5=20UI=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E4=B8=8E=E8=A7=86=E8=A7=89=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/group-report-service.ts | 89 +++++---- .../report-template-fragment-contract.ts | 130 +++++++++++++ .../report-template-fragment-contract.spec.ts | 176 ++++++++++++++++++ .../report-template-production-fragments.json | 134 +++++++++++++ 4 files changed, 494 insertions(+), 35 deletions(-) create mode 100644 src/shared/report-template-fragment-contract.ts create mode 100644 tests/e2e/report-template-fragment-contract.spec.ts create mode 100644 tests/fixtures/report-template-production-fragments.json diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index eca7ba8..ba3303c 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -19,6 +19,7 @@ import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { imageInsightService } from './services/image-insight-service' import { getReportTemplate } from '../shared/report-templates' import type { ReportTemplateRef } from '../shared/report-template-package' +import { injectReportTemplateFragmentContract } from '../shared/report-template-fragment-contract' import { reportTemplateService, validateReportTemplateHtml } from './report-template-service' const LEGACY_TEMPLATE_FILES: Record = { @@ -92,11 +93,13 @@ const hashName = (name: string): number => { return hash } -const fallbackAvatar = (name: string): string => { +type RenderedAvatar = { source: string; fallback: boolean } + +const fallbackAvatar = (name: string): RenderedAvatar => { const hue = hashName(name) % 360 const initial = escapeHtml(Array.from(name.trim())[0] || '?') const svg = `${initial}` - return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` + return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true } } const imageMimeType = (contentType: string | null, source: string): string => { @@ -108,9 +111,9 @@ const imageMimeType = (contentType: string | null, source: string): string => { return 'image/jpeg' } -const embedAvatar = async (source: string | undefined, name: string): Promise => { +const embedAvatar = async (source: string | undefined, name: string): Promise => { if (!source) return fallbackAvatar(name) - if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source + if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return { source, fallback: false } try { if (/^https?:\/\//i.test(source)) { @@ -123,12 +126,12 @@ const embedAvatar = async (source: string | undefined, name: string): Promise ) const inlineTemplateAssets = async (html: string, templateRoot: string): Promise => { - const matches = [...html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)] - let result = html - for (const match of matches) { - const relative = match[2] + const assetDataUrl = async (relative: string): Promise => { const assetPath = path.resolve(templateRoot, relative) - if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) continue + if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) return null const extension = path.extname(assetPath).toLowerCase() const mime = extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg' const data = (await fs.readFile(assetPath)).toString('base64') - result = result.replace(match[0], `src="data:${mime};base64,${data}"`) + return `data:${mime};base64,${data}` + } + let result = html + for (const match of html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)) { + const dataUrl = await assetDataUrl(match[2]) + if (dataUrl) result = result.replace(match[0], `src="${dataUrl}"`) + } + for (const match of html.matchAll(/\burl\(\s*(['"]?)(assets\/[A-Za-z0-9._/-]+)\1\s*\)/g)) { + const dataUrl = await assetDataUrl(match[2]) + if (dataUrl) result = result.replace(match[0], `url("${dataUrl}")`) } return result } @@ -267,24 +276,34 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise avatarNames.add(item.sender)) report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner)) - const avatars = new Map() + const avatars = new Map() await Promise.all( Array.from(avatarNames).map(async (name) => { avatars.set(name, await embedAvatar(metadata.avatars[name], name)) }) ) - const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name) + const avatar = (name: string): RenderedAvatar => avatars.get(name) || fallbackAvatar(name) + const renderAvatar = ( + name: string, + role: 'hero' | 'message' | 'participant' | 'ranking', + legacyClass = '', + alt = '' + ): string => { + const rendered = avatar(name) + const fallbackClass = rendered.fallback ? ' tm-avatar--fallback' : '' + return `${escapeHtml(alt)}` + } const heroNames = selectHeroParticipantNames(metadata.heroParticipants) const heroAvatars = heroNames - .map((name) => `${escapeHtml(name)}`) + .map((name) => renderAvatar(name, 'hero', '', name)) .join('') const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section' const topicCards = report.topics .map( - (topic) => `
-

${escapeHtml(topic.title)}

${escapeHtml(topic.heat)}热
+ (topic) => `
+

${escapeHtml(topic.title)}

${escapeHtml(topic.heat)}热
${escapeHtml(topic.timeRange)}

${escapeHtml(topic.summary)}

${ @@ -327,11 +346,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise${topic.participants +
${topic.participants .slice(0, 5) .map( (name) => - `${escapeHtml(name)}` + `${renderAvatar(name, 'participant')}${escapeHtml(name)}` ) .join('')}
${topic.keywords.map((word) => `${escapeHtml(word)}`).join('')}
@@ -348,10 +367,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
- -
${escapeHtml(message.sender)}${escapeHtml(message.time)}
-
${escapeHtml(message.content)}
${escapeHtml(message.note)}
+ (message) => `
+ ${renderAvatar(message.sender, 'message', 'avatar')} +
${escapeHtml(message.sender)}${escapeHtml(message.time)}
+
${escapeHtml(message.content)}
${escapeHtml(message.note)}
` ) .join('') @@ -359,12 +378,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise - `
${quote.messages + `
${quote.messages .map( ( message - ) => `
-
${escapeHtml(message.sender)}
${escapeHtml(message.content)}
+ ) => `
${renderAvatar(message.sender, 'message', 'chat-avatar')}
+
${escapeHtml(message.sender)}
${escapeHtml(message.content)}
` ) .join('')}
${escapeHtml(quote.note)}
` @@ -373,7 +392,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
+ (item) => `
${escapeHtml(item.task)}
${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}
${item.note ? `
${escapeHtml(item.note)}
` : ''} @@ -383,7 +402,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
+ (item) => `
${escapeHtml(item.question)}
${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}
${escapeHtml(item.note)}
@@ -410,7 +429,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
+ (item) => `
${escapeHtml(item.topic)}
最初:${escapeHtml(item.initialView)}
后来:${escapeHtml(item.finalView)}
@@ -449,7 +468,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
+ (item) => `
${escapeHtml(item.title)} · ${escapeHtml(item.sender)}
${escapeHtml(item.note)}
` @@ -458,8 +477,8 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise `
- + (item, index) => `
+ ${renderAvatar(item.sender, 'ranking')} ${index + 1}. ${escapeHtml(item.sender)} ${item.count} 条 · ${item.durationSec} 秒
` @@ -480,7 +499,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise - `
${index + 1}. ${escapeHtml(speaker.name)}${Math.max(0, speaker.count)} 条
` + `
${renderAvatar(speaker.name, 'ranking')}${index + 1}. ${escapeHtml(speaker.name)}${Math.max(0, speaker.count)} 条
` ) .join('') @@ -510,7 +529,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise - `
Q:${escapeHtml(item.question)}
A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
` + `
Q:${escapeHtml(item.question)}
A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
` ) .join('') @@ -637,7 +656,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise :has(.tm-fragment) { + grid-template-columns: minmax(0, 1fr) !important; + min-width: 0 !important; + } +} +` + +export const injectReportTemplateFragmentContract = (html: string): string => { + const style = `` + if (/<\/head\s*>/i.test(html)) return html.replace(/<\/head\s*>/i, `${style}`) + return `${style}${html}` +} diff --git a/tests/e2e/report-template-fragment-contract.spec.ts b/tests/e2e/report-template-fragment-contract.spec.ts new file mode 100644 index 0000000..b65805f --- /dev/null +++ b/tests/e2e/report-template-fragment-contract.spec.ts @@ -0,0 +1,176 @@ +import { _electron as electron, chromium, expect, test } from '@playwright/test' +import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { ZipArchive } from 'archiver' + +const fixture = JSON.parse( + readFileSync(resolve('tests/fixtures/report-template-production-fragments.json'), 'utf8') +) + +const makeZip = async (directory: string): Promise => { + const archivePath = join(directory, 'hostile-avatar-template.zip') + const output = createWriteStream(archivePath) + const archive = new ZipArchive({ zlib: { level: 6 } }) + archive.pipe(output) + archive.append( + JSON.stringify({ + protocolVersion: '1.0', + kind: 'daily-report', + id: 'community.github.example.fragment-contract', + name: 'Production Fragment Contract 压力模板', + author: { name: 'fixture' }, + templateVersion: '1.0.0', + interfaceVersion: '1', + entry: 'template.html', + capture: { width: 1000, maxWidth: 1000, maxHeight: 20000 }, + license: { spdx: 'MIT' } + }), + { name: 'manifest.json' } + ) + archive.append( + `
{{HERO_AVATARS}}
{{TOPIC_CARDS}}
{{IMPORTANT_MESSAGES}}
{{QUOTE_BLOCKS}}
{{TODO_CARDS}}{{UNRESOLVED_CARDS}}
{{QA_CARDS}}
{{RANK_ITEMS}}
`, + { name: 'template.html' } + ) + await new Promise((resolvePromise, reject) => { + output.on('close', resolvePromise) + output.on('error', reject) + archive.on('error', reject) + void archive.finalize().catch(reject) + }) + return archivePath +} + +test('REPORT-TEMPLATE-E2E-FRAGMENT-01 protects production avatars and text from hostile template CSS', async ({}, testInfo) => { + const userData = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-user-')) + const outputDir = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-output-')) + const fixtureDir = mkdtempSync(join(tmpdir(), 'tracememo-fragment-contract-fixture-')) + const launch = () => + electron.launch({ + args: [resolve('out/main/reportTemplateTest.js')], + env: { + ...process.env, + TRACEMEMO_TEMPLATE_TEST_USER_DATA: userData, + TRACEMEMO_REPORT_OUTPUT_DIR: outputDir + } + }) + let app = await launch() + try { + const page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + const installed = await page.evaluate((packagePath) => window.api.installReportTemplate(packagePath), await makeZip(fixtureDir)) + expect(installed.success, installed.error).toBe(true) + const exported = await page.evaluate( + ({ metadata, report }) => + window.api.exportGroupReport({ + templateRef: { id: 'community.github.example.fragment-contract', version: '1.0.0' }, + metadata, + report + }), + fixture + ) + expect(exported.success, exported.error).toBe(true) + expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('tm-production-fragment-contract') + expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('tm-avatar--fallback') + expect(readFileSync(exported.htmlPath!, 'utf8')).toContain('<>') + expect(readFileSync(exported.pngPath!).length).toBeGreaterThan(1000) + + const browser = await chromium.launch({ headless: true }) + try { + for (const viewportWidth of [1000, 430]) { + const inspectPage = await browser.newPage({ viewport: { width: viewportWidth, height: 1200 }, deviceScaleFactor: 1 }) + const externalRequests: string[] = [] + const badResponses: string[] = [] + inspectPage.on('request', (request) => { + if (/^https?:/i.test(request.url())) externalRequests.push(request.url()) + }) + inspectPage.on('response', (response) => { + if (response.status() >= 400) badResponses.push(`${response.status()} ${response.url()}`) + }) + await inspectPage.goto(pathToFileURL(exported.htmlPath!).href) + await inspectPage.waitForFunction(() => Array.from(document.images).every((image) => image.complete)) + const metrics = await inspectPage.evaluate(() => { + const rect = (element: Element) => { + const box = element.getBoundingClientRect() + return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height } + } + const overlaps = (first: ReturnType, second: ReturnType) => + first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top + const avatars = Array.from(document.querySelectorAll('img.tm-avatar')).map((element) => { + const box = rect(element) + const parent = element.closest('.tm-message,.tm-participant,.tm-ranking-item,.tm-fragment,.report') + return { + classes: element.className, + box, + parent: parent ? rect(parent) : null, + display: getComputedStyle(element).display, + objectFit: getComputedStyle(element).objectFit + } + }) + const messages = Array.from(document.querySelectorAll('.tm-message')).map((element) => { + const avatar = element.querySelector('img.tm-avatar') + const body = element.querySelector('.tm-message__body') + const author = element.querySelector('.tm-message__author') + const timestamp = element.querySelector('.tm-message__time') + return { + message: rect(element), + avatar: avatar ? rect(avatar) : null, + body: body ? rect(body) : null, + author: author ? rect(author) : null, + timestamp: timestamp ? rect(timestamp) : null, + authorTimestampOverlap: author && timestamp ? overlaps(rect(author), rect(timestamp)) : false + } + }) + return { + scrollWidth: document.documentElement.scrollWidth, + imageFailures: Array.from(document.images).filter((image) => image.naturalWidth === 0).length, + avatars, + messages, + fallbackCount: document.querySelectorAll('.tm-avatar--fallback').length + } + }) + expect(metrics.scrollWidth).toBeLessThanOrEqual(viewportWidth + 1) + expect(metrics.imageFailures).toBe(0) + expect(metrics.fallbackCount).toBeGreaterThanOrEqual(4) + for (const avatar of metrics.avatars) { + expect(avatar.display).not.toBe('none') + expect(avatar.objectFit).toBe('cover') + expect(avatar.box.width).toBeGreaterThan(0) + expect(avatar.box.height).toBeGreaterThan(0) + expect(Math.abs(avatar.box.width - avatar.box.height)).toBeLessThanOrEqual(1) + if (avatar.classes.includes('tm-avatar--message')) { + expect(avatar.box.width).toBeGreaterThanOrEqual(28) + expect(avatar.box.width).toBeLessThanOrEqual(44) + } + if (avatar.classes.includes('tm-avatar--participant')) { + expect(avatar.box.width).toBeGreaterThanOrEqual(18) + expect(avatar.box.width).toBeLessThanOrEqual(28) + } + expect(avatar.parent).not.toBeNull() + expect(avatar.box.width).toBeLessThanOrEqual(avatar.parent!.width + 1) + expect(avatar.box.height).toBeLessThanOrEqual(avatar.parent!.height + 1) + } + for (const message of metrics.messages) { + expect(message.body?.width || 0).toBeGreaterThan(0) + expect(message.body!.right).toBeLessThanOrEqual(viewportWidth + 1) + expect(message.authorTimestampOverlap).toBe(false) + expect(message.avatar!.right).toBeLessThanOrEqual(message.body!.left + 1) + } + await inspectPage.screenshot({ path: testInfo.outputPath(`fragment-contract-${viewportWidth}.png`), fullPage: true }) + await inspectPage.close() + expect(externalRequests).toEqual([]) + expect(badResponses).toEqual([]) + } + } finally { + await browser.close() + } + } finally { + await app.close() + rmSync(fixtureDir, { recursive: true, force: true }) + rmSync(outputDir, { recursive: true, force: true }) + rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/tests/fixtures/report-template-production-fragments.json b/tests/fixtures/report-template-production-fragments.json new file mode 100644 index 0000000..a77c7e8 --- /dev/null +++ b/tests/fixtures/report-template-production-fragments.json @@ -0,0 +1,134 @@ +{ + "metadata": { + "groupName": "Production Fragment 验收组", + "reportDate": "2026-09-09", + "dateRange": "09:00-18:30", + "messageCount": 128, + "activeUsers": 9, + "timeSpan": "9 h 30 min", + "generatedAt": "2026-09-09 18:35", + "recordNote": "虚构数据:仅用于生产 fragment UI contract 验收。", + "footerNote": "不对应真实聊天记录。", + "heroParticipants": ["真实图片用户", "无头像", "王小明"], + "avatars": { + "真实图片用户": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=" + }, + "reportMode": "full" + }, + "report": { + "overview": "真实 renderer 生成的 fragment 压力 fixture,覆盖头像、fallback、长昵称、长正文与空模块。", + "hero": { + "headline": "生产 Fragment 压力验收", + "summary": "头像必须保持受保护的几何尺寸,文本必须在桌面和移动宽度下可读。", + "keyTakeaway": "模板主题可以变化,但不能把生产 fragment 挤坏。", + "pendingNote": "", + "statusLine": "使用真实与 fallback 头像混合场景。" + }, + "topics": [ + { + "title": "超长话题标题:接口回归、缓存排查与多端发布窗口协调需要持续跟踪", + "timeRange": "09:10-11:40", + "heat": "高", + "participants": ["真实图片用户", "无头像", "王小明", "赵小红", "陈晨", "李雷", "韩梅梅", "超长昵称示例用户(含 &<> 特殊字符)以及更多文字"], + "summary": "多个参与者同时讨论时,参与者头像与昵称应换行而不突破 topic 容器。", + "conclusions": [{ "text": "保留最小可读正文宽度,并保证 fallback 与真实图片头像的几何一致。" }], + "keywords": ["头像", "长昵称", "移动端", "contract"] + }, + { + "title": "第二个话题", + "timeRange": "14:00-15:30", + "heat": "中", + "participants": ["无头像"], + "summary": "单参与者与空 optional 模块不应留下异常空白。", + "conclusions": [], + "keywords": ["空模块"] + } + ], + "resources": [], + "importantMessages": [ + { + "sender": "真实图片用户", + "time": "10:01", + "content": "普通图片头像与普通正文。", + "note": "图片头像" + }, + { + "sender": "超长昵称示例用户(含 &<> 特殊字符)以及更多文字", + "time": "10:02", + "content": "这是一段包含中文、English、emoji 🙂、URL-like https://example.invalid/path?long=query 与超长连续字符串abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 的多行正文。\n第二行仍应自然换行,且不能被头像、昵称或时间挤压。", + "note": "长昵称与长正文" + }, + { + "sender": "无头像", + "time": "10:03", + "content": "fallback avatar 使用单字名称。", + "note": "单字 fallback" + }, + { + "sender": "王小明", + "time": "10:04", + "content": "fallback avatar 使用多字名称。", + "note": "多字 fallback" + } + ], + "quotes": [ + { + "messages": [ + { "sender": "真实图片用户", "content": "普通金句与真实图片头像。" }, + { "sender": "无头像", "content": "fallback 头像也必须保持与消息头像相同的安全几何。" } + ], + "note": "带作者的普通金句。" + }, + { + "messages": [ + { "sender": "超长昵称示例用户(含 &<> 特殊字符)以及更多文字", "content": "没有额外作者字段的长金句:当内容足够长时,消息容器的正文宽度仍必须大于零,并能在 430px 视口中换行。" } + ], + "note": "" + } + ], + "qa": [ + { "question": "长正文会不会压住头像?", "answer": "不会,正文容器具有最小可用宽度与换行保护。", "answerer": "无头像" } + ], + "todos": [ + { "task": "完成 Production Fragment UI Contract 验收记录", "owner": "真实图片用户", "deadline": "今晚", "topic": "模板", "note": "正常 TODO" } + ], + "unresolved": [ + { "question": "超长连续字符串在窄屏是否溢出?", "owner": "王小明", "status": "待验证", "note": "使用 guardrail 自动检查。", "lastDiscussedAt": "18:20" } + ], + "storylines": [], + "reversals": [], + "participantChains": [], + "analytics": { + "topicHeat": [], + "activeTimeline": "09:00-18:30", + "topSpeakers": [ + { "name": "真实图片用户", "count": 34 }, + { "name": "无头像", "count": 21 }, + { "name": "王小明", "count": 15 } + ], + "voiceLeaderboard": [] + }, + "keywords": ["production", "avatar", "fallback", "长昵称", "长正文"], + "media": { "gallery": [], "voiceHighlights": [], "funBadges": [] }, + "sectionMeta": { + "topics": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 }, + "importantMessages": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 4, "displayedCount": 4 }, + "moments": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 }, + "actions": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 2, "displayedCount": 2 }, + "qa": { "enabled": true, "importance": 1, "confidence": 1, "totalCount": 1, "displayedCount": 1 }, + "resources": { "enabled": false, "importance": 0, "confidence": 1, "totalCount": 0, "displayedCount": 0 } + }, + "summaryStats": { + "messageCount": 128, + "activeUsers": 9, + "topicCount": 2, + "mediaCount": 0, + "imageCount": 0, + "voiceCount": 0, + "stickerCount": 0, + "conclusionCount": 1, + "todoCount": 1, + "unresolvedCount": 1 + } + } +}