Skip to content

Commit 480f934

Browse files
authored
feat: File module (#422)
* Basic typing etc. * Fix basic upload * Fix basic upload * Fix Private file upload * Cleanup * cleanup * Fix userpool * Add delete command * Add mutations for file deletion * env cleanup * Better docs
1 parent 68cacc0 commit 480f934

9 files changed

+209
-76
lines changed

backend/src/flox/modules/file/config.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import { MODULES } from '../../MODULES';
88
*/
99

1010
type FileModuleConfig = {
11-
// TODO: once file module is set up, add options here
11+
// File module has no options
1212
};
1313

1414
// Default configuration set; will get merged with custom config from flox.config.json
1515
const defaultConfig: FileModuleConfig = {
16-
// TODO: once file module is set up, add options here
16+
// File module has no options
1717
};
1818

1919
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Field, ID, InputType } from '@nestjs/graphql';
2+
import { IsUUID } from 'class-validator';
3+
4+
@InputType()
5+
export class DeleteFileInput {
6+
@Field(() => ID)
7+
@IsUUID()
8+
uuid: string;
9+
}

backend/src/flox/modules/file/file.controller.ts

+32-43
Original file line numberDiff line numberDiff line change
@@ -4,72 +4,61 @@ import {
44
Post,
55
Req,
66
Res,
7+
UnauthorizedException,
8+
UploadedFile,
9+
UseInterceptors,
710
} from '@nestjs/common';
811
import { FileService } from './file.service';
912
import { LoggedIn, Public } from '../auth/authentication.decorator';
13+
import { Response, Request } from 'express';
14+
import { FileInterceptor } from '@nestjs/platform-express';
1015

