Skip to content

Commit

Permalink
feat: add ability to upload files
Browse files Browse the repository at this point in the history
  • Loading branch information
kpazgan committed Jul 30, 2024
1 parent 6dbc6d0 commit f362c1d
Show file tree
Hide file tree
Showing 26 changed files with 9,655 additions and 6,091 deletions.
2 changes: 2 additions & 0 deletions examples/common_nestjs_remix/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ yarn-error.log*
# Misc
.DS_Store
*.pem

uploads/*
10 changes: 10 additions & 0 deletions examples/common_nestjs_remix/apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
DATABASE_URL="postgres://postgres:guidebook@localhost:5432/guidebook"
JWT_SECRET=
JWT_REFRESH_SECRET=

# AWS
AWS_ENDPOINT=https://s3.eu-central-1.amazonaws.com
AWS_REGION=eu-central-1
AWS_ACCESS_KEY_ID=2ff0700865cc5c7192db13dbb37434d5
AWS_SECRET_ACCESS_KEY=23924d388c6a5720a32399e80ec191fb
AWS_BUCKET_NAME=test-bucket

# LOCAL UPLOADS
LOCAL_UPLOAD_FOLDER=uploads
5 changes: 5 additions & 0 deletions examples/common_nestjs_remix/apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.617.0",
"@aws-sdk/s3-request-presigner": "^3.617.0",
"@knaadh/nestjs-drizzle-postgres": "^1.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3",
Expand All @@ -42,6 +44,7 @@
"drizzle-orm": "^0.31.2",
"drizzle-typebox": "^0.1.1",
"lodash": "^4.17.21",
"multer": "1.4.5-lts.1",
"nestjs-typebox": "3.0.0-next.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
Expand All @@ -56,12 +59,14 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@testcontainers/localstack": "^10.10.4",
"@types/bcrypt": "^5.0.2",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.6",
"@types/multer": "^1.4.11",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
Expand Down
6 changes: 5 additions & 1 deletion examples/common_nestjs_remix/apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { JwtModule } from "@nestjs/jwt";
import jwtConfig from "./common/configuration/jwt";
import aws from "./common/configuration/aws";
import { APP_GUARD } from "@nestjs/core";
import { JwtAuthGuard } from "./common/guards/jwt-auth-guard";
import { FilesModule } from "./files/files.module";
import localFile from "./common/configuration/local_file";

@Module({
imports: [
ConfigModule.forRoot({
load: [database, jwtConfig],
load: [database, jwtConfig, aws, localFile],
isGlobal: true,
}),
DrizzlePostgresModule.registerAsync({
Expand Down Expand Up @@ -47,6 +50,7 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth-guard";
}),
AuthModule,
UsersModule,
FilesModule,
],
controllers: [],
providers: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

const schema = Type.Object({
region: Type.String(),
accessKeyId: Type.String(),
secretAccessKey: Type.String(),
bucketName: Type.String(),
endpoint: Type.String(),
});

type AWSConfig = Static<typeof schema>;

export default registerAs("aws", (): AWSConfig => {
const values = {
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
bucketName: process.env.AWS_BUCKET_NAME,
endpoint: process.env.AWS_ENDPOINT,
};

return Value.Decode(schema, values);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

const schema = Type.Object({
uploadDir: Type.Optional(Type.String()),
});

type LocalFileConfig = Static<typeof schema>;

export default registerAs("localFile", (): LocalFileConfig => {
const values = {
uploadDir: process.env.LOCAL_UPLOAD_FOLDER,
};

return Value.Decode(schema, values);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { files } from "src/storage/schema";
import { createSelectSchema } from "drizzle-typebox";
import { Static } from "@sinclair/typebox";

export const commonFileSchema = createSelectSchema(files);

export type CommonFile = Static<typeof commonFileSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { DatabasePg } from "src/common";
import { TestContext, createUnitTest } from "test/create-unit-test";
import { truncateAllTables } from "test/helpers/test-helpers";
import { S3FileService } from "../s3-file.service";
import { files } from "../../storage/schema";
import fs from "node:fs";
import { FileService } from "../file.service";

describe("S3FileService", () => {
let testContext: TestContext;
let s3FileService: S3FileService;
let db: DatabasePg;

beforeAll(async () => {
testContext = await createUnitTest();
s3FileService = testContext.module.get(FileService);
db = testContext.db;
}, 70000);

afterAll(async () => {
await testContext.teardown();
});

afterEach(async () => {
await truncateAllTables(db);
});

describe("upload file", () => {
it("should upload a file", async () => {
const buffer = fs.readFileSync(`${__dirname}/test.txt`);
const file = {
buffer,
originalname: "test.txt",
mimetype: "text/plain",
} as Express.Multer.File;

const result = await s3FileService.uploadFile(file);

const [savedFile] = await db.select().from(files).limit(1);
console.log("savedFile", savedFile);

expect(result.id).toBe(expect.any(String));
});
});
});
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
Controller,
Delete,
Get,
Param,
ParseFilePipeBuilder,
Post,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import { BaseResponse, UUIDSchema } from "../../common";
import { Validate } from "nestjs-typebox";
import { FileInterceptor } from "@nestjs/platform-express";
import { FileService } from "../file.service";

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

@Post("upload")
@UseInterceptors(FileInterceptor("file"))
async uploadFile(
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({ fileType: ".(png|jpeg|jpg)" })
.addMaxSizeValidator({ maxSize: 10 * 1024 * 1024 })
.build(),
)
file: Express.Multer.File,
) {
const result = await this.fileService.uploadFile(file);

return new BaseResponse(result);
}

@Get("download/:id")
@Validate({
request: [{ type: "param", name: "id", schema: UUIDSchema }],
})
async downloadFile(@Param("id") id: string) {
return new BaseResponse(await this.fileService.getFileUrl(id));
}

@Delete(":id")
@Validate({
request: [{ type: "param", name: "id", schema: UUIDSchema }],
})
async deleteFile(@Param("id") id: string) {
await this.fileService.deleteFile(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { CommonFile } from "../common/schemas/common-file.schema";

export abstract class FileService {
abstract uploadFile(file: Express.Multer.File): Promise<CommonFile>;

abstract deleteFile(id: string): Promise<void>;

abstract getFileUrl(id: string): Promise<{ url: string }>;
}
15 changes: 15 additions & 0 deletions examples/common_nestjs_remix/apps/api/src/files/files.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { FileService } from "./file.service";
import { LocalFileService } from "./local-file.service";
import { FileController } from "./api/file.controller";

@Module({
providers: [
{
provide: FileService,
useClass: LocalFileService,
},
],
controllers: [FileController],
})
export class FilesModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
Inject,
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { FileService } from "./file.service";
import { ConfigService } from "@nestjs/config";
import { DatabasePg } from "../common";
import { v7 as uuid } from "uuid";
import { files } from "../storage/schema";
import { CommonFile } from "../common/schemas/common-file.schema";
import { eq } from "drizzle-orm";
import * as fs from "node:fs";
import * as path from "node:path";

@Injectable()
export class LocalFileService extends FileService {
uploadsDir = path.join(
__dirname,
"..",
"..",
"..",
"..",
"..",
this.configService.getOrThrow<string>("localFile.uploadDir"),
);
constructor(
private configService: ConfigService,
@Inject("DB") private readonly db: DatabasePg,
) {
super();
}

async uploadFile(file: Express.Multer.File) {
try {
const key = `${uuid()}-${file.originalname}`;
fs.writeFile(path.join(this.uploadsDir, key), file.buffer, (err) => {
if (err) {
throw new InternalServerErrorException("Failed to upload file");
}
});

const [savedFile] = await this.db
.insert(files)
.values({
url: key,
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
})
.returning();

return savedFile as CommonFile;
} catch (error) {
throw new InternalServerErrorException("Failed to upload file");
}
}

async getFileUrl(id: string): Promise<{ url: string }> {
try {
const file = await this.db.query.files.findFirst({
where: eq(files.id, id),
});

if (!file) {
throw new NotFoundException("File not found");
}

const url = `https://storage.guidebook.localhost/${file.url}`;
return { url: url };
} catch (error) {
throw new InternalServerErrorException("Failed to get file");
}
}

async deleteFile(id: string): Promise<void> {
try {
const file = await this.db.query.files.findFirst({
where: eq(files.id, id),
});

if (!file) {
throw new NotFoundException("File not found");
}

fs.unlinkSync(path.join(this.uploadsDir, file.url));

await this.db.delete(files).where(eq(files.id, id)).execute();
} catch (error) {
throw new InternalServerErrorException("Failed to delete file");
}
}
}
Loading

0 comments on commit f362c1d

Please sign in to comment.