Skip to content

Commit 5fce8d5

Browse files
committed
upload file and get the file endpoint
1 parent 7a2472b commit 5fce8d5

22 files changed

+355
-0
lines changed

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TaskModule } from './task/task.module';
1313
import { ProjectTaskModule } from './project-task/project-task.module';
1414
import { UpdateModule } from './task-update/task-update.module';
1515
import { UpdateCommentModule } from './update-comment/update-comment.module';
16+
import { FileModule } from './file/file.module';
1617

1718
@Module({
1819
imports: [
@@ -24,6 +25,7 @@ import { UpdateCommentModule } from './update-comment/update-comment.module';
2425
ProjectTaskModule,
2526
UpdateModule,
2627
UpdateCommentModule,
28+
FileModule,
2729
],
2830
providers: [
2931
{

src/config/multer.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MulterModuleOptions } from '@nestjs/platform-express';
2+
import { MulterFile } from '../core/interface';
3+
import { MimeType } from '../database/enum';
4+
5+
const acceptedMimes = [
6+
MimeType.DOCX,
7+
MimeType.JPEG,
8+
MimeType.PNG,
9+
MimeType.PDF,
10+
];
11+
12+
export const MulterConfig: MulterModuleOptions = {
13+
dest: './upload',
14+
fileFilter: (_, file: MulterFile, callback) => {
15+
if (acceptedMimes.includes(<MimeType>file.mimetype)) {
16+
return callback(null, true);
17+
}
18+
return callback(new Error('Only Image, PDF or Docx allowed'), false);
19+
},
20+
};

src/core/app.service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,13 @@ export abstract class AppService {
2626
);
2727
}
2828
}
29+
30+
/** if can not manage return '403 Forbidden' */
31+
canView(can: boolean, entityName: string): void {
32+
if (!can) {
33+
throw new ForbiddenException(
34+
`You don't have the permission to veiw this ${entityName}`,
35+
);
36+
}
37+
}
2938
}

src/core/interface/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './multer';

src/core/interface/multer.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Readable } from 'stream';
2+
3+
// Type definitions for multer 1.4
4+
// Project: https://github.com/expressjs/multer
5+
// Definitions by: jt000 <https://github.com/jt000>
6+
// vilicvane <https://github.com/vilic>
7+
// David Broder-Rodgers <https://github.com/DavidBR-SW>
8+
// Michael Ledin <https://github.com/mxl>
9+
// HyunSeob Lee <https://github.com/hyunseob>
10+
// Pierre Tchuente <https://github.com/PierreTchuente>
11+
// Oliver Emery <https://github.com/thrymgjol>
12+
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
13+
// TypeScript Version: 2.8
14+
15+
export interface MulterFile {
16+
/** Name of the form field associated with this file. */
17+
fieldname: string;
18+
/** Name of the file on the uploader's computer. */
19+
originalname: string;
20+
/**
21+
* Value of the `Content-Transfer-Encoding` header for this file.
22+
* @deprecated since July 2015
23+
* @see RFC 7578, Section 4.7
24+
*/
25+
encoding: string;
26+
/** Value of the `Content-Type` header for this file. */
27+
mimetype: string;
28+
/** Size of the file in bytes. */
29+
size: number;
30+
/**
31+
* A readable stream of this file. Only available to the `_handleFile`
32+
* callback for custom `StorageEngine`s.
33+
*/
34+
stream: Readable;
35+
/** `DiskStorage` only: Directory to which this file has been uploaded. */
36+
destination: string;
37+
/** `DiskStorage` only: Name of this file within `destination`. */
38+
filename: string;
39+
/** `DiskStorage` only: Full path to the uploaded file. */
40+
path: string;
41+
/** `MemoryStorage` only: A Buffer containing the entire file. */
42+
buffer: Buffer;
43+
}

src/database/entity/employee.entity.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Role } from '../enum/role.enum';
1111
import { Project } from './project.entity';
1212
import { Task } from './task.entity';
1313
import { Comment } from './comment.entity';
14+
import { File } from './file.entity';
1415