1116
@Controller()
1217
export class FileController {
13-
constructor(private readonly taskService: FileService) {}
18+
constructor(private readonly fileService: FileService) {}
1419

1520
@Public()
1621
@Post('/uploadPublicFile')
22+
@UseInterceptors(FileInterceptor('file'))
1723
async uploadPublicFile(
18-
@Req() req: Express.Request,
19-
@Res() res: unknown,
20-
): Promise<any> {
21-
// Verify that request is multipart
22-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
23-
// @ts-ignore
24-
if (!req.isMultipart()) {
25-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
26-
// @ts-ignore
27-
res.send(new BadRequestException('File expected on this endpoint')); // TODO
24+
@Req() req: Request,
25+
@Res() res: Response,
26+
@UploadedFile() file: Express.Multer.File,
27+
): Promise<void> {
28+
// Verify that request contains file
29+
if (!file) {
30+
res.send(new BadRequestException('File expected on this endpoint'));
2831
return;
2932
}
30-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
31-
// @ts-ignore
32-
const file = await req.file();
33-
const fileBuffer = await file.toBuffer();
34-
const newFile = await this.taskService.uploadPublicFile(
35-
fileBuffer,
36-
file.filename,
37-
);
38-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
39-
// @ts-ignore
33+
34+
// Actually upload via FileService
35+
const newFile = await this.fileService.uploadPublicFile(file);
36+
4037
res.send(newFile);
4138
}
4239

4340
@Post('/uploadPrivateFile')
4441
@LoggedIn()
42+
@UseInterceptors(FileInterceptor('file'))
4543
async uploadPrivateFile(
46-
@Req() req: Express.Request,
47-
@Res() res: unknown,
48-
): Promise<any> {
49-
// Verify that request is multipart
50-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
51-
// @ts-ignore
52-
if (!req.isMultipart()) {
53-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
54-
// @ts-ignore
44+
@Req() req: Request,
45+
@Res() res: Response,
46+
@UploadedFile() file: Express.Multer.File,
47+
): Promise<void> {
48+
// Verify that request contains file
49+
if (!file) {
5550
res.send(new BadRequestException('File expected on this endpoint'));
5651
return;
5752
}
5853

59-
// Get user, as determined by JWT Strategy
60-
const owner = req['user'].userId;
54+
// Ensure userID is given
55+
if (!req['user']?.userId) {
56+
res.send(new UnauthorizedException());
57+
}
6158

62-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
63-
// @ts-ignore
64-
const file = await req.file();
65-
const fileBuffer = await file.toBuffer();
66-
const newFile = await this.taskService.uploadPrivateFile(
67-
fileBuffer,
68-
file.filename,
69-
owner,
70-
);
71-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
72-
// @ts-ignore
59+
// Get user, as determined by JWT Strategy
60+
const owner = req['user']?.userId;
61+
const newFile = await this.fileService.uploadPrivateFile(file, owner);
7362
res.send(newFile);
7463
}
7564
}

backend/src/flox/modules/file/file.resolver.ts

+41-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { Args, Resolver, Query } from '@nestjs/graphql';
1+
import { Args, Resolver, Query, Mutation } from '@nestjs/graphql';
22
import PublicFile from './entities/public_file.entity';
33
import { FileService } from './file.service';
4-
import { GetPublicFileArgs } from './dto/get-public-file.args';
5-
import { GetPrivateFileArgs } from './dto/get-private-file.args';
4+
import { GetPublicFileArgs } from './dto/args/get-public-file.args';
5+
import { GetPrivateFileArgs } from './dto/args/get-private-file.args';
66
import PrivateFile from './entities/private_file.entity';
77
import { LoggedIn, Public } from '../auth/authentication.decorator';
8+
import { User } from '../auth/entities/user.entity';
9+
import { DeleteFileInput } from './dto/input/delete-file.input';
810

911
@Resolver(() => PublicFile)
1012
export class FileResolver {
@@ -35,4 +37,40 @@ export class FileResolver {
3537
): Promise<PrivateFile> {
3638
return this.fileService.getPrivateFile(getPrivateFileArgs);
3739
}
40+
41+
/**
42+
* Deletes a private file
43+
* @param {DeleteFileInput} deleteFileInput - contains UUID
44+
* @returns {Promise<PrivateFile>} - the file that was deleted
45+
*/
46+
@LoggedIn() // TODO application specific: set appropriate guards here
47+
@Mutation(() => User)
48+
async deletePrivateFile(
49+
@Args('deleteFileInput')
50+
deleteFileInput: DeleteFileInput,
51+
): Promise<PrivateFile> {
52+
// TODO application specific: Ensure only allowed person (usually admin or file owner) is allowed to delete
53+
return this.fileService.deleteFile(
54+
deleteFileInput,
55+
false,
56+
) as unknown as PrivateFile;
57+
}
58+
59+
/**
60+
* Deletes a public file
61+
* @param {DeleteFileInput} deleteFileInput - contains UUID
62+
* @returns {Promise<PrivateFile>} - the file that was deleted
63+
*/
64+
@LoggedIn() // TODO application specific: set appropriate guards here
65+
@Mutation(() => User)
66+
async deletePublicFile(
67+
@Args('deleteFileInput')
68+
deleteFileInput: DeleteFileInput,
69+
): Promise<PublicFile> {
70+
// TODO application specific: Ensure only allowed person (usually admin ) is allowed to delete
71+
return this.fileService.deleteFile(
72+
deleteFileInput,
73+
true,
74+
) as unknown as PublicFile;
75+
}
3876
}

backend/src/flox/modules/file/file.service.ts

+77-27
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,33 @@ import { InjectRepository } from '@nestjs/typeorm';
33
import { Repository } from 'typeorm';
44
import PublicFile from './entities/public_file.entity';
55
import PrivateFile from './entities/private_file.entity';
6-
import { GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3';
6+
import {
7+
DeleteObjectCommand,
8+
GetObjectCommand,
9+
PutObjectCommand,
10+
S3,
11+
} from '@aws-sdk/client-s3';
712
import { ConfigService } from '@nestjs/config';
813
import { v4 as uuid } from 'uuid';
9-
import { GetPublicFileArgs } from './dto/get-public-file.args';
10-
import { GetPrivateFileArgs } from './dto/get-private-file.args';
14+
import { GetPublicFileArgs } from './dto/args/get-public-file.args';
15+
import { GetPrivateFileArgs } from './dto/args/get-private-file.args';
1116
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
17+
import { DeleteFileInput } from './dto/input/delete-file.input';
1218

1319
@Injectable()
1420
export class FileService {
15-
// TODO: When implementing file module, solve via .env / Terraform
1621
// S3 credentials
17-
// private readonly credentials = {
18-
// region: this.configService.get('AWS_MAIN_REGION'),
19-
// accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY_ID'),
20-
// secretAccessKey: this.configService.get('AWS_S3_SECRET_ACCESS_KEY'),
21-
// };
22+
private readonly credentials = {
23+
region: this.configService.get('AWS_MAIN_REGION'),
24+
accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
25+
secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
26+
};
2227

2328
// AWS S3 instance
24-
private s3: S3 = new S3({});
29+
private s3: S3 = new S3({
30+
credentials: this.credentials,
31+
region: this.credentials.region,
32+
});
2533
constructor(
2634
@InjectRepository(PublicFile)
2735
private publicFilesRepository: Repository<PublicFile>,
@@ -34,51 +42,49 @@ export class FileService {
3442

3543
/**
3644
* Uploads a file to the public S3 bucket
37-
* @param {Buffer} dataBuffer - data buffer representation of the file to upload
38-
* @param {string} filename - the file's name
45+
* @param {Express.Multer.File} file - the file to upload
3946
* @returns {Promise<PublicFile>} - the newly uploaded file
4047
*/
41-
async uploadPublicFile(
42-
dataBuffer: Buffer,
43-
filename: string,
44-
): Promise<PublicFile> {
48+
async uploadPublicFile(file: Express.Multer.File): Promise<PublicFile> {
4549
// File upload
46-
const key = `${uuid()}-${filename}`;
50+
const key = `${uuid()}-${file.originalname}`;
4751
const uploadParams = {
4852
Bucket: this.configService.get('AWS_PUBLIC_BUCKET_NAME'),
4953
Key: key,
50-
Body: dataBuffer,
54+
Body: file.buffer,
55+
ContentType: file.mimetype,
5156
};
5257
await this.s3.send(new PutObjectCommand(uploadParams));
5358
const configService = new ConfigService();
59+
60+
const url = `https://${configService.get(
61+
'AWS_PUBLIC_BUCKET_NAME',
62+
)}.s3.${configService.get('AWS_MAIN_REGION')}.amazonaws.com/${key}`;
63+
5464
const newFile = this.publicFilesRepository.create({
5565
key: key,
56-
url: `https://${configService.get(
57-
'AWS_PUBLIC_BUCKET_NAME',
58-
)}.s3.${configService.get('AWS_MAIN_REGION')}.amazonaws.com/${key}`,
66+
url: url,
5967
});
6068
await this.publicFilesRepository.save(newFile);
6169
return newFile;
6270
}
6371

6472
/**
6573
* Uploads a file to the private S3 bucket
66-
* @param {Buffer} dataBuffer - data buffer representation of the file to upload
67-
* @param {string} filename - the file's name
74+
* @param {Express.Multer.File} file - the file to upload
6875
* @param {string} owner - the file owner's UUID
6976
* @returns {Promise<PrivateFile>} - the newly uploaded file
7077
*/
7178
async uploadPrivateFile(
72-
dataBuffer: Buffer,
73-
filename: string,
79+
file: Express.Multer.File,
7480
owner: string,
7581
): Promise<PrivateFile> {
7682
//File upload
77-
const key = `${uuid()}-${filename}`;
83+
const key = `${uuid()}-${file.originalname}`;
7884
const uploadParams = {
7985
Bucket: this.configService.get('AWS_PRIVATE_BUCKET_NAME'),
8086
Key: key,
81-
Body: dataBuffer,
87+
Body: file.buffer,
8288
};
8389
await this.s3.send(new PutObjectCommand(uploadParams));
8490
const newFile = this.privateFilesRepository.create({
@@ -143,6 +149,50 @@ export class FileService {
143149
return { ...result, url };
144150
}
145151

152+
// File not found: throw error
153+
throw new NotFoundException();
154+
}
155+
156+
/**
157+
* Deletes a private or public file
158+
* @param {DeleteFileInput} deleteFileInput - contains UUID
159+
* @param {boolean} isPublic - whether the file is public (otherwise, is private)
160+
* @returns {Promise<PrivateFile|PublicFile>} - the file that was deleted
161+
*/
162+
async deleteFile(
163+
deleteFileInput: DeleteFileInput,
164+
isPublic: boolean,
165+
): Promise<PrivateFile | PublicFile> {
166+
const repository = isPublic
167+
? this.publicFilesRepository
168+
: this.privateFilesRepository;
169+
170+
const file: PrivateFile | PublicFile = await repository.findOne({
171+
where: {
172+
uuid: deleteFileInput.uuid,
173+
},
174+
});
175+
176+
if (file) {
177+
// Delete on S3
178+
await this.s3.send(
179+
new DeleteObjectCommand({
180+
Bucket: this.configService.get(
181+
isPublic ? 'AWS_PUBLIC_BUCKET_NAME' : 'AWS_PRIVATE_BUCKET_NAME',
182+
),
183+
Key: file.key,
184+
}),
185+
);
186+
187+
// Delete in database (TypeScript does not understand variable typing between PrivateFile / PublicFile here)
188+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
189+
// @ts-ignore
190+
const deletedFile = await repository.remove(file);
191+
deletedFile.uuid = deleteFileInput.uuid;
192+
return deletedFile;
193+
}
194+
195+
// File not found: throw error
146196
throw new NotFoundException();
147197
}
148198
}

backend/src/schema.gql

+6
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
1414
"""
1515
scalar DateTime
1616

17+
input DeleteFileInput {
18+
uuid: ID!
19+
}
20+
1721
input DeleteUserInput {
1822
uuid: ID!
1923
}
2024

2125
type Mutation {
2226
createUser(createUserInput: CreateUserInput!): User!
27+
deletePrivateFile(deleteFileInput: DeleteFileInput!): User!
28+
deletePublicFile(deleteFileInput: DeleteFileInput!): User!
2329
deleteUser(deleteUserInput: DeleteUserInput!): User!
2430
updateUser(updateUserInput: UpdateUserInput!): User!
2531
}

0 commit comments

Comments
 (0)