Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
1 change: 1 addition & 0 deletions src/components/ChatBox/MessageItem/MarkDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:');
Expand Down
89 changes: 25 additions & 64 deletions src/components/Folder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2421,72 +2426,14 @@ function HtmlRenderer({
return;
}

// Find all img tags with relative paths (match various formats)
const imgRegex = /<img\s+([^>]*?)(?:\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 <img ...> and <img ... />
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
? `<img ${newAttributes} />`
: `<img ${newAttributes}>`;

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) {
Expand Down Expand Up @@ -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('');
Expand Down
208 changes: 208 additions & 0 deletions src/lib/htmlLocalAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// ========= 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<string>
): Promise<string> {
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;

const quotedPathMatcher = new RegExp(
`(["'])${escapeRegExp(relativePath)}\\1`
);
if (!quotedPathMatcher.test(result)) 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<string>
): Promise<string> {
if (typeof DOMParser === 'undefined') {
return html;
}

const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const doctype = html.match(/<!doctype[^>]*>/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}`;
}
10 changes: 10 additions & 0 deletions src/lib/htmlSanitization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<script[\s\S]*?<\/script>/gi, '');
}
9 changes: 9 additions & 0 deletions test/unit/electron/main/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading