🐛 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:
Arvin Xu
2026-06-13 01:55:00 +08:00
committed by GitHub
parent da94942d9c
commit 6887930428
16 changed files with 569 additions and 9 deletions
@@ -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');
+9 -1
View File
@@ -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);
};
+28 -1
View File
@@ -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();
});
});
+9 -2
View File
@@ -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(
+51
View File
@@ -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');
+5
View File
@@ -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);
}