From ead386c9ace39b158f51df970f1f55fc0e04bac5 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 25 May 2026 12:48:12 +0100 Subject: [PATCH 1/2] Normalize Windows drive-letter localfile URLs before resolving file paths and improve local HTML asset inlining for rendered project content. --- electron/main/index.ts | 3 +- .../ChatBox/MessageItem/MarkDown.tsx | 1 + src/components/Folder/index.tsx | 89 +++----- src/lib/htmlLocalAssets.ts | 203 ++++++++++++++++++ src/lib/htmlSanitization.ts | 10 + test/unit/electron/main/index.test.ts | 9 + test/unit/lib/htmlLocalAssets.test.ts | 116 ++++++++++ test/unit/lib/htmlSanitization.test.ts | 42 ++++ 8 files changed, 408 insertions(+), 65 deletions(-) create mode 100644 src/lib/htmlLocalAssets.ts create mode 100644 test/unit/lib/htmlLocalAssets.test.ts create mode 100644 test/unit/lib/htmlSanitization.test.ts diff --git a/electron/main/index.ts b/electron/main/index.ts index 7ef8765c6..481d3ed92 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2894,7 +2894,8 @@ app.whenReady().then(async () => { // Register protocol handler for both default session and main window session const protocolHandler = async (request: Request) => { const url = decodeURIComponent(request.url.replace('localfile://', '')); - const filePath = path.resolve(path.normalize(url)); + const normalizedUrl = url.replace(/^\/([A-Za-z]:[\\/])/, '$1'); + const filePath = path.resolve(path.normalize(normalizedUrl)); log.info(`[PROTOCOL] Handling localfile request: ${request.url}`); log.info(`[PROTOCOL] Resolved path: ${filePath}`); diff --git a/src/components/ChatBox/MessageItem/MarkDown.tsx b/src/components/ChatBox/MessageItem/MarkDown.tsx index dd3d18f8a..5b0db270d 100644 --- a/src/components/ChatBox/MessageItem/MarkDown.tsx +++ b/src/components/ChatBox/MessageItem/MarkDown.tsx @@ -168,6 +168,7 @@ export const MarkDown = memo( // Check if it's a relative path const isRelative = src && + !src.includes('${') && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:'); diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index 86064097b..989f85bf0 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -61,6 +61,11 @@ import { deferInlineScriptsUntilLoad, injectFontStyles, } from '@/lib/htmlFontStyles'; +import { + inlineLocalHtmlImgElements, + inlineLocalProjectImagePaths, + toLocalFileUrl, +} from '@/lib/htmlLocalAssets'; import { containsDangerousContent } from '@/lib/htmlSanitization'; import { useAuthStore } from '@/store/authStore'; import { useTranslation } from 'react-i18next'; @@ -2421,72 +2426,14 @@ function HtmlRenderer({ return; } - // Find all img tags with relative paths (match various formats) - const imgRegex = /]*?)(?:\s*\/\s*>|>)/gi; - const matches = Array.from(html.matchAll(imgRegex)); - - // Process each img tag - const processedImages = await Promise.all( - matches.map(async (match) => { - const fullMatch = match[0]; - const attributes = match[1]; - // Reconstruct the img tag to handle both and - const imgTag = fullMatch; - - // Extract src attribute - const srcMatch = attributes.match(/src\s*=\s*["']([^"']+)["']/i); - if (!srcMatch) return { original: imgTag, processed: imgTag }; - - const src = srcMatch[1]; - - // Skip if src is already absolute (http, https, data:, localfile:) - if ( - src.startsWith('http://') || - src.startsWith('https://') || - src.startsWith('data:') || - src.startsWith('localfile://') - ) { - return { original: imgTag, processed: imgTag }; - } - - // Build full path for relative image - const imagePath = joinPath(htmlDir, src); - - try { - if (!electronAPI?.readFileAsDataUrl) { - return { original: imgTag, processed: imgTag }; - } - // Read image as data URL - const dataUrl = await electronAPI.readFileAsDataUrl(imagePath); - - // Replace src with data URL - const newAttributes = attributes.replace( - /src\s*=\s*["'][^"']+["']/i, - `src="${dataUrl}"` - ); - // Preserve the original tag format (self-closing or not) - const isSelfClosing = imgTag.trim().endsWith('/>'); - const processedTag = isSelfClosing - ? `` - : ``; - - return { original: imgTag, processed: processedTag }; - } catch (error) { - console.error(`Failed to load image: ${imagePath}`, error); - // Keep original tag if image loading fails - return { original: imgTag, processed: imgTag }; - } - }) - ); - - // Replace all img tags in HTML let processedHtmlContent = html; - processedImages.forEach(({ original, processed }) => { - processedHtmlContent = processedHtmlContent.replace( - original, - processed + if (electronAPI?.readFileAsDataUrl) { + processedHtmlContent = await inlineLocalHtmlImgElements( + processedHtmlContent, + htmlDir, + electronAPI.readFileAsDataUrl ); - }); + } // Load and inject CSS files, replacing external link tags for (const cssFile of cssFiles) { @@ -2548,6 +2495,20 @@ function HtmlRenderer({ } } + if (electronAPI?.readFileAsDataUrl) { + processedHtmlContent = await inlineLocalProjectImagePaths( + processedHtmlContent, + htmlDir, + projectFiles, + electronAPI.readFileAsDataUrl + ); + } + + processedHtmlContent = injectBaseHref( + processedHtmlContent, + toLocalFileUrl(htmlDir) + ); + // Final check for dangerous content after all processing (including injected JS) if (containsDangerousContent(processedHtmlContent)) { setProcessedHtml(''); diff --git a/src/lib/htmlLocalAssets.ts b/src/lib/htmlLocalAssets.ts new file mode 100644 index 000000000..90b59951b --- /dev/null +++ b/src/lib/htmlLocalAssets.ts @@ -0,0 +1,203 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +const IMAGE_EXTENSIONS = [ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'svg', + 'bmp', + 'ico', + 'avif', +]; + +function normalizePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function joinPath(...paths: string[]): string { + return paths + .filter(Boolean) + .map((pathPart) => normalizePath(pathPart)) + .join('/') + .replace(/\/+/g, '/'); +} + +function encodePathSegments(filePath: string): string { + const normalizedPath = normalizePath(filePath); + + if (normalizedPath.startsWith('//')) { + const withoutLeadingSlashes = normalizedPath.replace(/^\/+/, ''); + const [host, ...pathSegments] = withoutLeadingSlashes.split('/'); + const encodedPath = pathSegments.map(encodeURIComponent).join('/'); + return encodedPath ? `//${host}/${encodedPath}` : `//${host}/`; + } + + const hasWindowsDrive = /^[A-Za-z]:\//.test(normalizedPath); + if (hasWindowsDrive) { + const [drive, ...pathSegments] = normalizedPath.split('/'); + const encodedPath = pathSegments.map(encodeURIComponent).join('/'); + return encodedPath ? `/${drive}/${encodedPath}` : `/${drive}/`; + } + + return normalizedPath + .split('/') + .map((segment, index) => + index === 0 && segment === '' ? '' : encodeURIComponent(segment) + ) + .join('/'); +} + +export function toLocalFileUrl(filePath: string): string { + if ( + filePath.startsWith('localfile://') || + filePath.startsWith('file://') || + filePath.startsWith('http://') || + filePath.startsWith('https://') || + filePath.startsWith('blob:') || + filePath.startsWith('data:') + ) { + const normalized = + filePath.startsWith('file://') && !filePath.startsWith('localfile://') + ? filePath.replace(/^file:\/\//, 'localfile://') + : filePath; + return normalized.endsWith('/') ? normalized : `${normalized}/`; + } + + const encodedPath = encodePathSegments(filePath); + const localFileUrl = `localfile://${encodedPath}`; + return localFileUrl.endsWith('/') ? localFileUrl : `${localFileUrl}/`; +} + +function isStaticImageSrc(src: string): boolean { + return !src.includes('${'); +} + +function isSpecialImageSrc(src: string): boolean { + const normalizedSrc = src.trim().toLowerCase(); + return ( + !normalizedSrc || + normalizedSrc.startsWith('http://') || + normalizedSrc.startsWith('https://') || + normalizedSrc.startsWith('//') || + normalizedSrc.startsWith('data:') || + normalizedSrc.startsWith('blob:') || + normalizedSrc.startsWith('localfile:') + ); +} + +export function getRelativePathFromDir( + baseDir: string, + filePath: string +): string | null { + const normalizedBase = normalizePath(baseDir).replace(/\/$/, ''); + const normalizedFile = normalizePath(filePath); + + if ( + normalizedFile !== normalizedBase && + !normalizedFile.startsWith(`${normalizedBase}/`) + ) { + return null; + } + + return normalizedFile.slice(normalizedBase.length + 1); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function isImagePath(pathValue: string): boolean { + const pathWithoutQuery = pathValue.split(/[?#]/)[0].toLowerCase(); + return IMAGE_EXTENSIONS.some((ext) => pathWithoutQuery.endsWith(`.${ext}`)); +} + +export interface LocalProjectImageFile { + path: string; + isFolder?: boolean; + isRemote?: boolean; +} + +export async function inlineLocalProjectImagePaths( + html: string, + htmlDir: string, + projectFiles: LocalProjectImageFile[], + readFileAsDataUrl: (path: string) => Promise +): Promise { + let result = html; + + for (const file of projectFiles) { + if (file.isFolder || file.isRemote) continue; + + const relativePath = getRelativePathFromDir(htmlDir, file.path); + if (!relativePath || !isImagePath(relativePath)) continue; + + try { + const dataUrl = await readFileAsDataUrl(file.path); + const quotedPathPattern = new RegExp( + `(["'])${escapeRegExp(relativePath)}\\1`, + 'g' + ); + result = result.replace(quotedPathPattern, (_match, quote: string) => { + return `${quote}${dataUrl}${quote}`; + }); + } catch (error) { + console.warn( + '[HtmlRenderer] Failed to inline local project image:', + relativePath, + error + ); + } + } + + return result; +} + +export async function inlineLocalHtmlImgElements( + html: string, + htmlDir: string, + readFileAsDataUrl: (path: string) => Promise +): Promise { + if (typeof DOMParser === 'undefined') { + return html; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const doctype = html.match(/]*>/i)?.[0] || ''; + + await Promise.all( + Array.from(doc.querySelectorAll('img[src]')).map(async (image) => { + const src = image.getAttribute('src'); + if (!src || !isStaticImageSrc(src) || isSpecialImageSrc(src)) { + return; + } + + try { + const dataUrl = await readFileAsDataUrl(joinPath(htmlDir, src)); + image.setAttribute('src', dataUrl); + } catch (error) { + console.error( + `[HtmlRenderer] Failed to load image: ${joinPath(htmlDir, src)}`, + error + ); + } + }) + ); + + const serialized = doc.documentElement?.outerHTML || html; + return `${doctype}${serialized}`; +} diff --git a/src/lib/htmlSanitization.ts b/src/lib/htmlSanitization.ts index a07bd7ac1..c3c79b45c 100644 --- a/src/lib/htmlSanitization.ts +++ b/src/lib/htmlSanitization.ts @@ -155,3 +155,13 @@ export function sanitizeHtmlStrict(html: string): string { } return sanitizeHtml(html); } + +/** Skip JS template literal expressions such as `${escapeHtml(node.image)}`. */ +export function isStaticImageSrc(src: string): boolean { + return !src.includes('${'); +} + +/** Remove script blocks so img tag scans match real HTML, not JS template strings. */ +export function stripScriptBlocks(html: string): string { + return html.replace(//gi, ''); +} diff --git a/test/unit/electron/main/index.test.ts b/test/unit/electron/main/index.test.ts index f75310e85..d769a8b65 100644 --- a/test/unit/electron/main/index.test.ts +++ b/test/unit/electron/main/index.test.ts @@ -1370,6 +1370,15 @@ describe('Electron Main Index Functions', () => { false ); }); + + it('should preserve Windows drive letters from standard localfile URLs', () => { + const decodedUrl = '/C:/Users/test/canvas_map/image.png'; + const normalizedUrl = decodedUrl.replace(/^\/([A-Za-z]:[\\/])/, '$1'); + + expect(path.win32.resolve(path.win32.normalize(normalizedUrl))).toBe( + 'C:\\Users\\test\\canvas_map\\image.png' + ); + }); }); describe('Application Lifecycle', () => { diff --git a/test/unit/lib/htmlLocalAssets.test.ts b/test/unit/lib/htmlLocalAssets.test.ts new file mode 100644 index 000000000..8882a4dc9 --- /dev/null +++ b/test/unit/lib/htmlLocalAssets.test.ts @@ -0,0 +1,116 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { describe, expect, it, vi } from 'vitest'; + +import { + getRelativePathFromDir, + inlineLocalHtmlImgElements, + inlineLocalProjectImagePaths, + toLocalFileUrl, +} from '@/lib/htmlLocalAssets'; + +describe('toLocalFileUrl', () => { + it('converts absolute unix paths to localfile base hrefs', () => { + expect(toLocalFileUrl('/Users/test/canvas_map')).toBe( + 'localfile:///Users/test/canvas_map/' + ); + }); + + it('preserves existing localfile urls and trailing slash', () => { + expect(toLocalFileUrl('localfile:///Users/test/canvas_map/')).toBe( + 'localfile:///Users/test/canvas_map/' + ); + }); + + it('emits standard localfile urls for Windows drive paths', () => { + expect(toLocalFileUrl('C:\\Users\\test\\canvas_map')).toBe( + 'localfile:///C:/Users/test/canvas_map/' + ); + }); +}); + +describe('getRelativePathFromDir', () => { + it('returns relative image paths within the html directory', () => { + expect( + getRelativePathFromDir( + '/Users/test/canvas_map', + '/Users/test/canvas_map/assets/home.png' + ) + ).toBe('assets/home.png'); + }); +}); + +describe('inlineLocalHtmlImgElements', () => { + it('rewrites real image elements without replacing identical script strings', async () => { + const html = ` + + home + `; + + const readFileAsDataUrl = vi + .fn() + .mockResolvedValue('data:image/png;base64,abc123'); + + const result = await inlineLocalHtmlImgElements( + html, + '/Users/test/canvas_map', + readFileAsDataUrl + ); + + expect(readFileAsDataUrl).toHaveBeenCalledTimes(1); + expect(readFileAsDataUrl).toHaveBeenCalledWith( + '/Users/test/canvas_map/assets/home.png' + ); + expect(result).toContain( + `const thumbnail = 'home';` + ); + expect(result).toContain(' { + it('replaces quoted relative image paths with data urls', async () => { + const html = ` + + `; + + const readFileAsDataUrl = vi + .fn() + .mockResolvedValue('data:image/png;base64,abc123'); + + const result = await inlineLocalProjectImagePaths( + html, + '/Users/test/canvas_map', + [ + { + path: '/Users/test/canvas_map/assets/home.png', + }, + ], + readFileAsDataUrl + ); + + expect(readFileAsDataUrl).toHaveBeenCalledWith( + '/Users/test/canvas_map/assets/home.png' + ); + expect(result).toContain('data:image/png;base64,abc123'); + expect(result).not.toContain('"assets/home.png"'); + }); +}); diff --git a/test/unit/lib/htmlSanitization.test.ts b/test/unit/lib/htmlSanitization.test.ts new file mode 100644 index 000000000..dca8e5394 --- /dev/null +++ b/test/unit/lib/htmlSanitization.test.ts @@ -0,0 +1,42 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { describe, expect, it } from 'vitest'; + +import { isStaticImageSrc, stripScriptBlocks } from '@/lib/htmlSanitization'; + +describe('isStaticImageSrc', () => { + it('accepts static relative paths', () => { + expect(isStaticImageSrc('assets/home.png')).toBe(true); + }); + + it('rejects JS template literal expressions', () => { + expect(isStaticImageSrc('${escapeHtml(node.image)}')).toBe(false); + expect(isStaticImageSrc('assets/${node.id}.png')).toBe(false); + }); +}); + +describe('stripScriptBlocks', () => { + it('removes script blocks so img scans skip JS template strings', () => { + const html = ` + home + + `; + + expect(stripScriptBlocks(html)).not.toContain('escapeHtml'); + expect(stripScriptBlocks(html)).toContain('assets/home.png'); + }); +}); From 3ac81884935dd135add27bc82daf6642d34e1276 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Wed, 3 Jun 2026 18:22:59 +0800 Subject: [PATCH 2/2] fix: avoid reading unreferenced project images in HTML preview --- src/lib/htmlLocalAssets.ts | 5 ++++ test/unit/lib/htmlLocalAssets.test.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/lib/htmlLocalAssets.ts b/src/lib/htmlLocalAssets.ts index 90b59951b..29ea3f6bf 100644 --- a/src/lib/htmlLocalAssets.ts +++ b/src/lib/htmlLocalAssets.ts @@ -145,6 +145,11 @@ export async function inlineLocalProjectImagePaths( const relativePath = getRelativePathFromDir(htmlDir, file.path); if (!relativePath || !isImagePath(relativePath)) continue; + const quotedPathMatcher = new RegExp( + `(["'])${escapeRegExp(relativePath)}\\1` + ); + if (!quotedPathMatcher.test(result)) continue; + try { const dataUrl = await readFileAsDataUrl(file.path); const quotedPathPattern = new RegExp( diff --git a/test/unit/lib/htmlLocalAssets.test.ts b/test/unit/lib/htmlLocalAssets.test.ts index 8882a4dc9..fdc89a1eb 100644 --- a/test/unit/lib/htmlLocalAssets.test.ts +++ b/test/unit/lib/htmlLocalAssets.test.ts @@ -113,4 +113,37 @@ describe('inlineLocalProjectImagePaths', () => { expect(result).toContain('data:image/png;base64,abc123'); expect(result).not.toContain('"assets/home.png"'); }); + + it('does not read project images that are not referenced in the html', async () => { + const html = ` + + `; + + const readFileAsDataUrl = vi + .fn() + .mockResolvedValue('data:image/png;base64,abc123'); + + await inlineLocalProjectImagePaths( + html, + '/Users/test/canvas_map', + [ + { + path: '/Users/test/canvas_map/assets/home.png', + }, + { + path: '/Users/test/canvas_map/assets/unused.png', + }, + ], + readFileAsDataUrl + ); + + expect(readFileAsDataUrl).toHaveBeenCalledTimes(1); + expect(readFileAsDataUrl).toHaveBeenCalledWith( + '/Users/test/canvas_map/assets/home.png' + ); + }); });