Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

Commit

Permalink
feat(files): add files/:id/data endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
Juknum committed Oct 31, 2023
1 parent a24a78d commit ac21a82
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 3 deletions.
31 changes: 31 additions & 0 deletions src/modules/files/files.controller.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 2 additions & 0 deletions src/modules/files/files.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
13 changes: 10 additions & 3 deletions src/modules/files/files.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 = {
Expand All @@ -35,6 +35,13 @@ export class FilesService {
private readonly usersDataService: UsersDataService,
) {}

async findOne(id: number): Promise<FileKind> {
const file = (await this.orm.em.findOne(File, { id })) as unknown as Loaded<FileKind, string>;
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.
Expand Down Expand Up @@ -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<unknown>, silent: boolean = true) {
deleteFromDisk<T>(file: File<T>, silent: boolean = true) {
try {
accessSync(file.path);
} catch {
Expand Down
134 changes: 134 additions & 0 deletions tests/e2e/files.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;

let tokenVerified: string;

let tokenSubscriber: string;
let userIdSubscriber: number;

beforeAll(async () => {
type res = Omit<request.Response, 'body'> & { body: TokenDTO };

em = orm.em.fork();

const responseA: res = await request(server).post('/auth/login').send({
email: '[email protected]',
password: 'root',
});

tokenVerified = responseA.body.token;

const responseB: res = await request(server).post('/auth/login').send({
email: '[email protected]',
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),
});
});
});
});
});

0 comments on commit ac21a82

Please sign in to comment.