Skip to content

Commit

Permalink
feat: update avatar (#318)
Browse files Browse the repository at this point in the history
* feat: update avatar

* feat: refresh avatar after update

* fix: unit test
  • Loading branch information
boris-w authored Jan 5, 2024
1 parent a63ef3a commit 884b6fb
Show file tree
Hide file tree
Showing 16 changed files with 316 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,25 @@ export class AttachmentsController {
return null;
}

@Get('/read')
@Get('/read/:path(*)')
async read(
@Res({ passthrough: true }) res: Response,
@Req() req: Request,
@Param('path') path: string,
@Query('token') token: string,
@Query('filename') filename?: string
) {
const { fileStream, headers } = await this.attachmentsService.readLocalFile(token, filename);
const hasCache = this.attachmentsService.localFileConditionalCaching(path, req.headers, res);
if (hasCache) {
res.status(304);
return;
}
const { fileStream, headers } = await this.attachmentsService.readLocalFile(
path,
token,
filename
);
res.set(headers);
// one years
const maxAge = 60 * 60 * 24 * 365;
res.set({
...headers,
'Cache-Control': `public, max-age=${maxAge}`,
});

return new StreamableFile(fileStream);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { IncomingHttpHeaders } from 'http';
import { join } from 'path';
import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '@teable-group/db-main-prisma';
import type { SignatureRo, SignatureVo } from '@teable-group/openapi';
import type { Request } from 'express';
import type { Request, Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { CacheService } from '../../cache/cache.service';
import { StorageConfig, IStorageConfig } from '../../configs/storage';
Expand Down Expand Up @@ -35,7 +36,7 @@ export class AttachmentsService {
const file = await localStorage.saveTemporaryFile(req);
await localStorage.validateToken(token, file);
const hash = await localStorage.getHash(file.path);
await localStorage.save(file, join(bucket, path));
await localStorage.save(file.path, join(bucket, path));

await this.cacheService.set(
`attachment:upload:${token}`,
Expand All @@ -44,21 +45,54 @@ export class AttachmentsService {
);
}

async readLocalFile(token: string, filename?: string) {
async readLocalFile(path: string, token?: string, filename?: string) {
const localStorage = this.storageAdapter as LocalStorage;
const { path, respHeaders } = localStorage.verifyReadToken(token);
let respHeaders: Record<string, string> = {};

if (!path) {
throw new HttpException(`Could not find attachment: ${token}`, HttpStatus.NOT_FOUND);
}
const { dir, token: tokenInPath } = localStorage.parsePath(path);
if (token && !StorageAdapter.isPublicDir(dir)) {
respHeaders = localStorage.verifyReadToken(token).respHeaders ?? {};
} else {
const attachment = await this.prismaService
.txClient()
.attachments.findUnique({ where: { token: tokenInPath, deletedTime: null } });
if (!attachment) {
throw new BadRequestException(`Invalid path: ${path}`);
}
respHeaders['Content-Type'] = attachment.mimetype;
}

const headers: Record<string, string> = respHeaders ?? {};
if (filename) {
headers['Content-Disposition'] = `attachment; filename="${filename}"`;
}

const fileStream = localStorage.read(path);

return { headers, fileStream };
}

localFileConditionalCaching(path: string, reqHeaders: IncomingHttpHeaders, res: Response) {
const ifModifiedSince = reqHeaders['if-modified-since'];
const localStorage = this.storageAdapter as LocalStorage;
const lastModifiedTimestamp = localStorage.getLastModifiedTime(path);
if (!lastModifiedTimestamp) {
throw new BadRequestException(`Could not find attachment: ${path}`);
}
// Comparison of accuracy in seconds
if (
!ifModifiedSince ||
Math.floor(new Date(ifModifiedSince).getTime() / 1000) <
Math.floor(lastModifiedTimestamp / 1000)
) {
res.set('Last-Modified', new Date(lastModifiedTimestamp).toUTCString());
return false;
}
return true;
}

async signature(signatureRo: SignatureRo): Promise<SignatureVo> {
const { type, ...presignedParams } = signatureRo;
const hash = presignedParams.hash;
Expand Down
26 changes: 26 additions & 0 deletions apps/nestjs-backend/src/features/attachments/plugins/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export default abstract class StorageAdapter {
}
};

static readonly isPublicDir = (dir: string) => {
switch (dir) {
case 'avatar':
case 'form':
return true;
case 'table':
return false;
default:
throw new BadRequestException('Invalid file dir');
}
};

/**
* generate presigned url
* @param bucket bucket name
Expand Down Expand Up @@ -62,4 +74,18 @@ export default abstract class StorageAdapter {
[key: string]: any;
}
): Promise<string>;

/**
* uploadFile with file path
* @param bucket bucket name
* @param path path name
* @param filePath file path
* @param metadata Metadata of the object.
*/
abstract uploadFileWidthPath(
bucket: string,
path: string,
filePath: string,
metadata: Record<string, unknown>
): Promise<string>;
}
19 changes: 6 additions & 13 deletions apps/nestjs-backend/src/features/attachments/plugins/local.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,20 +188,18 @@ describe('LocalStorage', () => {

describe('save', () => {
it('should save file to storage', async () => {
const mockFile = {
path: '/mock/temp/path',
};
const mockFilePath = '/mock/temp/path';

const mockRename = 'mock-rename.png';
const mockDistPath = resolve(storage.storageDir, mockRename);
vi.spyOn(fse, 'copy').mockResolvedValueOnce(undefined);
vi.spyOn(fse, 'remove').mockResolvedValueOnce(undefined);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await storage.save(mockFile as any, mockRename);
const result = await storage.save(mockFilePath, mockRename);

expect(fse.copy).toHaveBeenCalledWith(mockFile.path, mockDistPath);
expect(fse.remove).toHaveBeenCalledWith(mockFile.path);
expect(fse.copy).toHaveBeenCalledWith(mockFilePath, mockDistPath);
expect(fse.remove).toHaveBeenCalledWith(mockFilePath);
expect(result).toBe(join(storage.path, mockRename));
});
});
Expand Down Expand Up @@ -282,8 +280,7 @@ describe('LocalStorage', () => {
expect(storage.getFileMate).toHaveBeenCalledWith(
resolve(storage.storageDir, mockBucket, mockPath)
);
expect(storage['getUrl']).toHaveBeenCalledWith({
path: mockPath,
expect(storage['getUrl']).toHaveBeenCalledWith(mockPath, {
// eslint-disable-next-line @typescript-eslint/naming-convention
respHeaders: mockRespHeaders,
expiresDate: -1,
Expand Down Expand Up @@ -325,11 +322,10 @@ describe('LocalStorage', () => {

expect(storage.expireTokenEncryptor.encrypt).toHaveBeenCalledWith({
expiresDate: Math.floor(Date.now() / 1000) + mockExpiresIn,
path: mockPath,
respHeaders: mockRespHeaders,
});
expect(fullStorageUrlModule.getFullStorageUrl).toHaveBeenCalledWith(
'/api/attachments/read?token=mock-token'
'/api/attachments/read/mock/file/path?token=mock-token'
);
expect(result).toBe('http://example.com');
});
Expand All @@ -340,7 +336,6 @@ describe('LocalStorage', () => {
it('should verify read token', () => {
vi.spyOn(storage.expireTokenEncryptor, 'decrypt').mockReturnValueOnce({
expiresDate,
path: 'mock-path',
respHeaders: mockRespHeaders,
});

Expand All @@ -349,15 +344,13 @@ describe('LocalStorage', () => {
expect(storage.expireTokenEncryptor.decrypt).toHaveBeenCalledWith('mock-token');

expect(result).toEqual({
path: 'mock-path',
respHeaders: mockRespHeaders,
});
});

it('should throw BadRequestException for expired token', () => {
vi.spyOn(storage.expireTokenEncryptor, 'decrypt').mockReturnValueOnce({
expiresDate: 1,
path: 'mock-path',
});

expect(() => storage.verifyReadToken('expired-token')).toThrow(BadRequestException);
Expand Down
49 changes: 36 additions & 13 deletions apps/nestjs-backend/src/features/attachments/plugins/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type { ILocalFileUpload, IObjectMeta, IPresignParams, IRespHeaders } from

interface ITokenEncryptor {
expiresDate: number;
path: string;
respHeaders?: IRespHeaders;
}

Expand All @@ -26,6 +25,7 @@ export class LocalStorage implements StorageAdapter {
storageDir: string;
temporaryDir = resolve(process.cwd(), '.temporary');
expireTokenEncryptor: Encryptor<ITokenEncryptor>;
readPath = '/api/attachments/read';

constructor(
@StorageConfig() readonly config: IStorageConfig,
Expand All @@ -49,9 +49,17 @@ export class LocalStorage implements StorageAdapter {
}
}

private getUrl(params: ITokenEncryptor) {
private getUrl(path: string, params: ITokenEncryptor) {
const token = this.expireTokenEncryptor.encrypt(params);
return `/api/attachments/read?token=${token}`;
return `${join(this.readPath, path)}?token=${token}`;
}

parsePath(path: string) {
const [dir, token] = path.split('/');
return {
dir,
token,
};
}

async presigned(_bucket: string, dir: string, params: IPresignParams) {
Expand Down Expand Up @@ -135,18 +143,26 @@ export class LocalStorage implements StorageAdapter {
});
}

async save(file: ILocalFileUpload, rename: string) {
async save(filePath: string, rename: string) {
const distPath = resolve(this.storageDir);
const newFilePath = resolve(distPath, rename);
await fse.copy(file.path, newFilePath);
await fse.remove(file.path);
await fse.copy(filePath, newFilePath);
await fse.remove(filePath);
return join(this.path, rename);
}

read(path: string) {
return createReadStream(resolve(this.storageDir, path));
}

getLastModifiedTime(path: string) {
const url = resolve(this.storageDir, path);
if (!fse.existsSync(url)) {
return;
}
return fse.statSync(url).mtimeMs;
}

async getFileMate(path: string) {
const info = await sharp(path).metadata();
return {
Expand Down Expand Up @@ -178,8 +194,7 @@ export class LocalStorage implements StorageAdapter {
hash,
mimetype,
size,
url: this.getUrl({
path,
url: this.getUrl(path, {
respHeaders: { 'Content-Type': mimetype },
expiresDate: -1,
}),
Expand All @@ -193,24 +208,32 @@ export class LocalStorage implements StorageAdapter {
expiresIn: number = this.config.urlExpireIn,
respHeaders?: IRespHeaders
): Promise<string> {
const token = this.expireTokenEncryptor.encrypt({
const url = this.getUrl(path, {
expiresDate: Math.floor(Date.now() / 1000) + expiresIn,
path,
respHeaders,
});
const url = `/api/attachments/read?token=${token}`;
return getFullStorageUrl(url);
}

verifyReadToken(token: string) {
try {
const { expiresDate, path, respHeaders } = this.expireTokenEncryptor.decrypt(token);
const { expiresDate, respHeaders } = this.expireTokenEncryptor.decrypt(token);
if (expiresDate > 0 && Math.floor(Date.now() / 1000) > expiresDate) {
throw new BadRequestException('Token has expired');
}
return { path, respHeaders };
return { respHeaders };
} catch (error) {
throw new BadRequestException('Invalid token');
}
}

async uploadFileWidthPath(
bucket: string,
path: string,
filePath: string,
_metadata: Record<string, unknown>
) {
this.save(filePath, join(bucket, path));
return join(this.readPath, path);
}
}
10 changes: 10 additions & 0 deletions apps/nestjs-backend/src/features/attachments/plugins/minio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,14 @@ export class MinioStorage implements StorageAdapter {
) {
return this.minioClient.presignedGetObject(bucket, path, expiresIn, respHeaders);
}

async uploadFileWidthPath(
bucket: string,
path: string,
filePath: string,
metadata: Record<string, unknown>
) {
await this.minioClient.fPutObject(bucket, path, filePath, metadata);
return `/${bucket}/${path}`;
}
}
32 changes: 25 additions & 7 deletions apps/nestjs-backend/src/features/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { Body, Controller, Patch } from '@nestjs/common';
import {
IUpdateUserAvatarRo,
BadRequestException,
Body,
Controller,
Patch,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
IUpdateUserNameRo,
IUserNotifyMeta,
updateUserAvatarRoSchema,
updateUserNameRoSchema,
userNotifyMetaSchema,
} from '@teable-group/openapi';
Expand All @@ -27,12 +33,24 @@ export class UserController {
return this.userService.updateUserName(userId, updateUserNameRo.name);
}

@UseInterceptors(
FileInterceptor('file', {
fileFilter: (_req, file, callback) => {
if (file.mimetype.startsWith('image/')) {
callback(null, true);
} else {
callback(new BadRequestException('Invalid file type'), false);
}
},
limits: {
fileSize: 3 * 1024 * 1024, // limit file size is 3MB
},
})
)
@Patch('updateAvatar')
async updateAvatar(
@Body(new ZodValidationPipe(updateUserAvatarRoSchema)) updateUserAvatarRo: IUpdateUserAvatarRo
): Promise<void> {
async updateAvatar(@UploadedFile() file: Express.Multer.File): Promise<void> {
const userId = this.cls.get('user.id');
return this.userService.updateAvatar(userId, updateUserAvatarRo.avatar);
return this.userService.updateAvatar(userId, file);
}

@Patch('updateNotifyMeta')
Expand Down
Loading

0 comments on commit 884b6fb

Please sign in to comment.