diff --git a/packages/backend/server/src/core/storage/config.ts b/packages/backend/server/src/core/storage/config.ts index 57b739f4a7..19ff6688fe 100644 --- a/packages/backend/server/src/core/storage/config.ts +++ b/packages/backend/server/src/core/storage/config.ts @@ -7,7 +7,10 @@ export type StorageConfig = { } & Ext; export interface StorageStartupConfigurations { - avatar: StorageConfig<{ publicLinkFactory: (key: string) => string }>; + avatar: StorageConfig<{ + publicLinkFactory: (key: string) => string; + keyInPublicLink: (link: string) => string; + }>; blob: StorageConfig; } @@ -22,6 +25,7 @@ defineStartupConfig('storages', { provider: 'fs', bucket: 'avatars', publicLinkFactory: key => `/api/avatars/${key}`, + keyInPublicLink: link => link.split('/').pop() as string, }, blob: { provider: 'fs', diff --git a/packages/backend/server/src/core/storage/wrappers/avatar.ts b/packages/backend/server/src/core/storage/wrappers/avatar.ts index 538a9c8e76..db4bf3243a 100644 --- a/packages/backend/server/src/core/storage/wrappers/avatar.ts +++ b/packages/backend/server/src/core/storage/wrappers/avatar.ts @@ -42,8 +42,8 @@ export class AvatarStorage { return this.provider.get(key); } - delete(key: string) { - return this.provider.delete(key); + delete(link: string) { + return this.provider.delete(this.storageConfig.keyInPublicLink(link)); } @OnEvent('user.deleted') diff --git a/packages/backend/server/src/core/user/controller.ts b/packages/backend/server/src/core/user/controller.ts index 6fc2e4bf5f..0bfd50c3b0 100644 --- a/packages/backend/server/src/core/user/controller.ts +++ b/packages/backend/server/src/core/user/controller.ts @@ -2,8 +2,10 @@ import { Controller, Get, Param, Res } from '@nestjs/common'; import type { Response } from 'express'; import { ActionForbidden, UserAvatarNotFound } from '../../fundamentals'; +import { Public } from '../auth/guard'; import { AvatarStorage } from '../storage'; +@Public() @Controller('/api/avatars') export class UserAvatarController { constructor(private readonly storage: AvatarStorage) {} diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 8098752f7d..f5b3760cc8 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -91,18 +91,26 @@ export class UserResolver { @Args({ name: 'avatar', type: () => GraphQLUpload }) avatar: FileUpload ) { + if (!avatar.mimetype.startsWith('image/')) { + throw new Error('Invalid file type'); + } + if (!user) { throw new UserNotFound(); } const avatarUrl = await this.storage.put( - `${user.id}-avatar`, + `${user.id}-avatar-${Date.now()}`, avatar.createReadStream(), { contentType: avatar.mimetype, } ); + if (user.avatarUrl) { + await this.storage.delete(user.avatarUrl); + } + return this.users.updateUser(user.id, { avatarUrl }); } diff --git a/packages/backend/server/tests/user/user.e2e.ts b/packages/backend/server/tests/user/user.e2e.ts new file mode 100644 index 0000000000..732f6f8a55 --- /dev/null +++ b/packages/backend/server/tests/user/user.e2e.ts @@ -0,0 +1,96 @@ +import type { INestApplication } from '@nestjs/common'; +import type { TestFn } from 'ava'; +import ava from 'ava'; +import request from 'supertest'; + +import { AppModule } from '../../src/app.module'; +import { AuthService, CurrentUser } from '../../src/core/auth'; +import { createTestingApp, gql, internalSignIn } from '../utils'; + +const test = ava as TestFn<{ + app: INestApplication; + u1: CurrentUser; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [AppModule], + }); + + t.context.u1 = await app.get(AuthService).signUp('u1', 'u1@affine.pro', '1'); + t.context.app = app; +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +async function fakeUploadAvatar( + app: INestApplication, + userId: string, + avatar: Buffer +) { + const cookie = await internalSignIn(app, userId); + + return gql(app) + .set('Cookie', cookie) + .field( + 'operations', + JSON.stringify({ + name: 'uploadAvatar', + query: `mutation uploadAvatar($avatar: Upload!) { + uploadAvatar(avatar: $avatar) { + avatarUrl + } + }`, + variables: { avatar: null }, + }) + ) + .field('map', JSON.stringify({ '0': ['variables.avatar'] })) + .attach('0', avatar, { + filename: 'test.png', + contentType: 'image/png', + }); +} + +test('should be able to upload user avatar', async t => { + const { app } = t.context; + + const avatar = Buffer.from('test'); + const res = await fakeUploadAvatar(app, t.context.u1.id, avatar); + + t.is(res.status, 200); + const avatarUrl = res.body.data.uploadAvatar.avatarUrl; + t.truthy(avatarUrl); + + const avatarRes = await request(app.getHttpServer()) + .get(new URL(avatarUrl).pathname) + .expect(200); + + t.deepEqual(avatarRes.body, Buffer.from('test')); +}); + +test('should be able to update user avatar, and invalidate old avatar url', async t => { + const { app } = t.context; + + const avatar = Buffer.from('test'); + let res = await fakeUploadAvatar(app, t.context.u1.id, avatar); + + const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl; + + const newAvatar = Buffer.from('new'); + res = await fakeUploadAvatar(app, t.context.u1.id, newAvatar); + const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl; + + t.not(oldAvatarUrl, newAvatarUrl); + + await request(app.getHttpServer()) + .get(new URL(oldAvatarUrl).pathname) + .expect(404); + + const avatarRes = await request(app.getHttpServer()) + .get(new URL(newAvatarUrl).pathname) + .expect(200); + + t.deepEqual(avatarRes.body, Buffer.from('new')); +}); diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 1ecc7993cb..b8bd9910bf 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -6,6 +6,7 @@ import { PrismaClient } from '@prisma/client'; import cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import type { Response } from 'supertest'; +import supertest from 'supertest'; import { AppModule, FunctionalityModules } from '../../src/app.module'; import { AuthGuard, AuthModule } from '../../src/core/auth'; @@ -147,3 +148,15 @@ export function handleGraphQLError(resp: Response) { throw new Error(stacktrace ? stacktrace.join('\n') : cause.message, cause); } } + +export function gql(app: INestApplication, query?: string) { + const req = supertest(app.getHttpServer()) + .post('/graphql') + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }); + + if (query) { + return req.send({ query }); + } + + return req; +}