Skip to content

Commit

Permalink
feat: File module (#422)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
davwys authored Sep 1, 2022
1 parent 68cacc0 commit 480f934
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 76 deletions.
4 changes: 2 additions & 2 deletions backend/src/flox/modules/file/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { MODULES } from '../../MODULES';
*/

type FileModuleConfig = {
// TODO: once file module is set up, add options here
// File module has no options
};

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

/**
Expand Down
9 changes: 9 additions & 0 deletions backend/src/flox/modules/file/dto/input/delete-file.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Field, ID, InputType } from '@nestjs/graphql';
import { IsUUID } from 'class-validator';

@InputType()
export class DeleteFileInput {
@Field(() => ID)
@IsUUID()
uuid: string;
}
75 changes: 32 additions & 43 deletions backend/src/flox/modules/file/file.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,72 +4,61 @@ import {
Post,
Req,
Res,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileService } from './file.service';
import { LoggedIn, Public } from '../auth/authentication.decorator';
import { Response, Request } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller()
export class FileController {
constructor(private readonly taskService: FileService) {}
constructor(private readonly fileService: FileService) {}

@Public()
@Post('/uploadPublicFile')
@UseInterceptors(FileInterceptor('file'))
async uploadPublicFile(
@Req() req: Express.Request,
@Res() res: unknown,
): Promise<any> {
// Verify that request is multipart
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!req.isMultipart()) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
res.send(new BadRequestException('File expected on this endpoint')); // TODO
@Req() req: Request,
@Res() res: Response,
@UploadedFile() file: Express.Multer.File,
): Promise<void> {
// Verify that request contains file
if (!file) {
res.send(new BadRequestException('File expected on this endpoint'));
return;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const file = await req.file();
const fileBuffer = await file.toBuffer();
const newFile = await this.taskService.uploadPublicFile(
fileBuffer,
file.filename,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore

// Actually upload via FileService
const newFile = await this.fileService.uploadPublicFile(file);

res.send(newFile);
}

@Post('/uploadPrivateFile')
@LoggedIn()
@UseInterceptors(FileInterceptor('file'))
async uploadPrivateFile(
@Req() req: Express.Request,
@Res() res: unknown,
): Promise<any> {
// Verify that request is multipart
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!req.isMultipart()) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@Req() req: Request,
@Res() res: Response,
@UploadedFile() file: Express.Multer.File,
): Promise<void> {
// Verify that request contains file
if (!file) {
res.send(new BadRequestException('File expected on this endpoint'));
return;
}

// Get user, as determined by JWT Strategy
const owner = req['user'].userId;
// Ensure userID is given
if (!req['user']?.userId) {
res.send(new UnauthorizedException());
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const file = await req.file();
const fileBuffer = await file.toBuffer();
const newFile = await this.taskService.uploadPrivateFile(
fileBuffer,
file.filename,
owner,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// Get user, as determined by JWT Strategy
const owner = req['user']?.userId;
const newFile = await this.fileService.uploadPrivateFile(file, owner);
res.send(newFile);
}
}
44 changes: 41 additions & 3 deletions backend/src/flox/modules/file/file.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Args, Resolver, Query } from '@nestjs/graphql';
import { Args, Resolver, Query, Mutation } from '@nestjs/graphql';
import PublicFile from './entities/public_file.entity';
import { FileService } from './file.service';
import { GetPublicFileArgs } from './dto/get-public-file.args';
import { GetPrivateFileArgs } from './dto/get-private-file.args';
import { GetPublicFileArgs } from './dto/args/get-public-file.args';
import { GetPrivateFileArgs } from './dto/args/get-private-file.args';
import PrivateFile from './entities/private_file.entity';
import { LoggedIn, Public } from '../auth/authentication.decorator';
import { User } from '../auth/entities/user.entity';
import { DeleteFileInput } from './dto/input/delete-file.input';

@Resolver(() => PublicFile)
export class FileResolver {
Expand Down Expand Up @@ -35,4 +37,40 @@ export class FileResolver {
): Promise<PrivateFile> {
return this.fileService.getPrivateFile(getPrivateFileArgs);
}

/**
* Deletes a private file
* @param {DeleteFileInput} deleteFileInput - contains UUID
* @returns {Promise<PrivateFile>} - the file that was deleted
*/
@LoggedIn() // TODO application specific: set appropriate guards here
@Mutation(() => User)
async deletePrivateFile(
@Args('deleteFileInput')
deleteFileInput: DeleteFileInput,
): Promise<PrivateFile> {
// TODO application specific: Ensure only allowed person (usually admin or file owner) is allowed to delete
return this.fileService.deleteFile(
deleteFileInput,
false,
) as unknown as PrivateFile;
}

/**
* Deletes a public file
* @param {DeleteFileInput} deleteFileInput - contains UUID
* @returns {Promise<PrivateFile>} - the file that was deleted
*/
@LoggedIn() // TODO application specific: set appropriate guards here
@Mutation(() => User)
async deletePublicFile(
@Args('deleteFileInput')
deleteFileInput: DeleteFileInput,
): Promise<PublicFile> {
// TODO application specific: Ensure only allowed person (usually admin ) is allowed to delete
return this.fileService.deleteFile(
deleteFileInput,
true,
) as unknown as PublicFile;
}
}
104 changes: 77 additions & 27 deletions backend/src/flox/modules/file/file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,33 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import PublicFile from './entities/public_file.entity';
import PrivateFile from './entities/private_file.entity';
import { GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3,
} from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';
import { v4 as uuid } from 'uuid';
import { GetPublicFileArgs } from './dto/get-public-file.args';
import { GetPrivateFileArgs } from './dto/get-private-file.args';
import { GetPublicFileArgs } from './dto/args/get-public-file.args';
import { GetPrivateFileArgs } from './dto/args/get-private-file.args';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { DeleteFileInput } from './dto/input/delete-file.input';

