diff --git a/src/modules/files/files.controller.ts b/src/modules/files/files.controller.ts new file mode 100644 index 0000000..b723bf5 --- /dev/null +++ b/src/modules/files/files.controller.ts @@ -0,0 +1,31 @@ +import type { RequestWithUser } from '#types/api'; + +import { Controller, Get, Param, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { z } from 'zod'; + +import { TranslateService } from '@modules/translate/translate.service'; +import { User } from '@modules/users/entities/user.entity'; +import { validate } from '@utils/validate'; + +import { FilesService } from './files.service'; + +@ApiTags('Files') +@Controller('files') +@UseGuards(AuthGuard('jwt')) +@ApiBearerAuth() +export class FilesController { + constructor(private readonly filesService: FilesService, private readonly t: TranslateService) {} + + @Get(':id/data') + async getFile(@Req() req: RequestWithUser, @Param('id') id: number) { + validate(z.coerce.number().int().min(1), id, this.t.Errors.Id.Invalid('File', id)); + + const file = await this.filesService.findOne(id); + await file.visibility?.init(); + + if (await this.filesService.canReadFile(file, req.user as User)) return file; + throw new UnauthorizedException(this.t.Errors.File.Unauthorized(file.visibility?.name)); + } +} diff --git a/src/modules/files/files.module.ts b/src/modules/files/files.module.ts index 4bf2fe6..49c4c10 100644 --- a/src/modules/files/files.module.ts +++ b/src/modules/files/files.module.ts @@ -6,12 +6,14 @@ import { TranslateService } from '@modules/translate/translate.service'; import { UsersDataService } from '@modules/users/services/users-data.service'; import { FileVisibilityGroup } from './entities/file-visibility.entity'; +import { FilesController } from './files.controller'; import { FilesService } from './files.service'; import { ImagesService } from './images.service'; @Module({ imports: [MikroOrmModule.forFeature([FileVisibilityGroup])], providers: [EmailsService, FilesService, TranslateService, ImagesService, UsersDataService], + controllers: [FilesController], exports: [FilesService], }) export class FilesModule {} diff --git a/src/modules/files/files.service.ts b/src/modules/files/files.service.ts index e276315..bdf0a77 100644 --- a/src/modules/files/files.service.ts +++ b/src/modules/files/files.service.ts @@ -5,7 +5,7 @@ import { accessSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { Readable } from 'stream'; -import { MikroORM, CreateRequestContext } from '@mikro-orm/core'; +import { MikroORM, CreateRequestContext, Loaded } from '@mikro-orm/core'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { fromBuffer, MimeType } from 'file-type'; @@ -14,7 +14,7 @@ import { User } from '@modules/users/entities/user.entity'; import { UsersDataService } from '@modules/users/services/users-data.service'; import { FileVisibilityGroup } from './entities/file-visibility.entity'; -import { File } from './entities/file.entity'; +import { File, FileKind } from './entities/file.entity'; import { ImagesService } from './images.service'; type WriteFileOptions = { @@ -35,6 +35,13 @@ export class FilesService { private readonly usersDataService: UsersDataService, ) {} + async findOne(id: number): Promise { + const file = (await this.orm.em.findOne(File, { id })) as unknown as Loaded; + if (!file) throw new NotFoundException(this.t.Errors.Id.NotFound('File', id)); + + return file; + } + /** * Determine if the given user can read the given file. * @param {File} file - The file to check the visibility of. @@ -148,7 +155,7 @@ export class FilesService { * @param {File} file The file to delete * @param {boolean} silent If true, the function will not throw an error if the file doesn't exist */ - deleteFromDisk(file: File, silent: boolean = true) { + deleteFromDisk(file: File, silent: boolean = true) { try { accessSync(file.path); } catch { diff --git a/tests/e2e/files.e2e-spec.ts b/tests/e2e/files.e2e-spec.ts new file mode 100644 index 0000000..a74d444 --- /dev/null +++ b/tests/e2e/files.e2e-spec.ts @@ -0,0 +1,134 @@ +import request from 'supertest'; + +import { TokenDTO } from '@modules/auth/dto/token.dto'; +import { FileVisibilityGroup } from '@modules/files/entities/file-visibility.entity'; +import { File } from '@modules/files/entities/file.entity'; +import { UserPicture } from '@modules/users/entities/user-picture.entity'; + +import { orm, server, t } from '..'; + +describe('Files (e2e)', () => { + let em: typeof orm.em; + let file: File; + + let tokenVerified: string; + + let tokenSubscriber: string; + let userIdSubscriber: number; + + beforeAll(async () => { + type res = Omit & { body: TokenDTO }; + + em = orm.em.fork(); + + const responseA: res = await request(server).post('/auth/login').send({ + email: 'promos@email.com', + password: 'root', + }); + + tokenVerified = responseA.body.token; + + const responseB: res = await request(server).post('/auth/login').send({ + email: 'subscriber@email.com', + password: 'root', + }); + + tokenSubscriber = responseB.body.token; + userIdSubscriber = responseB.body.user_id; + + const visibility = await em.findOne(FileVisibilityGroup, { name: 'SUBSCRIBER' }); + file = em.create(UserPicture, { + filename: 'test.png', + mimetype: 'image/png', + path: 'test.png', + size: 123, + visibility, + description: 'foo bar', + picture_user: userIdSubscriber, + }); + + await em.persistAndFlush(file); + }); + + afterAll(async () => { + await em.removeAndFlush(file); + }); + + describe('(GET) /files/:id/data', () => { + describe('400 : Bad Request', () => { + it('when id is not valid', async () => { + const res = await request(server) + .get(`/files/invalid/data`) + .set('Authorization', `Bearer ${tokenSubscriber}`) + .expect(400); + + expect(res.body).toEqual({ + error: 'Bad Request', + statusCode: 400, + message: t.Errors.Id.Invalid('File', 'invalid'), + }); + }); + }); + + describe('401 : Unauthorized', () => { + it('when user is not logged in', async () => { + const res = await request(server).get(`/files/${file.id}/data`).expect(401); + + expect(res.body).toEqual({ + message: 'Unauthorized', + statusCode: 401, + }); + }); + + it('when user is not in the visibility group', async () => { + const res = await request(server) + .get(`/files/${file.id}/data`) + .set('Authorization', `Bearer ${tokenVerified}`) + .expect(401); + + expect(res.body).toEqual({ + error: 'Unauthorized', + message: t.Errors.File.Unauthorized(file.visibility?.name), + statusCode: 401, + }); + }); + }); + + describe('404 : Not Found', () => { + it('when file is not found', async () => { + const res = await request(server) + .get(`/files/9999/data`) + .set('Authorization', `Bearer ${tokenSubscriber}`) + .expect(404); + + expect(res.body).toEqual({ + error: 'Not Found', + statusCode: 404, + message: t.Errors.Id.NotFound('File', 9999), + }); + }); + }); + + describe('200 : Ok', () => { + it('when file is found', async () => { + const res = await request(server) + .get(`/files/${file.id}/data`) + .set('Authorization', `Bearer ${tokenSubscriber}`) + .expect(200); + + expect(res.body).toEqual({ + id: file.id, + filename: 'test.png', + mimetype: 'image/png', + path: 'test.png', + size: 123, + visibility: file.visibility.id, + description: 'foo bar', + picture_user: userIdSubscriber, + created: expect.any(String), + updated: expect.any(String), + }); + }); + }); + }); +});