From bf01c17623151daa003eb72643c8a282aa4c344e Mon Sep 17 00:00:00 2001 From: devleejb Date: Thu, 8 Feb 2024 19:02:15 +0900 Subject: [PATCH] Implement Image Upload --- backend/package-lock.json | 20 --------- backend/package.json | 1 - .../src/files/dto/create-upload-url.dto.ts | 6 +++ backend/src/files/files.controller.ts | 26 ++++++++---- backend/src/files/files.service.ts | 42 ++++++++++++------- .../types/create-upload-url-response.type.ts | 3 ++ frontend/package-lock.json | 7 ++++ frontend/package.json | 2 + frontend/src/components/editor/Editor.tsx | 23 +++++++++- frontend/src/hooks/api/file.ts | 15 ++++++- frontend/src/hooks/api/types/file.d.ts | 8 ++++ frontend/src/utils/imageUploader.ts | 4 +- 12 files changed, 107 insertions(+), 50 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index f69bbdb6..20e09545 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,7 +10,6 @@ "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "^3.509.0", - "@aws-sdk/s3-presigned-post": "^3.509.0", "@aws-sdk/s3-request-presigner": "^3.509.0", "@langchain/community": "^0.0.21", "@langchain/core": "^0.1.18", @@ -1066,25 +1065,6 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/s3-presigned-post": { - "version": "3.509.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.509.0.tgz", - "integrity": "sha512-JTI3p1DdVsVsfbpkwkq6bYusbm4WUdoBd0DDyofoypWsVGF3S0TbajvwZWR9vFZ84yxGLMifiivysk4Ne8fwZw==", - "dependencies": { - "@aws-sdk/client-s3": "3.509.0", - "@aws-sdk/types": "3.502.0", - "@aws-sdk/util-format-url": "3.502.0", - "@smithy/middleware-endpoint": "^2.4.1", - "@smithy/signature-v4": "^2.1.1", - "@smithy/types": "^2.9.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-sdk/s3-request-presigner": { "version": "3.509.0", "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.509.0.tgz", diff --git a/backend/package.json b/backend/package.json index f7d27e24..c3e9bdcb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,6 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.509.0", - "@aws-sdk/s3-presigned-post": "^3.509.0", "@aws-sdk/s3-request-presigner": "^3.509.0", "@langchain/community": "^0.0.21", "@langchain/core": "^0.1.18", diff --git a/backend/src/files/dto/create-upload-url.dto.ts b/backend/src/files/dto/create-upload-url.dto.ts index 2d15965c..fe707d10 100644 --- a/backend/src/files/dto/create-upload-url.dto.ts +++ b/backend/src/files/dto/create-upload-url.dto.ts @@ -3,4 +3,10 @@ import { ApiProperty } from "@nestjs/swagger"; export class CreateUploadPresignedUrlDto { @ApiProperty({ type: String, description: "ID of workspace to create file" }) workspaceId: string; + + @ApiProperty({ type: Number, description: "Length of content to upload" }) + contentLength: number; + + @ApiProperty({ type: String, description: "Type of file" }) + contentType: string; } diff --git a/backend/src/files/files.controller.ts b/backend/src/files/files.controller.ts index f4fa86e1..dba75628 100644 --- a/backend/src/files/files.controller.ts +++ b/backend/src/files/files.controller.ts @@ -1,8 +1,16 @@ -import { Body, Controller, HttpRedirectResponse, Param, Post, Redirect, Req } from "@nestjs/common"; +import { + Body, + Controller, + Get, + HttpRedirectResponse, + Param, + Post, + Redirect, + Req, +} from "@nestjs/common"; import { FilesService } from "./files.service"; -import { ApiResponse, ApiOperation, ApiBody, ApiBearerAuth } from "@nestjs/swagger"; +import { ApiResponse, ApiOperation, ApiBody } from "@nestjs/swagger"; import { CreateUploadPresignedUrlResponse } from "./types/create-upload-url-response.type"; -import { AuthroizedRequest } from "src/utils/types/req.type"; import { CreateUploadPresignedUrlDto } from "./dto/create-upload-url.dto"; import { Public } from "src/utils/decorators/auth.decorator"; @@ -21,15 +29,15 @@ export class FilesController { async createUploadPresignedUrl( @Body() createUploadPresignedUrlDto: CreateUploadPresignedUrlDto ): Promise { - return { - url: await this.filesService.createUploadPresignedUrl( - createUploadPresignedUrlDto.workspaceId - ), - }; + return this.filesService.createUploadPresignedUrl( + createUploadPresignedUrlDto.workspaceId, + createUploadPresignedUrlDto.contentLength, + createUploadPresignedUrlDto.contentType + ); } @Public() - @Post(":file_name") + @Get(":file_name") @Redirect() @ApiOperation({ summary: "Create Presigned URL for Download", diff --git a/backend/src/files/files.service.ts b/backend/src/files/files.service.ts index eb15019b..bc2ffd07 100644 --- a/backend/src/files/files.service.ts +++ b/backend/src/files/files.service.ts @@ -1,11 +1,16 @@ -import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; -import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { + Injectable, + NotFoundException, + UnauthorizedException, + UnprocessableEntityException, +} from "@nestjs/common"; +import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { createPresignedPost, PresignedPostOptions } from "@aws-sdk/s3-presigned-post"; import { ConfigService } from "@nestjs/config"; import { generateRandomKey } from "src/utils/functions/random-string"; import { PrismaService } from "src/db/prisma.service"; import { Workspace } from "@prisma/client"; +import { CreateUploadPresignedUrlResponse } from "./types/create-upload-url-response.type"; @Injectable() export class FilesService { @@ -18,7 +23,11 @@ export class FilesService { this.s3Client = new S3Client(); } - async createUploadPresignedUrl(workspaceId: string) { + async createUploadPresignedUrl( + workspaceId: string, + contentLength: number, + contentType: string + ): Promise { let workspace: Workspace; try { workspace = await this.prismaService.workspace.findFirstOrThrow({ @@ -30,19 +39,22 @@ export class FilesService { throw new UnauthorizedException(); } - const options: PresignedPostOptions = { + if (contentLength > 10_000_000) { + throw new UnprocessableEntityException(); + } + + const fileKey = `${workspace.slug}-${generateRandomKey()}.${contentType.split("/")[1]}`; + const command = new PutObjectCommand({ Bucket: this.configService.get("AWS_S3_BUCKET_NAME"), - Key: `${workspace.slug}-${generateRandomKey()}`, - Conditions: [ - ["content-length-range", 0, 10_000_000], // 10MB - ["starts-with", "$Content-Type", "image/"], // only image' - ["eq", "x-amz-storage-class", "INTELLIGENT_TIERING"], - ], - Expires: 300, + Key: fileKey, + StorageClass: "INTELLIGENT_TIERING", + ContentType: contentType, + ContentLength: contentLength, + }); + return { + fileKey, + url: await getSignedUrl(this.s3Client, command, { expiresIn: 300 }), }; - const { url } = await createPresignedPost(this.s3Client, options); - - return url; } async createDownloadPresignedUrl(fileKey: string) { diff --git a/backend/src/files/types/create-upload-url-response.type.ts b/backend/src/files/types/create-upload-url-response.type.ts index 5ad6490e..b2e9bb31 100644 --- a/backend/src/files/types/create-upload-url-response.type.ts +++ b/backend/src/files/types/create-upload-url-response.type.ts @@ -3,4 +3,7 @@ import { ApiProperty } from "@nestjs/swagger"; export class CreateUploadPresignedUrlResponse { @ApiProperty({ type: String, description: "Presigned URL for upload" }) url: string; + + @ApiProperty({ type: String, description: "Key of file" }) + fileKey: string; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c1d77ec..85c8fb56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,12 +26,14 @@ "@uiw/codemirror-themes": "^4.21.21", "@uiw/react-markdown-preview": "^5.0.7", "axios": "^1.6.5", + "browser-image-resizer": "^2.4.1", "clipboardy": "^4.0.0", "codemirror": "^6.0.1", "codemirror-markdown-commands": "^0.0.3", "codemirror-markdown-slug": "^0.0.3", "codemirror-toolbar": "^0.0.3", "color": "^4.2.3", + "form-data": "^4.0.0", "lib0": "^0.2.88", "lodash": "^4.17.21", "match-sorter": "^6.3.3", @@ -2744,6 +2746,11 @@ "node": ">=8" } }, + "node_modules/browser-image-resizer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/browser-image-resizer/-/browser-image-resizer-2.4.1.tgz", + "integrity": "sha512-gqrmr7+NTI9FgZVVyw/GIqwJE3MhNWaBn1R5ptu75r+/M5ncyntSMQYuYhOPonm44qQNnkGN9cnghlpd9h1Hug==" + }, "node_modules/browserslist": { "version": "4.22.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7dfa12fa..7ba47614 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,12 +30,14 @@ "@uiw/codemirror-themes": "^4.21.21", "@uiw/react-markdown-preview": "^5.0.7", "axios": "^1.6.5", + "browser-image-resizer": "^2.4.1", "clipboardy": "^4.0.0", "codemirror": "^6.0.1", "codemirror-markdown-commands": "^0.0.3", "codemirror-markdown-slug": "^0.0.3", "codemirror-toolbar": "^0.0.3", "color": "^4.2.3", + "form-data": "^4.0.0", "lib0": "^0.2.88", "lodash": "^4.17.21", "match-sorter": "^6.3.3", diff --git a/frontend/src/components/editor/Editor.tsx b/frontend/src/components/editor/Editor.tsx index 05312f89..aba16f6f 100644 --- a/frontend/src/components/editor/Editor.tsx +++ b/frontend/src/components/editor/Editor.tsx @@ -12,12 +12,17 @@ import { keymap } from "@codemirror/view"; import { indentWithTab } from "@codemirror/commands"; import { intelligencePivot } from "../../utils/intelligence/intelligencePivot"; import { imageUploader } from "../../utils/imageUploader"; +import { useCreateUploadUrlMutation, useUploadFileMutation } from "../../hooks/api/file"; +import { selectWorkspace } from "../../store/workspaceSlice"; function Editor() { const dispatch = useDispatch(); const themeMode = useCurrentTheme(); const [element, setElement] = useState(); const editorStore = useSelector(selectEditor); + const workspaceStore = useSelector(selectWorkspace); + const { mutateAsync: createUploadUrl } = useCreateUploadUrlMutation(); + const { mutateAsync: uploadFile } = useUploadFileMutation(); const ref = useCallback((node: HTMLElement | null) => { if (!node) return; setElement(node); @@ -30,6 +35,20 @@ function Editor() { return; } + const handleUploadImage = async (file: File) => { + if (!workspaceStore.data) return ""; + + const uploadUrlData = await createUploadUrl({ + workspaceId: workspaceStore.data.id, + contentLength: new Blob([file]).size, + contentType: file.type, + }); + + await uploadFile({ ...uploadUrlData, file }); + + return `${import.meta.env.VITE_API_ADDR}/files/${uploadUrlData.fileKey}`; + }; + const state = EditorState.create({ doc: editorStore.doc.getRoot().content?.toString() ?? "", extensions: [ @@ -46,7 +65,7 @@ function Editor() { EditorView.lineWrapping, keymap.of([indentWithTab]), intelligencePivot, - imageUploader(async (url) => url, editorStore.doc), + imageUploader(handleUploadImage, editorStore.doc), ], }); @@ -59,7 +78,7 @@ function Editor() { return () => { view?.destroy(); }; - }, [dispatch, editorStore.client, editorStore.doc, element, themeMode]); + }, [dispatch, editorStore.client, editorStore.doc, element, themeMode, createUploadUrl]); return (
{ return useMutation({ @@ -11,3 +11,16 @@ export const useCreateUploadUrlMutation = () => { }, }); }; + +export const useUploadFileMutation = () => { + return useMutation({ + mutationFn: async (data: UploadFileRequest) => { + return axios.put(data.url, new Blob([data.file]), { + headers: { + Authorization: undefined, + "Content-Type": data.file.type, + }, + }); + }, + }); +}; diff --git a/frontend/src/hooks/api/types/file.d.ts b/frontend/src/hooks/api/types/file.d.ts index dc83f31f..9f6ae48c 100644 --- a/frontend/src/hooks/api/types/file.d.ts +++ b/frontend/src/hooks/api/types/file.d.ts @@ -1,7 +1,15 @@ export class CreateUploadUrlRequest { workspaceId: string; + contentLength: number; + contentType: string; } export class CreateUploadUrlResponse { url: string; + fileKey: string; +} + +export class UploadFileRequest { + url: string; + file: File; } diff --git a/frontend/src/utils/imageUploader.ts b/frontend/src/utils/imageUploader.ts index fa900eca..738509b9 100644 --- a/frontend/src/utils/imageUploader.ts +++ b/frontend/src/utils/imageUploader.ts @@ -1,7 +1,7 @@ import { EditorView } from "codemirror"; import { CodePairDocType } from "../store/editorSlice"; -export type UploadCallback = (imageBase64: string) => Promise; +export type UploadCallback = (file: File) => Promise; const convertImageFilesToUrlList = async (fileList: FileList, uploadCallback: UploadCallback) => { return await Promise.all( @@ -13,7 +13,7 @@ const convertImageFilesToUrlList = async (fileList: FileList, uploadCallback: Up reader.readAsDataURL(file); reader.onload = function () { - resolve(uploadCallback(reader.result as string)); + resolve(uploadCallback(file)); }; reader.onerror = function (error) { reject(error);