Skip to content

Commit

Permalink
Implement Image Upload
Browse files Browse the repository at this point in the history
  • Loading branch information
devleejb committed Feb 8, 2024
1 parent dab9499 commit bf01c17
Show file tree
Hide file tree
Showing 12 changed files with 107 additions and 50 deletions.
20 changes: 0 additions & 20 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions backend/src/files/dto/create-upload-url.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
26 changes: 17 additions & 9 deletions backend/src/files/files.controller.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -21,15 +29,15 @@ export class FilesController {
async createUploadPresignedUrl(
@Body() createUploadPresignedUrlDto: CreateUploadPresignedUrlDto
): Promise<CreateUploadPresignedUrlResponse> {
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",
Expand Down
42 changes: 27 additions & 15 deletions backend/src/files/files.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -18,7 +23,11 @@ export class FilesService {
this.s3Client = new S3Client();
}

async createUploadPresignedUrl(workspaceId: string) {
async createUploadPresignedUrl(
workspaceId: string,
contentLength: number,
contentType: string
): Promise<CreateUploadPresignedUrlResponse> {
let workspace: Workspace;
try {
workspace = await this.prismaService.workspace.findFirstOrThrow({
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/files/types/create-upload-url-response.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 21 additions & 2 deletions frontend/src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>();
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);
Expand All @@ -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: [
Expand All @@ -46,7 +65,7 @@ function Editor() {
EditorView.lineWrapping,
keymap.of([indentWithTab]),
intelligencePivot,
imageUploader(async (url) => url, editorStore.doc),
imageUploader(handleUploadImage, editorStore.doc),
],
});

Expand All @@ -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 (
<div
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/hooks/api/file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { CreateUploadUrlRequest, CreateUploadUrlResponse } from "./types/file";
import { UploadFileRequest, CreateUploadUrlRequest, CreateUploadUrlResponse } from "./types/file";

export const useCreateUploadUrlMutation = () => {
return useMutation({
Expand All @@ -11,3 +11,16 @@ export const useCreateUploadUrlMutation = () => {
},
});
};

export const useUploadFileMutation = () => {
return useMutation({
mutationFn: async (data: UploadFileRequest) => {
return axios.put<void>(data.url, new Blob([data.file]), {
headers: {
Authorization: undefined,
"Content-Type": data.file.type,
},
});
},
});
};
8 changes: 8 additions & 0 deletions frontend/src/hooks/api/types/file.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions frontend/src/utils/imageUploader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EditorView } from "codemirror";
import { CodePairDocType } from "../store/editorSlice";

export type UploadCallback = (imageBase64: string) => Promise<string>;
export type UploadCallback = (file: File) => Promise<string>;

const convertImageFilesToUrlList = async (fileList: FileList, uploadCallback: UploadCallback) => {
return await Promise.all(
Expand 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);
Expand Down

0 comments on commit bf01c17

Please sign in to comment.