1516
@Entity()
1617
@Unique(['username'])
@@ -46,6 +47,13 @@ export class Employee {
4647
@Exclude()
4748
comments: Comment[];
4849

50+
@OneToMany(
51+
() => File,
52+
file => file.owner,
53+
)
54+
@Exclude()
55+
files: File[];
56+
4957
@Column()
5058
@Exclude()
5159
password: string;

src/database/entity/file.entity.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
2+
import { Exclude } from 'class-transformer';
3+
import { Employee } from '.';
4+
import { MimeType } from '../enum';
5+
6+
@Entity()
7+
export class File {
8+
@PrimaryGeneratedColumn()
9+
id: number;
10+
11+
@Column()
12+
mime: MimeType;
13+
14+
@Column()
15+
filename: string;
16+
17+
@Column()
18+
@Exclude()
19+
filepath: string;
20+
21+
@ManyToOne(
22+
() => Employee,
23+
employee => employee.files,
24+
{ eager: true },
25+
)
26+
owner: Employee;
27+
28+
isOwner(employee: Employee): boolean {
29+
return this.owner.id === employee.id;
30+
}
31+
}

src/database/entity/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './project.entity';
33
export * from './task.entity';
44
export * from './update.entity';
55
export * from './comment.entity';
6+
export * from './file.entity';

src/database/enum/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './role.enum';
22
export * from './project-status.enum';
33
export * from './task-status.enum';
44
export * from './update-type.enum';
5+
export * from './mime-type.enum';

src/database/enum/mime-type.enum.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum MimeType {
2+
DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
3+
JPEG = 'image/jpeg',
4+
PNG = 'image/png',
5+
PDF = 'application/pdf',
6+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { EntityRepository } from 'typeorm';
2+
import { File } from '../entity';
3+
import { AppRepository } from '../../core/app.repository';
4+
5+
@EntityRepository(File)
6+
export class FileRepository extends AppRepository<File> {
7+
constructor() {
8+
super('File');
9+
}
10+
}

src/database/repository/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './project.repository';
33
export * from './task.repository';
44
export * from './update.repository';
55
export * from './comment.repository ';
6+
export * from './file.repository';

src/file/file.controller.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
Controller,
3+
Post,
4+
UseInterceptors,
5+
UploadedFile,
6+
Get,
7+
Param,
8+
Res,
9+
} from '@nestjs/common';
10+
import { FileInterceptor } from '@nestjs/platform-express';
11+
import { File, Employee } from '../database/entity';
12+
import { MulterFile } from '../core/interface';
13+
import { FileService } from './file.service';
14+
import { Auth, CurrentEmployee } from '../core/decorator';
15+
import { Response } from 'express';
16+
17+
@Controller('file')
18+
export class FileController {
19+
constructor(private fileService: FileService) {}
20+
21+
@Post('/')
22+
@Auth()
23+
@UseInterceptors(FileInterceptor('file'))
24+
async create(
25+
@UploadedFile() uploadedFile: MulterFile,
26+
@CurrentEmployee() employee: Employee,
27+
): Promise<File> {
28+
return this.fileService.create(uploadedFile, employee);
29+
}
30+
31+
@Get('/:id')
32+
@Auth()
33+
async get(
34+
@Param('id') fileId: number,
35+
@CurrentEmployee() employee: Employee,
36+
@Res() res: Response,
37+
): Promise<void> {
38+
const file = await this.fileService.get(fileId, employee);
39+
res.setHeader('Content-Type', file.mime);
40+
res.setHeader('Content-Length', file.length);
41+
42+
file.stream.pipe(res);
43+
}
44+
}

src/file/file.module.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Module } from '@nestjs/common';
2+
import { FileController } from './file.controller';
3+
import { FileService } from './file.service';
4+
import { TypeOrmModule } from '@nestjs/typeorm';
5+
import { FileRepository } from '../database/repository';
6+
import { AuthModule } from '../auth/auth.module';
7+
import { MulterConfig } from '../config/multer.config';
8+
import { MulterModule } from '@nestjs/platform-express';
9+
10+
@Module({
11+
imports: [
12+
MulterModule.register(MulterConfig),
13+
TypeOrmModule.forFeature([FileRepository]),
14+
AuthModule,
15+
],
16+
controllers: [FileController],
17+
providers: [FileService],
18+
})
19+
export class FileModule {}

