diff --git a/src/file/file.controller.ts b/src/file/file.controller.ts index 70d3a1a..685eacf 100644 --- a/src/file/file.controller.ts +++ b/src/file/file.controller.ts @@ -1,8 +1,16 @@ import { FastifyReply, FastifyRequest } from "fastify"; import { generateId } from "../utils/id"; -import { deleteFileS3, getFileUrlS3, uploadFileS3 } from "../utils/s3"; +import { + completeMultiPartUpload, + deleteFileS3, + getFileUrlS3, + getUploadUrl, + getUploadUrlMultiPart, + uploadFileS3, +} from "../utils/s3"; import { createFile, getFile } from "./file.service"; +import { UploadMultiPartCompleteRequest } from "./file.schema"; export async function fileUploadHandler( req: FastifyRequest, @@ -15,6 +23,7 @@ export async function fileUploadHandler( try { const chunks = []; for await (const chunk of file.file) { + // @ts-ignore chunks.push(Buffer.from(chunk)); } @@ -35,18 +44,75 @@ export async function fileUploadHandler( } } +export async function fileUploadS3UrlHandler( + req: FastifyRequest, + res: FastifyReply +) { + try { + const key = generateId(); + const signedUrl = await getUploadUrl(key); + + return res.code(200).send({ key, signedUrl }); + } catch (err) { + return err; + } +} + +export async function fileUploadS3UrlMultiPartHandler( + req: FastifyRequest, + res: FastifyReply +) { + try { + const { fileSize } = req.query as { fileSize: number }; + if (!fileSize || fileSize == 0) + return res.code(400).send({ error: "invalid fileSize" }); + + const key = generateId(); + const multiPartRes = await getUploadUrlMultiPart(key, fileSize); + + return res.code(200).send({ key, ...multiPartRes }); + } catch (err) { + return err; + } +} + +export async function finishMulitPartUploadHandler( + req: FastifyRequest, + res: FastifyReply +) { + const input = req.body as UploadMultiPartCompleteRequest; + + try { + await completeMultiPartUpload(input.key, input.uploadId, input.parts); + } catch (err) { + return err; + } +} + export async function fileDownloadHandler( req: FastifyRequest, res: FastifyReply ) { const { fileId } = req.params as { fileId: string }; + const { direct } = req.query as { direct: boolean }; try { - const file = await getFile(fileId, req.user.tenantId); - if (file === null) - return res.code(404).send({ error: "resource not found" }); + let id: string; + let name: string | null; - const signedUrl = await getFileUrlS3(file.pid, file.name); + if (!direct) { + const file = await getFile(fileId, req.user.tenantId); + if (file === null) + return res.code(404).send({ error: "resource not found" }); + + id = file.id; + name = file.name; + } else { + id = fileId; + name = null; + } + + const signedUrl = await getFileUrlS3(id, name); return res.code(200).send({ url: signedUrl }); } catch (err) { return err; diff --git a/src/file/file.route.ts b/src/file/file.route.ts index c590384..415a14f 100644 --- a/src/file/file.route.ts +++ b/src/file/file.route.ts @@ -3,6 +3,9 @@ import { deleteFileHandler, fileDownloadHandler, fileUploadHandler, + fileUploadS3UrlHandler, + fileUploadS3UrlMultiPartHandler, + finishMulitPartUploadHandler, } from "./file.controller"; import { $file } from "./file.schema"; @@ -47,4 +50,42 @@ export async function fileRoutes(fastify: FastifyInstance) { }, deleteFileHandler ); + + fastify.get( + "/", + { + config: { requiredClaims: ["file:upload"] }, + preHandler: [fastify.authorize], + }, + fileUploadS3UrlHandler + ); + + fastify.get( + "/multipart", + { + schema: { + querystring: { + type: "object", + properties: { + fileSize: { type: "number" }, + }, + }, + }, + config: { requiredClaims: ["file:upload"] }, + preHandler: [fastify.authorize], + }, + fileUploadS3UrlMultiPartHandler + ); + + fastify.post( + "/multipart/complete", + { + schema: { + body: $file("uploadMultipartCompleteRequest"), + }, + config: { requiredClaims: ["file:upload"] }, + preHandler: [fastify.authorize], + }, + finishMulitPartUploadHandler + ); } diff --git a/src/file/file.schema.ts b/src/file/file.schema.ts index dc46034..8fc595f 100644 --- a/src/file/file.schema.ts +++ b/src/file/file.schema.ts @@ -31,6 +31,17 @@ const downloadFileResponse = z.object({ url: z.string().url(), }); +const uploadMultipartCompleteRequest = z.object({ + key: z.string(), + uploadId: z.string(), + parts: z.array( + z.object({ + ETag: z.string(), + PartNumber: z.number(), + }) + ), +}); + export const files = z .object({ pid: z.string().optional(), @@ -58,10 +69,15 @@ export const files = z validateRecursive(data); }); +export type UploadMultiPartCompleteRequest = z.infer< + typeof uploadMultipartCompleteRequest +>; + export const { schemas: fileSchemas, $ref: $file } = buildJsonSchemas( { uploadFileResponse, downloadFileResponse, + uploadMultipartCompleteRequest, }, { $id: "file" } ); diff --git a/src/utils/s3.ts b/src/utils/s3.ts index a975415..7179b36 100644 --- a/src/utils/s3.ts +++ b/src/utils/s3.ts @@ -3,10 +3,14 @@ import { PutObjectCommand, GetObjectCommand, DeleteObjectCommand, + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const BUCKET = process.env.BUCKET || ""; +const CHUNK_SIZE = parseInt(process.env.CHUNK_SIZE || "10000000"); const client = new S3Client({ region: process.env.REGION || "", @@ -26,11 +30,69 @@ export async function uploadFileS3(key: string, body: Buffer) { const response = await client.send(command); } -export async function getFileUrlS3(key: string, name: string) { +export async function getUploadUrl(key: string) { + const command = new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + }); + + return await getSignedUrl(client, command, { expiresIn: 300 }); +} + +export async function getUploadUrlMultiPart(key: string, fileSize: number) { + const command = new CreateMultipartUploadCommand({ + Bucket: BUCKET, + Key: key, + }); + + const res = await client.send(command); + const uploadId = res.UploadId; + + const numberOfParts = Math.ceil(fileSize / CHUNK_SIZE); + + let presignedUrls: string[] = []; + + for (let i = 0; i < numberOfParts; i++) { + const presignedUrl = await getSignedUrl( + client, + new UploadPartCommand({ + Bucket: BUCKET, + Key: key, + UploadId: uploadId, + PartNumber: i + 1, + }), + {} + ); + + presignedUrls.push(presignedUrl); + } + + return { chunkSize: CHUNK_SIZE, uploadId, presignedUrls }; +} + +export async function completeMultiPartUpload( + key: string, + uploadId: string, + parts: { ETag: string; PartNumber: number }[] +) { + const command = new CompleteMultipartUploadCommand({ + Key: key, + Bucket: BUCKET, + UploadId: uploadId, + MultipartUpload: { + Parts: parts, + }, + }); + + await client.send(command); +} + +export async function getFileUrlS3(key: string, name: string | null) { const command = new GetObjectCommand({ Bucket: BUCKET, Key: key, - ResponseContentDisposition: `attachment; filename=${name}`, + ResponseContentDisposition: + name !== null ? `attachment; filename=${name}` : undefined, }); return await getSignedUrl(client, command, { expiresIn: 300 }); diff --git a/tsconfig.json b/tsconfig.json index cf86e65..ce5a7af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -85,7 +85,7 @@ /* Type Checking */ // "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */