Merge commit '43e8a60'
This commit is contained in:
@@ -160,9 +160,19 @@ function sendData(res: ServerResponse, data: unknown): void {
|
|||||||
sendJson(res, 200, { success: true, status: 200, data });
|
sendJson(res, 200, { success: true, status: 200, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_FILE_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']);
|
const ASSET_FILE_EXTENSIONS = new Set([
|
||||||
|
'.gif',
|
||||||
|
'.jpeg',
|
||||||
|
'.jpg',
|
||||||
|
'.mov',
|
||||||
|
'.mp4',
|
||||||
|
'.png',
|
||||||
|
'.svg',
|
||||||
|
'.webm',
|
||||||
|
'.webp',
|
||||||
|
]);
|
||||||
|
|
||||||
function safeImageFileName(value: string, assetId: string): string {
|
function safeAssetFileName(value: string, assetId: string): string {
|
||||||
const fallbackId = assetId
|
const fallbackId = assetId
|
||||||
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
@@ -173,7 +183,7 @@ function safeImageFileName(value: string, assetId: string): string {
|
|||||||
.join('')
|
.join('')
|
||||||
.replace(/[. ]+$/g, '')
|
.replace(/[. ]+$/g, '')
|
||||||
.slice(0, 120);
|
.slice(0, 120);
|
||||||
if (!sanitized || !IMAGE_FILE_EXTENSIONS.has(extname(sanitized).toLowerCase())) {
|
if (!sanitized || !ASSET_FILE_EXTENSIONS.has(extname(sanitized).toLowerCase())) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
return sanitized;
|
return sanitized;
|
||||||
@@ -186,12 +196,12 @@ async function saveAssetContent(
|
|||||||
assetId: string,
|
assetId: string,
|
||||||
defaultFileName: string,
|
defaultFileName: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fileName = safeImageFileName(defaultFileName, assetId);
|
const fileName = safeAssetFileName(defaultFileName, assetId);
|
||||||
const extension = extname(fileName).slice(1);
|
const extension = extname(fileName).slice(1);
|
||||||
const options: SaveDialogOptions = {
|
const options: SaveDialogOptions = {
|
||||||
defaultPath: join(app.getPath('downloads'), fileName),
|
defaultPath: join(app.getPath('downloads'), fileName),
|
||||||
filters: [
|
filters: [
|
||||||
{ name: '图片', extensions: [extension] },
|
{ name: '设计资产', extensions: [extension] },
|
||||||
{ name: '所有文件', extensions: ['*'] },
|
{ name: '所有文件', extensions: ['*'] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -207,11 +217,14 @@ async function saveAssetContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = await ctx.imageWorkspace!.openAssetContent(workspaceId, assetId);
|
const content = await ctx.imageWorkspace!.openAssetContent(workspaceId, assetId);
|
||||||
if (!content.ok || !content.body || !content.headers.get('content-type')?.startsWith('image/')) {
|
const contentType = content.headers.get('content-type');
|
||||||
|
if (!content.ok
|
||||||
|
|| !content.body
|
||||||
|
|| (!contentType?.startsWith('image/') && !contentType?.startsWith('video/'))) {
|
||||||
throw new DesignWorkspaceModuleError(
|
throw new DesignWorkspaceModuleError(
|
||||||
content.status >= 400 ? content.status : 502,
|
content.status >= 400 ? content.status : 502,
|
||||||
'DESIGN_ASSET_DOWNLOAD_FAILED',
|
'DESIGN_ASSET_DOWNLOAD_FAILED',
|
||||||
'生成图片暂时无法下载,请稍后重试',
|
'生成资产暂时无法下载,请稍后重试',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +243,7 @@ async function saveAssetContent(
|
|||||||
throw new DesignWorkspaceModuleError(
|
throw new DesignWorkspaceModuleError(
|
||||||
500,
|
500,
|
||||||
'DESIGN_ASSET_SAVE_FAILED',
|
'DESIGN_ASSET_SAVE_FAILED',
|
||||||
'图片保存失败,请重新选择位置后重试',
|
'资产保存失败,请重新选择位置后重试',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
sendData(res, { status: 'saved' });
|
sendData(res, { status: 'saved' });
|
||||||
|
|||||||
@@ -427,6 +427,79 @@ describe('AI design Main route boundary', () => {
|
|||||||
await expect(readFile(savedPath, 'utf8')).resolves.toBe('image-bytes');
|
await expect(readFile(savedPath, 'utf8')).resolves.toBe('image-bytes');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['mp4', 'video/mp4'],
|
||||||
|
['mov', 'video/quicktime'],
|
||||||
|
['webm', 'video/webm'],
|
||||||
|
])('streams a private %s asset into a video save location', async (extension, mimeType) => {
|
||||||
|
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
|
||||||
|
const savedPath = join(temporaryDirectory, `animation.${extension}`);
|
||||||
|
electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath });
|
||||||
|
const expectedBytes = `video-${extension}-bytes`;
|
||||||
|
const content = new Response(expectedBytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': mimeType },
|
||||||
|
});
|
||||||
|
const getContentType = vi.spyOn(content.headers, 'get');
|
||||||
|
const openAssetContent = vi.fn().mockResolvedValue(content);
|
||||||
|
const response = createResponse();
|
||||||
|
|
||||||
|
await handleImageWorkspaceRoutes(
|
||||||
|
createRequest('POST', { defaultFileName: `Ocean animation.${extension}` }),
|
||||||
|
response.res,
|
||||||
|
new URL(
|
||||||
|
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download',
|
||||||
|
),
|
||||||
|
{ imageWorkspace: { openAssetContent } } as unknown as HostApiContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(electronMocks.showSaveDialog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
defaultPath: expect.stringContaining(`Ocean animation.${extension}`),
|
||||||
|
filters: [
|
||||||
|
{ name: '设计资产', extensions: [extension] },
|
||||||
|
{ name: '所有文件', extensions: ['*'] },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(getContentType).toHaveBeenCalledWith('content-type');
|
||||||
|
expect(content.headers.get('content-type')).toBe(mimeType);
|
||||||
|
expect(openAssetContent).toHaveBeenCalledWith('workspace-one', 'asset-one');
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
data: { status: 'saved' },
|
||||||
|
});
|
||||||
|
await expect(readFile(savedPath, 'utf8')).resolves.toBe(expectedBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a private download response that is not an image or video', async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
|
||||||
|
const savedPath = join(temporaryDirectory, 'payload.mp4');
|
||||||
|
electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath });
|
||||||
|
const response = createResponse();
|
||||||
|
|
||||||
|
await handleImageWorkspaceRoutes(
|
||||||
|
createRequest('POST', { defaultFileName: 'payload.mp4' }),
|
||||||
|
response.res,
|
||||||
|
new URL(
|
||||||
|
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
imageWorkspace: {
|
||||||
|
openAssetContent: vi.fn().mockResolvedValue(new Response('untrusted', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
} as unknown as HostApiContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
code: 'DESIGN_ASSET_DOWNLOAD_FAILED',
|
||||||
|
error: '生成资产暂时无法下载,请稍后重试',
|
||||||
|
});
|
||||||
|
await expect(readFile(savedPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves an existing destination when the private image stream fails', async () => {
|
it('preserves an existing destination when the private image stream fails', async () => {
|
||||||
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
|
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
|
||||||
const savedPath = join(temporaryDirectory, 'existing.png');
|
const savedPath = join(temporaryDirectory, 'existing.png');
|
||||||
|
|||||||
Reference in New Issue
Block a user