@Injectable()
export class FileService {
// TODO: When implementing file module, solve via .env / Terraform
// S3 credentials
// private readonly credentials = {
// region: this.configService.get('AWS_MAIN_REGION'),
// accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY_ID'),
// secretAccessKey: this.configService.get('AWS_S3_SECRET_ACCESS_KEY'),
// };
private readonly credentials = {
region: this.configService.get('AWS_MAIN_REGION'),
accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
};

// AWS S3 instance
private s3: S3 = new S3({});
private s3: S3 = new S3({
credentials: this.credentials,
region: this.credentials.region,
});
constructor(
@InjectRepository(PublicFile)
private publicFilesRepository: Repository<PublicFile>,
Expand All @@ -34,51 +42,49 @@ export class FileService {

/**
* Uploads a file to the public S3 bucket
* @param {Buffer} dataBuffer - data buffer representation of the file to upload
* @param {string} filename - the file's name
* @param {Express.Multer.File} file - the file to upload
* @returns {Promise<PublicFile>} - the newly uploaded file
*/
async uploadPublicFile(
dataBuffer: Buffer,
filename: string,
): Promise<PublicFile> {
async uploadPublicFile(file: Express.Multer.File): Promise<PublicFile> {
// File upload
const key = `${uuid()}-${filename}`;
const key = `${uuid()}-${file.originalname}`;
const uploadParams = {
Bucket: this.configService.get('AWS_PUBLIC_BUCKET_NAME'),
Key: key,
Body: dataBuffer,
Body: file.buffer,
ContentType: file.mimetype,
};
await this.s3.send(new PutObjectCommand(uploadParams));
const configService = new ConfigService();

const url = `https://${configService.get(
'AWS_PUBLIC_BUCKET_NAME',
)}.s3.${configService.get('AWS_MAIN_REGION')}.amazonaws.com/${key}`;

const newFile = this.publicFilesRepository.create({
key: key,
url: `https://${configService.get(
'AWS_PUBLIC_BUCKET_NAME',
)}.s3.${configService.get('AWS_MAIN_REGION')}.amazonaws.com/${key}`,
url: url,
});
await this.publicFilesRepository.save(newFile);
return newFile;
}

/**
* Uploads a file to the private S3 bucket
* @param {Buffer} dataBuffer - data buffer representation of the file to upload
* @param {string} filename - the file's name
* @param {Express.Multer.File} file - the file to upload
* @param {string} owner - the file owner's UUID
* @returns {Promise<PrivateFile>} - the newly uploaded file
*/
async uploadPrivateFile(
dataBuffer: Buffer,
filename: string,
file: Express.Multer.File,
owner: string,
): Promise<PrivateFile> {
//File upload
const key = `${uuid()}-${filename}`;
const key = `${uuid()}-${file.originalname}`;
const uploadParams = {
Bucket: this.configService.get('AWS_PRIVATE_BUCKET_NAME'),
Key: key,
Body: dataBuffer,
Body: file.buffer,
};
await this.s3.send(new PutObjectCommand(uploadParams));
const newFile = this.privateFilesRepository.create({
Expand Down Expand Up @@ -143,6 +149,50 @@ export class FileService {
return { ...result, url };
}

// File not found: throw error
throw new NotFoundException();
}

/**
* Deletes a private or public file
* @param {DeleteFileInput} deleteFileInput - contains UUID
* @param {boolean} isPublic - whether the file is public (otherwise, is private)
* @returns {Promise<PrivateFile|PublicFile>} - the file that was deleted
*/
async deleteFile(
deleteFileInput: DeleteFileInput,
isPublic: boolean,
): Promise<PrivateFile | PublicFile> {
const repository = isPublic
? this.publicFilesRepository
: this.privateFilesRepository;

const file: PrivateFile | PublicFile = await repository.findOne({
where: {
uuid: deleteFileInput.uuid,
},
});

if (file) {
// Delete on S3
await this.s3.send(
new DeleteObjectCommand({
Bucket: this.configService.get(
isPublic ? 'AWS_PUBLIC_BUCKET_NAME' : 'AWS_PRIVATE_BUCKET_NAME',
),
Key: file.key,
}),
);

// Delete in database (TypeScript does not understand variable typing between PrivateFile / PublicFile here)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const deletedFile = await repository.remove(file);
deletedFile.uuid = deleteFileInput.uuid;
return deletedFile;
}

// File not found: throw error
throw new NotFoundException();
}
}
6 changes: 6 additions & 0 deletions backend/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
"""
scalar DateTime

input DeleteFileInput {
uuid: ID!
}

input DeleteUserInput {
uuid: ID!
}

type Mutation {
createUser(createUserInput: CreateUserInput!): User!
deletePrivateFile(deleteFileInput: DeleteFileInput!): User!
deletePublicFile(deleteFileInput: DeleteFileInput!): User!
deleteUser(deleteUserInput: DeleteUserInput!): User!
updateUser(updateUserInput: UpdateUserInput!): User!
}
Expand Down
Loading

0 comments on commit 480f934

Please sign in to comment.