src/file/file.service.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Injectable, BadRequestException } from '@nestjs/common';
2+
import { AppService } from '../core/app.service';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { FileRepository } from '../database/repository';
5+
import { MulterFile } from '../core/interface';
6+
import { File, Employee } from '../database/entity';
7+
import { MimeType } from '../database/enum';
8+
import { promises as fs } from 'fs';
9+
import { Readable, Stream } from 'stream';
10+
11+
@Injectable()
12+
export class FileService extends AppService {
13+
constructor(
14+
@InjectRepository(FileRepository) private fileRepo: FileRepository,
15+
) {
16+
super();
17+
}
18+
19+
async create(uploadedFile: MulterFile, employee: Employee): Promise<File> {
20+
if (!uploadedFile) throw new BadRequestException('File cannot be empty');
21+
22+
const file = new File();
23+
file.filename = uploadedFile.originalname;
24+
file.mime = <MimeType>uploadedFile.mimetype;
25+
file.filepath = uploadedFile.path;
26+
file.owner = employee;
27+
28+
return this.fileRepo.save(file);
29+
}
30+
31+
async get(
32+
fileId: number,
33+
employee: Employee,
34+
): Promise<{ stream: Stream; length: number; mime: MimeType }> {
35+
const file = await this.fileRepo.findOneOrException(fileId);
36+
37+
this.canView(file.isOwner(employee), 'file');
38+
39+
const buffer = await fs.readFile(file.filepath);
40+
const stream = new Readable();
41+
stream.push(buffer);
42+
stream.push(null);
43+
44+
return { stream, length: buffer.byteLength, mime: file.mime };
45+
}
46+
}

test/file.e2e-spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as request from 'supertest';
2+
import { INestApplication } from '@nestjs/common';
3+
import { Test, TestingModule } from '@nestjs/testing';
4+
import { AppModule } from '../src/app.module';
5+
import { FileRepository } from '../src/database/repository';
6+
import { Role } from '../src/database/enum';
7+
import { AuthHelper, TestHelper, FileHelper } from './helper';
8+
import { classToPlain } from 'class-transformer';
9+
10+
describe('FileController (e2e)', () => {
11+
let app: INestApplication;
12+
let fileRepo: FileRepository;
13+
let auth: AuthHelper;
14+
let test: TestHelper;
15+
let file: FileHelper;
16+
17+
beforeEach(async () => {
18+
const moduleRef: TestingModule = await Test.createTestingModule({
19+
imports: [AppModule],
20+
}).compile();
21+
22+
fileRepo = moduleRef.get(FileRepository);
23+
app = moduleRef.createNestApplication();
24+
25+
auth = new AuthHelper(app);
26+
test = new TestHelper(app, auth);
27+
file = new FileHelper();
28+
29+
await app.init();
30+
});
31+
32+
afterEach(async () => {
33+
await app.close();
34+
});
35+
36+
it('test /file (POST, GET) specs', async () => {
37+
const [token, staff] = await auth.signUp({ role: Role.STAFF });
38+
39+
const testFile = {
40+
filename: 'image.jpg',
41+
contentType: 'image/jpeg',
42+
filepath: './test/file/image.jpg',
43+
};
44+
const testFileStat = await file.stat(testFile.filepath);
45+
46+
const res = await request(app.getHttpServer())
47+
.post(`/file`)
48+
.attach('file', testFile.filepath)
49+
.set({ Authorization: token });
50+
51+
// A.1. Return 201 Created on correct create request as task staff
52+
expect(res.status).toEqual(201);
53+
54+
const expected = {
55+
id: res.body.id,
56+
mime: testFile.contentType,
57+
filename: testFile.filename,
58+
owner: { id: staff.id, username: staff.username },
59+
};
60+
61+
// A.2. Return response contain expected file response payload;
62+
expect(res.body).toEqual(expected);
63+
64+
// A.3. File entity is saved in the database
65+
const [files, count] = await fileRepo.findAndCount();
66+
expect(count).toEqual(1);
67+
expect(classToPlain(files[0])).toEqual(expected);
68+
69+
const getRes = await request(app.getHttpServer())
70+
.get(`/file/${res.body.id}`)
71+
.set({ Authorization: token });
72+
73+
// B. Return correct get file payload
74+
expect(getRes.status).toBe(200);
75+
expect(getRes.header['content-type']).toBe(testFile.contentType);
76+
expect(getRes.header['content-length']).toBe(testFileStat.size.toString());
77+
78+
// C. returns 401 Unauthorized when not logged in
79+
await test.unauthorized('POST', `/file`);
80+
81+
await file.emptyFolder('./upload', ['.gitignore']);
82+
});
83+
});

test/file/document.docx

11.3 KB
Binary file not shown.

test/file/image.jpg

21.3 KB
Loading

test/file/pdf.pdf

177 KB
Binary file not shown.

0 commit comments

Comments
 (0)