mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
feat(server): add image resize support (#14588)
fix #13842 #### PR Dependency Tree * **PR #14588** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Images are now processed natively and converted to WebP for smaller, optimized files; Copilot and avatar attachments use the processed WebP output. * Avatar uploads accept BMP, GIF, JPEG, PNG, WebP (5MB max) and are downscaled to a standard edge. * **Error Messages / i18n** * Added localized error "Image format not supported: {format}". * **Tests** * Added end-to-end and unit tests for conversion, EXIF preservation, and upload limits. * **Chores** * Added native image-processing dependencies. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
CopilotEmbeddingJob,
|
||||
MockEmbeddingClient,
|
||||
} from '../../plugins/copilot/embedding';
|
||||
import { ChatMessageCache } from '../../plugins/copilot/message';
|
||||
import { prompts, PromptService } from '../../plugins/copilot/prompt';
|
||||
import {
|
||||
CopilotProviderFactory,
|
||||
@@ -416,6 +417,7 @@ test('should be able to use test provider', async t => {
|
||||
|
||||
test('should create message correctly', async t => {
|
||||
const { app } = t.context;
|
||||
const messageCache = app.get(ChatMessageCache);
|
||||
|
||||
{
|
||||
const { id } = await createWorkspace(app);
|
||||
@@ -463,6 +465,19 @@ test('should create message correctly', async t => {
|
||||
new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })
|
||||
);
|
||||
t.truthy(messageId, 'should be able to create message with blob');
|
||||
|
||||
const message = await messageCache.get(messageId);
|
||||
const attachment = message?.attachments?.[0] as
|
||||
| { attachment: string; mimeType: string }
|
||||
| undefined;
|
||||
const payload = Buffer.from(
|
||||
attachment?.attachment.split(',').at(1) || '',
|
||||
'base64'
|
||||
);
|
||||
|
||||
t.is(attachment?.mimeType, 'image/webp');
|
||||
t.is(payload.subarray(0, 4).toString('ascii'), 'RIFF');
|
||||
t.is(payload.subarray(8, 12).toString('ascii'), 'WEBP');
|
||||
}
|
||||
|
||||
// with attachments
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import {
|
||||
createBmp,
|
||||
createTestingApp,
|
||||
getPublicUserById,
|
||||
smallestGif,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
updateAvatar,
|
||||
@@ -40,7 +40,10 @@ test('should be able to upload user avatar', async t => {
|
||||
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.deepEqual(avatarRes.body, avatar);
|
||||
t.true(avatarRes.headers['content-type'].startsWith('image/webp'));
|
||||
t.notDeepEqual(avatarRes.body, avatar);
|
||||
t.is(avatarRes.body.subarray(0, 4).toString('ascii'), 'RIFF');
|
||||
t.is(avatarRes.body.subarray(8, 12).toString('ascii'), 'WEBP');
|
||||
});
|
||||
|
||||
test('should be able to update user avatar, and invalidate old avatar url', async t => {
|
||||
@@ -54,9 +57,7 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
|
||||
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
const newAvatar = await fetch(smallestGif)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const newAvatar = createBmp(32, 32);
|
||||
res = await updateAvatar(app, newAvatar);
|
||||
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
@@ -66,7 +67,46 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
t.is(avatarRes.status, 404);
|
||||
|
||||
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
|
||||
t.deepEqual(newAvatarRes.body, newAvatar);
|
||||
t.true(newAvatarRes.headers['content-type'].startsWith('image/webp'));
|
||||
t.notDeepEqual(newAvatarRes.body, newAvatar);
|
||||
t.is(newAvatarRes.body.subarray(0, 4).toString('ascii'), 'RIFF');
|
||||
t.is(newAvatarRes.body.subarray(8, 12).toString('ascii'), 'WEBP');
|
||||
});
|
||||
|
||||
test('should accept avatar uploads up to 5MB after conversion', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = createBmp(1024, 1024);
|
||||
t.true(avatar.length > 500 * 1024);
|
||||
t.true(avatar.length < 5 * 1024 * 1024);
|
||||
|
||||
const res = await updateAvatar(app, avatar, {
|
||||
filename: 'large.bmp',
|
||||
contentType: 'image/bmp',
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
const avatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.true(avatarRes.headers['content-type'].startsWith('image/webp'));
|
||||
});
|
||||
|
||||
test('should reject unsupported vector avatars', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>'
|
||||
);
|
||||
const res = await updateAvatar(app, avatar, {
|
||||
filename: 'avatar.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.is(res.body.errors[0].message, 'Image format not supported: image/svg+xml');
|
||||
});
|
||||
|
||||
test('should be able to get public user by id', async t => {
|
||||
|
||||
@@ -7,6 +7,35 @@ export const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
|
||||
|
||||
export function createBmp(width: number, height: number) {
|
||||
const rowSize = Math.ceil((width * 3) / 4) * 4;
|
||||
const pixelDataSize = rowSize * height;
|
||||
const fileSize = 54 + pixelDataSize;
|
||||
const buffer = Buffer.alloc(fileSize);
|
||||
|
||||
buffer.write('BM', 0, 'ascii');
|
||||
buffer.writeUInt32LE(fileSize, 2);
|
||||
buffer.writeUInt32LE(54, 10);
|
||||
buffer.writeUInt32LE(40, 14);
|
||||
buffer.writeInt32LE(width, 18);
|
||||
buffer.writeInt32LE(height, 22);
|
||||
buffer.writeUInt16LE(1, 26);
|
||||
buffer.writeUInt16LE(24, 28);
|
||||
buffer.writeUInt32LE(pixelDataSize, 34);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowOffset = 54 + y * rowSize;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pixelOffset = rowOffset + x * 3;
|
||||
buffer[pixelOffset] = 0x33;
|
||||
buffer[pixelOffset + 1] = 0x66;
|
||||
buffer[pixelOffset + 2] = 0x99;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function listBlobs(
|
||||
app: TestingApp,
|
||||
workspaceId: string
|
||||
|
||||
@@ -121,7 +121,11 @@ export async function deleteAccount(app: TestingApp) {
|
||||
return res.deleteAccount.success;
|
||||
}
|
||||
|
||||
export async function updateAvatar(app: TestingApp, avatar: Buffer) {
|
||||
export async function updateAvatar(
|
||||
app: TestingApp,
|
||||
avatar: Buffer,
|
||||
options: { filename?: string; contentType?: string } = {}
|
||||
) {
|
||||
return app
|
||||
.POST('/graphql')
|
||||
.field(
|
||||
@@ -138,7 +142,7 @@ export async function updateAvatar(app: TestingApp, avatar: Buffer) {
|
||||
)
|
||||
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
|
||||
.attach('0', avatar, {
|
||||
filename: 'test.png',
|
||||
contentType: 'image/png',
|
||||
filename: options.filename || 'test.png',
|
||||
contentType: options.contentType || 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user