mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 fix: resolve local markdown image assets (#15729)
* 🐛 fix: resolve local markdown image assets * 🐛 fix: preserve UNC markdown asset paths * 🔒️ fix: restrict markdown image previews to images * ♻️ refactor: pass markdown image preview accept directly
This commit is contained in:
@@ -437,11 +437,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreviewUrl({
|
||||
accept,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
|
||||
try {
|
||||
const url = await this.app.localFileProtocolManager.createPreviewUrl({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
@@ -459,11 +461,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreview({
|
||||
accept,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
|
||||
try {
|
||||
const preview = await this.app.localFileProtocolManager.readPreviewFile({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
|
||||
@@ -225,6 +225,7 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -247,6 +248,28 @@ describe('LocalFileCtr', () => {
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward image-only preview URL constraints', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
|
||||
'localfile://file/workspace/image.png?token=abc',
|
||||
);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
accept: 'image',
|
||||
path: '/workspace/image.png',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/workspace/image.png?token=abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalFilePreview', () => {
|
||||
@@ -263,6 +286,7 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -289,6 +313,34 @@ describe('LocalFileCtr', () => {
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward image-only preview read constraints', async () => {
|
||||
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
|
||||
buffer: Buffer.from('image-bytes'),
|
||||
contentType: 'image/png',
|
||||
realPath: '/workspace/image.png',
|
||||
});
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
path: '/workspace/image.png',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
preview: {
|
||||
base64: Buffer.from('image-bytes').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
|
||||
@@ -54,6 +54,21 @@ export interface PreviewFileReadResult {
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
type PreviewFileAccept = 'image';
|
||||
|
||||
const normalizeContentType = (contentType: string): string =>
|
||||
contentType.split(';')[0].trim().toLowerCase();
|
||||
|
||||
const isAcceptedPreviewContentType = (
|
||||
contentType: string,
|
||||
accept?: PreviewFileAccept,
|
||||
): boolean => {
|
||||
if (!accept) return true;
|
||||
|
||||
const normalizedContentType = normalizeContentType(contentType);
|
||||
return accept === 'image' && normalizedContentType.startsWith('image/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom `localfile://` protocol for project file previews.
|
||||
*
|
||||
@@ -213,16 +228,26 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
async createPreviewUrl({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
if (!normalizedFilePath) return null;
|
||||
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
const realFilePath = accept
|
||||
? (
|
||||
await this.readPreviewFile({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
})
|
||||
)?.realPath
|
||||
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
@@ -237,9 +262,11 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
async readPreviewFile({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<PreviewFileReadResult | null> {
|
||||
@@ -250,9 +277,12 @@ export class LocalFileProtocolManager {
|
||||
if (!fileStat.isFile()) return null;
|
||||
|
||||
const buffer = await readFile(realFilePath);
|
||||
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
|
||||
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType: resolveLocalFileMimeType(realFilePath, buffer),
|
||||
contentType,
|
||||
realPath: realFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +119,21 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('does not mint image-only preview URLs for text files', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project');
|
||||
mockReadFile.mockResolvedValue(Buffer.from('const value = 1;'));
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
accept: 'image',
|
||||
filePath: '/Users/alice/project/App.tsx',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded characters in the path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
@@ -296,6 +311,21 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
});
|
||||
|
||||
it('does not return text payloads for image-only preview reads', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
|
||||
|
||||
const result = await manager.readPreviewFile({
|
||||
accept: 'image',
|
||||
filePath: '/Users/alice/project/.env',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
|
||||
});
|
||||
|
||||
it('does not read preview payloads outside the approved workspace root', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
|
||||
@@ -275,9 +275,17 @@ export const deviceRouter = router({
|
||||
* receives render data, not a `localfile://` URL; saving remains unsupported.
|
||||
*/
|
||||
getLocalFilePreview: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string(), workingDirectory: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
accept: z.enum(['image']).optional(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
deviceGateway.getLocalFilePreview({
|
||||
accept: input.accept,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -718,7 +718,37 @@ describe('DeviceGateway', () => {
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { path: '/proj/App.tsx', workingDirectory: '/proj' },
|
||||
params: { accept: undefined, path: '/proj/App.tsx', workingDirectory: '/proj' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards image-only preview constraints to the device rpc', async () => {
|
||||
configure();
|
||||
const data = {
|
||||
preview: {
|
||||
base64: 'aW1hZ2U=',
|
||||
contentType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockClient.invokeRpc.mockResolvedValue({ data, success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
await proxy.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
deviceId: 'dev-1',
|
||||
path: '/proj/image.png',
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { accept: 'image', path: '/proj/image.png', workingDirectory: '/proj' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -473,20 +473,24 @@ export class DeviceGateway {
|
||||
* exposing a `localfile://` URL to web callers.
|
||||
*/
|
||||
async getLocalFilePreview(params: {
|
||||
accept?: 'image';
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<DeviceLocalFilePreviewResult> {
|
||||
const { userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
|
||||
const { accept, userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceLocalFilePreviewResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'getLocalFilePreview', params: { path, workingDirectory } },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { accept, path, workingDirectory },
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
|
||||
@@ -111,7 +111,10 @@ export interface AuditSafePathsResult {
|
||||
allSafe: boolean;
|
||||
}
|
||||
|
||||
export type LocalFilePreviewAccept = 'image';
|
||||
|
||||
export interface LocalFilePreviewUrlParams {
|
||||
accept?: LocalFilePreviewAccept;
|
||||
path: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getFileExtension,
|
||||
resolveMarkdownRelativeAssetPath,
|
||||
} from '@/features/Portal/LocalFile/Body.helpers';
|
||||
|
||||
describe('LocalFile Body helpers', () => {
|
||||
describe('getFileExtension', () => {
|
||||
it('returns the final extension and ignores dotfiles', () => {
|
||||
expect(getFileExtension('report.md')).toBe('md');
|
||||
expect(getFileExtension('/repo/.gitignore')).toBe('');
|
||||
expect(getFileExtension('/repo/archive.tar.gz')).toBe('gz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMarkdownRelativeAssetPath', () => {
|
||||
it('resolves relative markdown image paths against the markdown file directory', () => {
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({
|
||||
markdownFilePath: '/repo/.records/report.md',
|
||||
src: 'assets/screenshot.png',
|
||||
}),
|
||||
).toBe('/repo/.records/assets/screenshot.png');
|
||||
});
|
||||
|
||||
it('handles dot segments and encoded path segments', () => {
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({
|
||||
markdownFilePath: '/repo/docs/reports/report.md',
|
||||
src: './assets/My%20Image.png?raw=1#preview',
|
||||
}),
|
||||
).toBe('/repo/docs/reports/assets/My Image.png');
|
||||
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({
|
||||
markdownFilePath: '/repo/docs/reports/report.md',
|
||||
src: '../shared/diagram.png',
|
||||
}),
|
||||
).toBe('/repo/docs/shared/diagram.png');
|
||||
});
|
||||
|
||||
it('keeps Windows-style paths on Windows-style inputs', () => {
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({
|
||||
markdownFilePath: 'C:\\repo\\docs\\report.md',
|
||||
src: 'assets\\screen.png',
|
||||
}),
|
||||
).toBe('C:\\repo\\docs\\assets\\screen.png');
|
||||
});
|
||||
|
||||
it('preserves UNC network share prefixes', () => {
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({
|
||||
markdownFilePath: '\\\\server\\share\\docs\\README.md',
|
||||
src: 'assets\\screen.png',
|
||||
}),
|
||||
).toBe('\\\\server\\share\\docs\\assets\\screen.png');
|
||||
});
|
||||
|
||||
it('ignores URLs, root-relative paths, anchors, and empty values', () => {
|
||||
const markdownFilePath = '/repo/report.md';
|
||||
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({ markdownFilePath, src: 'https://example.com/a.png' }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({ markdownFilePath, src: 'data:image/png;base64,abc' }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({ markdownFilePath, src: '//example.com/a.png' }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveMarkdownRelativeAssetPath({ markdownFilePath, src: '/assets/a.png' }),
|
||||
).toBeUndefined();
|
||||
expect(resolveMarkdownRelativeAssetPath({ markdownFilePath, src: '#hash' })).toBeUndefined();
|
||||
expect(resolveMarkdownRelativeAssetPath({ markdownFilePath, src: '' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,3 +51,87 @@ export const getFileExtension = (filename: string): string => {
|
||||
if (dotIdx < 0) return '';
|
||||
return base.slice(dotIdx + 1);
|
||||
};
|
||||
|
||||
const URL_LIKE_PREFIX = /^(?:[a-z][a-z\d+.-]*:|\/\/|#|\/)/i;
|
||||
|
||||
const splitAssetPath = (src: string): string => {
|
||||
const queryIndex = src.indexOf('?');
|
||||
const hashIndex = src.indexOf('#');
|
||||
const suffixIndex = [queryIndex, hashIndex]
|
||||
.filter((index) => index >= 0)
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
return (suffixIndex === undefined ? src : src.slice(0, suffixIndex)).trim();
|
||||
};
|
||||
|
||||
const decodePathSegment = (segment: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
};
|
||||
|
||||
const toSlashPath = (filePath: string): string => filePath.replaceAll('\\', '/');
|
||||
|
||||
const fromSlashPath = (filePath: string, sourcePath: string): string => {
|
||||
const usesWindowsSeparator = sourcePath.includes('\\') && !sourcePath.includes('/');
|
||||
return usesWindowsSeparator ? filePath.replaceAll('/', '\\') : filePath;
|
||||
};
|
||||
|
||||
const normalizeSlashPath = (
|
||||
filePath: string,
|
||||
{ preserveLeadingDoubleSlash = false }: { preserveLeadingDoubleSlash?: boolean } = {},
|
||||
): string => {
|
||||
const leadingEmptySegmentLimit = preserveLeadingDoubleSlash && filePath.startsWith('//') ? 2 : 1;
|
||||
const normalizedSegments: string[] = [];
|
||||
|
||||
for (const segment of filePath.split('/')) {
|
||||
if (!segment || segment === '.') {
|
||||
if (
|
||||
segment === '' &&
|
||||
normalizedSegments.length < leadingEmptySegmentLimit &&
|
||||
normalizedSegments.every((item) => item === '')
|
||||
) {
|
||||
normalizedSegments.push('');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment === '..') {
|
||||
if (normalizedSegments.length > 1) normalizedSegments.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedSegments.push(segment);
|
||||
}
|
||||
|
||||
const normalized = normalizedSegments.join('/');
|
||||
return normalized || '/';
|
||||
};
|
||||
|
||||
export const resolveMarkdownRelativeAssetPath = ({
|
||||
markdownFilePath,
|
||||
src,
|
||||
}: {
|
||||
markdownFilePath: string;
|
||||
src?: string;
|
||||
}): string | undefined => {
|
||||
const assetPath = src ? splitAssetPath(src) : '';
|
||||
if (!assetPath || URL_LIKE_PREFIX.test(assetPath)) return;
|
||||
|
||||
const slashMarkdownPath = toSlashPath(markdownFilePath);
|
||||
const isUncPath = slashMarkdownPath.startsWith('//');
|
||||
const lastSeparatorIndex = slashMarkdownPath.lastIndexOf('/');
|
||||
const baseDirectory =
|
||||
lastSeparatorIndex > 0 ? slashMarkdownPath.slice(0, lastSeparatorIndex) : '';
|
||||
const assetSegments = assetPath.split(/[\\/]+/).map(decodePathSegment);
|
||||
const basePath =
|
||||
slashMarkdownPath.startsWith('/') && lastSeparatorIndex === 0 ? '/' : baseDirectory;
|
||||
const resolvedPath = normalizeSlashPath(
|
||||
basePath ? [basePath, ...assetSegments].join('/') : assetSegments.join('/'),
|
||||
{ preserveLeadingDoubleSlash: isUncPath },
|
||||
);
|
||||
|
||||
return fromSlashPath(resolvedPath, markdownFilePath);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type { MarkdownProps } from '@lobehub/ui';
|
||||
import { ActionIcon, Center, Empty, Flexbox, Icon, Markdown, Segmented, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { CodeIcon, EyeIcon, RefreshCwIcon } from 'lucide-react';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from '@/utils/skillMarkdown';
|
||||
|
||||
import { extensionToLanguage, getFileExtension } from './Body.helpers';
|
||||
import MarkdownImage from './MarkdownImage';
|
||||
|
||||
interface ImagePreviewProps {
|
||||
blob: Blob;
|
||||
@@ -115,12 +117,14 @@ interface TextPreviewPaneProps {
|
||||
activeTopicId?: string | null;
|
||||
content: string;
|
||||
contentType?: string;
|
||||
deviceId?: string;
|
||||
ext: string;
|
||||
filePath: string;
|
||||
onReload?: () => Promise<unknown> | void;
|
||||
onSaved?: (savedContent: string) => void;
|
||||
readOnly?: boolean;
|
||||
reloading?: boolean;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const TextPreviewPane = memo<TextPreviewPaneProps>(
|
||||
@@ -128,12 +132,14 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
|
||||
activeTopicId,
|
||||
content,
|
||||
contentType,
|
||||
deviceId,
|
||||
ext,
|
||||
filePath,
|
||||
onReload,
|
||||
onSaved,
|
||||
readOnly = false,
|
||||
reloading = false,
|
||||
workingDirectory,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isMarkdown = useMemo(() => MARKDOWN_EXTS.has(ext.toLowerCase()), [ext]);
|
||||
@@ -192,6 +198,20 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
|
||||
const previewTitle = isMarkdown
|
||||
? (frontmatterFields.name ?? '')
|
||||
: (filePath.split('/').at(-1) ?? filePath);
|
||||
const markdownComponents = useMemo(
|
||||
() =>
|
||||
({
|
||||
img: (props) => (
|
||||
<MarkdownImage
|
||||
{...props}
|
||||
deviceId={deviceId}
|
||||
markdownFilePath={filePath}
|
||||
workingDirectory={workingDirectory}
|
||||
/>
|
||||
),
|
||||
}) satisfies MarkdownProps['components'],
|
||||
[deviceId, filePath, workingDirectory],
|
||||
);
|
||||
|
||||
const [modeByScope, setModeByScope] = useState<Record<string, TextPreviewMode>>({});
|
||||
const modeScopeKey = `${activeTopicId ?? NO_TOPIC_KEY}:${filePath}`;
|
||||
@@ -259,7 +279,12 @@ const TextPreviewPane = memo<TextPreviewPaneProps>(
|
||||
{isMarkdown && mode === 'render' ? (
|
||||
<>
|
||||
<SkillFrontmatterPreviewCard metadata={frontmatterMetadata} />
|
||||
<Markdown style={{ paddingBlock: 8, paddingInline: 12 }}>{body}</Markdown>
|
||||
<Markdown
|
||||
components={markdownComponents}
|
||||
style={{ paddingBlock: 8, paddingInline: 12 }}
|
||||
>
|
||||
{body}
|
||||
</Markdown>
|
||||
</>
|
||||
) : showHtmlPreview ? (
|
||||
<InlineHtmlPreview content={editingValue} key={`${filePath}:${htmlPreviewRevision}`} />
|
||||
@@ -353,10 +378,12 @@ const ActiveFileView = memo<ActiveFileViewProps>(
|
||||
activeTopicId={activeTopicId}
|
||||
content={preview.content}
|
||||
contentType={preview.contentType}
|
||||
deviceId={deviceId}
|
||||
ext={ext}
|
||||
filePath={filePath}
|
||||
readOnly={!!deviceId}
|
||||
reloading={isValidating}
|
||||
workingDirectory={workingDirectory}
|
||||
onReload={handleReload}
|
||||
onSaved={handleSavedContent}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cssVar } from 'antd-style';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useClientDataSWR } from '@/libs/swr';
|
||||
import { type LocalFilePreview, projectFileService } from '@/services/projectFile';
|
||||
|
||||
import { resolveMarkdownRelativeAssetPath } from './Body.helpers';
|
||||
|
||||
interface MarkdownImageProps extends ComponentProps<'img'> {
|
||||
deviceId?: string;
|
||||
markdownFilePath: string;
|
||||
node?: unknown;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const MarkdownImage = memo<MarkdownImageProps>(
|
||||
({ alt, deviceId, markdownFilePath, node, src, style, workingDirectory, ...rest }) => {
|
||||
void node;
|
||||
|
||||
const markdownSrc = typeof src === 'string' ? src : undefined;
|
||||
const resolvedPath = useMemo(
|
||||
() => resolveMarkdownRelativeAssetPath({ markdownFilePath, src: markdownSrc }),
|
||||
[markdownFilePath, markdownSrc],
|
||||
);
|
||||
|
||||
const { data: preview } = useClientDataSWR<LocalFilePreview>(
|
||||
resolvedPath
|
||||
? ['local-markdown-image-preview', deviceId ?? 'local', resolvedPath, workingDirectory]
|
||||
: null,
|
||||
() =>
|
||||
projectFileService.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
deviceId,
|
||||
path: resolvedPath!,
|
||||
workingDirectory,
|
||||
}),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedPath || preview?.type !== 'image') {
|
||||
setImageSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(preview.blob);
|
||||
setImageSrc(objectUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [preview, resolvedPath]);
|
||||
|
||||
if (resolvedPath && !imageSrc) {
|
||||
return (
|
||||
<span
|
||||
aria-label={alt}
|
||||
role={alt ? 'img' : undefined}
|
||||
title={markdownSrc}
|
||||
style={{
|
||||
background: cssVar.colorFillQuaternary,
|
||||
borderRadius: 6,
|
||||
display: 'inline-block',
|
||||
minHeight: 120,
|
||||
width: 'min(100%, 320px)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
src={imageSrc ?? src}
|
||||
style={{ maxWidth: '100%', ...style }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MarkdownImage.displayName = 'MarkdownImage';
|
||||
|
||||
export default MarkdownImage;
|
||||
@@ -65,4 +65,62 @@ describe('localFileService', () => {
|
||||
}),
|
||||
).rejects.toThrow('outside safe path');
|
||||
});
|
||||
|
||||
it('forwards image-only preview constraints to Electron', async () => {
|
||||
const { localFileService } = await import('./localFileService');
|
||||
|
||||
mockLocalSystem.getLocalFilePreviewUrl.mockResolvedValue({
|
||||
success: true,
|
||||
url: 'localfile://preview/image.png',
|
||||
});
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return {
|
||||
blob: vi.fn(async () => new Blob(['image'])),
|
||||
headers: { get: vi.fn(() => 'image/png') },
|
||||
ok: true,
|
||||
text: vi.fn(),
|
||||
} as unknown as Response;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await localFileService.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
path: '/repo/image.png',
|
||||
workingDirectory: '/repo',
|
||||
});
|
||||
|
||||
expect(mockLocalSystem.getLocalFilePreviewUrl).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
path: '/repo/image.png',
|
||||
workingDirectory: '/repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-image responses before reading the body for image-only previews', async () => {
|
||||
const { localFileService } = await import('./localFileService');
|
||||
|
||||
mockLocalSystem.getLocalFilePreviewUrl.mockResolvedValue({
|
||||
success: true,
|
||||
url: 'localfile://preview/.env',
|
||||
});
|
||||
const textMock = vi.fn(async () => 'SECRET=value');
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return {
|
||||
blob: vi.fn(),
|
||||
headers: { get: vi.fn(() => 'text/plain; charset=utf-8') },
|
||||
ok: true,
|
||||
text: textMock,
|
||||
} as unknown as Response;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
localFileService.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
path: '/repo/.env',
|
||||
workingDirectory: '/repo',
|
||||
}),
|
||||
).rejects.toThrow('Unsupported local file preview type');
|
||||
expect(textMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,10 @@ const normalizeContentType = (contentType: string | null): string =>
|
||||
const isTextPreviewMimeType = (mimeType: string): boolean =>
|
||||
mimeType.startsWith('text/') || TEXT_PREVIEW_MIME_TYPES.has(mimeType);
|
||||
|
||||
const fetchLocalFilePreview = async (url: string): Promise<LocalFilePreview> => {
|
||||
const fetchLocalFilePreview = async (
|
||||
url: string,
|
||||
accept?: LocalFilePreviewUrlParams['accept'],
|
||||
): Promise<LocalFilePreview> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -95,6 +98,10 @@ const fetchLocalFilePreview = async (url: string): Promise<LocalFilePreview> =>
|
||||
return { blob: await response.blob(), contentType, type: 'image' };
|
||||
}
|
||||
|
||||
if (accept === 'image') {
|
||||
throw new Error('Unsupported local file preview type');
|
||||
}
|
||||
|
||||
if (isTextPreviewMimeType(contentType)) {
|
||||
return { content: await response.text(), contentType, type: 'text' };
|
||||
}
|
||||
@@ -176,7 +183,7 @@ class LocalFileService {
|
||||
throw new Error(result.error || 'Missing local file preview URL');
|
||||
}
|
||||
|
||||
return fetchLocalFilePreview(result.url);
|
||||
return fetchLocalFilePreview(result.url, params.accept);
|
||||
}
|
||||
|
||||
async prepareSkillDirectory(
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('projectFileService', () => {
|
||||
});
|
||||
|
||||
expect(mockDeviceClient.getLocalFilePreview.query).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
deviceId: 'device-1',
|
||||
path: '/repo/index.html',
|
||||
workingDirectory: '/repo',
|
||||
@@ -62,6 +63,56 @@ describe('projectFileService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards image-only preview constraints to remote device RPC', async () => {
|
||||
const { projectFileService } = await import('./projectFile');
|
||||
|
||||
mockDeviceClient.getLocalFilePreview.query.mockResolvedValue({
|
||||
preview: {
|
||||
base64: 'aW1hZ2U=',
|
||||
contentType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
|
||||
await projectFileService.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
deviceId: 'device-1',
|
||||
path: '/repo/image.png',
|
||||
workingDirectory: '/repo',
|
||||
});
|
||||
|
||||
expect(mockDeviceClient.getLocalFilePreview.query).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
deviceId: 'device-1',
|
||||
path: '/repo/image.png',
|
||||
workingDirectory: '/repo',
|
||||
});
|
||||
expect(mockLocalFileService.getLocalFilePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-image remote payloads for image-only previews', async () => {
|
||||
const { projectFileService } = await import('./projectFile');
|
||||
|
||||
mockDeviceClient.getLocalFilePreview.query.mockResolvedValue({
|
||||
preview: {
|
||||
content: 'SECRET=value',
|
||||
contentType: 'text/plain',
|
||||
type: 'text',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
projectFileService.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
deviceId: 'device-1',
|
||||
path: '/repo/.env',
|
||||
workingDirectory: '/repo',
|
||||
}),
|
||||
).rejects.toThrow('Unsupported local file preview type');
|
||||
});
|
||||
|
||||
it('delegates desktop local-file preview to localFileService', async () => {
|
||||
const { projectFileService } = await import('./projectFile');
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class ProjectFileService {
|
||||
}: GetLocalFilePreviewParams): Promise<LocalFilePreview> {
|
||||
if (deviceId) {
|
||||
const result = await lambdaClient.device.getLocalFilePreview.query({
|
||||
accept: params.accept,
|
||||
deviceId,
|
||||
path: params.path,
|
||||
workingDirectory: params.workingDirectory,
|
||||
@@ -74,6 +75,10 @@ class ProjectFileService {
|
||||
throw new Error(result.error || 'Missing local file preview');
|
||||
}
|
||||
|
||||
if (params.accept === 'image' && result.preview.type !== 'image') {
|
||||
throw new Error('Unsupported local file preview type');
|
||||
}
|
||||
|
||||
return deserializeLocalFilePreview(result.preview);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user