From 2e239419d9422cac15d7acbb73d51f9111867e5e Mon Sep 17 00:00:00 2001 From: Jaehyeon Kim Date: Wed, 29 May 2024 16:20:36 +0900 Subject: [PATCH] feat(be): implement image upload (#1514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(be): implement image upload to S3 * docs(be): add upload image api docs * chore(be): modify image bucket baseurl * docs(be): add assert * chore(be): add alt property to return type of image-upload api * chore(infra): rename testcase to storage * feat(infra): add env for media bucket * chore(be): add sample image and modify return object - 샘플 이미지 추가 - src, alt 반환하던 것에서 src만 반환하는 것으로 변경 * Optimised images with calibre/image-actions * chore(be): add s3-media-provider to test code * chore(be): use relative path for sample image * chore(be): replace client with media-client * feat(be): add delete-image function * refactor: combine bucket setup scripts * fix(be): modify modify image size calculating logic - 5MB 초과 파일이 들어왔을 때도 계속해서 파일 사이즈를 계산하는 문제 수정 - 5MB 초과 시 파일 사이즈 계산 중지, 예외 던지도록 구현 * chore(be): modify filename to use uuid only * chore(be): remove duplicated import lines * chore(be): remove file extension * feat(be): add delete-image and improve image upload, delete logic * docs(be): add delete-image api docs * chore(be): parallelize deleting image logic * chore(be): parallelize image-delete api --------- Co-authored-by: k1g99 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jiyun Park <80032256+cho-to@users.noreply.github.com> --- .devcontainer/devcontainer.json | 2 +- .env.stage | 11 +- .gitpod.yml | 2 +- .../admin/src/problem/model/image.output.ts | 7 + .../apps/admin/src/problem/problem.module.ts | 3 +- .../admin/src/problem/problem.resolver.ts | 40 + .../admin/src/problem/problem.service.spec.ts | 3 +- .../apps/admin/src/problem/problem.service.ts | 158 +++- .../apps/admin/src/storage/s3.provider.ts | 20 +- .../apps/admin/src/storage/storage.module.ts | 4 +- .../admin/src/storage/storage.service.spec.ts | 4 +- .../apps/admin/src/storage/storage.service.ts | 30 +- .../libs/constants/src/time.constants.ts | 5 + .../libs/exception/src/business.exception.ts | 4 +- .../migration.sql | 14 + apps/backend/prisma/schema.prisma | 8 + apps/iris/src/loader/s3/source.go | 3 +- .../admin/Problem/Delete Image/NOT_FOUND.bru | 41 + .../admin/Problem/Delete Image/Succeed.bru | 31 + .../admin/Problem/Upload Image/Succeed.bru | 39 + .../admin/Problem/Upload Image/sample.png | Bin 0 -> 59634 bytes docker-compose.yml | 10 +- package.json | 2 +- pnpm-lock.yaml | 725 +++++++++--------- scripts/{init-testcase.ts => init-storage.ts} | 56 +- scripts/setup.sh | 4 +- 26 files changed, 821 insertions(+), 405 deletions(-) create mode 100644 apps/backend/apps/admin/src/problem/model/image.output.ts create mode 100644 apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql create mode 100644 collection/admin/Problem/Delete Image/NOT_FOUND.bru create mode 100644 collection/admin/Problem/Delete Image/Succeed.bru create mode 100644 collection/admin/Problem/Upload Image/Succeed.bru create mode 100644 collection/admin/Problem/Upload Image/sample.png rename scripts/{init-testcase.ts => init-storage.ts} (54%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 558ff855a7..52b8c4cb45 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "database", "cache", "rabbitmq", - "testcase", + "storage", "test-database" ], "workspaceFolder": "/workspace", diff --git a/.env.stage b/.env.stage index bc73955011..b60c7aba04 100644 --- a/.env.stage +++ b/.env.stage @@ -22,17 +22,26 @@ RABBITMQ_CONSUMER_CONNECTION_NAME=iris-consumer RABBITMQ_CONSUMER_TAG=consumer RABBITMQ_PRODUCER_CONNECTION_NAME=iris-producer +# Storage +STORAGE_BUCKET_ENDPOINT_URL=http://127.0.0.1:9000 # Testcase Endpoint TESTCASE_BUCKET_NAME=test-bucket -TESTCASE_ENDPOINT_URL=http://127.0.0.1:9000 +# TESTCASE_ENDPOINT_URL=http://127.0.0.1:9000 TESTCASE_ACCESS_KEY=skku TESTCASE_SECRET_KEY=skku1234 +# Media Upload Endpoint +MEDIA_BUCKET_NAME=image-bucket +# MEDIA_BUCKET_BASE_URL=http://127.0.0.1:9000/image-bucket/ +MEDIA_ACCESS_KEY=skku +MEDIA_SECRET_KEY=skku1234 + REDIS_HOST=127.0.0.1 REDIS_PORT=6380 DATABASE_URL=postgresql://postgres:1234@127.0.0.1:5433/skkuding?schema=public TEST_DATABASE_URL=postgresql://postgres:1234@127.0.0.1:5434/skkuding?schema=public + # TODO: Add information where each of these variables are used # TODO: I want to edit values after the container is created... diff --git a/.gitpod.yml b/.gitpod.yml index 51c1ca3a13..89a3c3d58f 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -23,7 +23,7 @@ ports: - port: 15672 # RabbitMQ Dashboard onOpen: ignore - - port: 30000 # Testcase Server + - port: 30000 # Storage Server onOpen: ignore tasks: diff --git a/apps/backend/apps/admin/src/problem/model/image.output.ts b/apps/backend/apps/admin/src/problem/model/image.output.ts new file mode 100644 index 0000000000..b70a88931e --- /dev/null +++ b/apps/backend/apps/admin/src/problem/model/image.output.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql' + +@ObjectType({ description: 'image' }) +export class ImageSource { + @Field(() => String) + src: string +} diff --git a/apps/backend/apps/admin/src/problem/problem.module.ts b/apps/backend/apps/admin/src/problem/problem.module.ts index a701925fc8..2d91f3ed09 100644 --- a/apps/backend/apps/admin/src/problem/problem.module.ts +++ b/apps/backend/apps/admin/src/problem/problem.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { StorageModule } from '@admin/storage/storage.module' import { ProblemTagResolver, TagResolver } from './problem-tag.resolver' import { @@ -9,7 +10,7 @@ import { import { ProblemService } from './problem.service' @Module({ - imports: [StorageModule], + imports: [StorageModule, ConfigModule], providers: [ ProblemResolver, ProblemTagResolver, diff --git a/apps/backend/apps/admin/src/problem/problem.resolver.ts b/apps/backend/apps/admin/src/problem/problem.resolver.ts index 8c713f448c..9a83cfe470 100644 --- a/apps/backend/apps/admin/src/problem/problem.resolver.ts +++ b/apps/backend/apps/admin/src/problem/problem.resolver.ts @@ -19,12 +19,14 @@ import { } from '@nestjs/graphql' import { ContestProblem, + Image, Problem, ProblemTag, ProblemTestcase, WorkbookProblem } from '@generated' import { Prisma } from '@prisma/client' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { AuthenticatedRequest } from '@libs/auth' import { OPEN_SPACE_ID } from '@libs/constants' import { @@ -33,6 +35,7 @@ import { UnprocessableDataException } from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' +import { ImageSource } from './model/image.output' import { CreateProblemInput, UploadFileInput, @@ -105,6 +108,43 @@ export class ProblemResolver { } } + @Mutation(() => ImageSource) + async uploadImage( + @Args('input') input: UploadFileInput, + @Context('req') req: AuthenticatedRequest + ) { + try { + return await this.problemService.uploadImage(input, req.user.id) + } catch (error) { + if (error instanceof UnprocessableDataException) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Mutation(() => Image) + async deleteImage( + @Args('filename') filename: string, + @Context('req') req: AuthenticatedRequest + ) { + try { + return await this.problemService.deleteImage(filename, req.user.id) + } catch (error) { + if (error instanceof UnprocessableDataException) { + throw error.convert2HTTPException() + } else if ( + error instanceof PrismaClientKnownRequestError && + error.code == 'P2025' + ) { + throw new NotFoundException(error.message) + } + this.logger.error(error) + throw new InternalServerErrorException() + } + } + @Query(() => [Problem]) async getProblems( @Args( diff --git a/apps/backend/apps/admin/src/problem/problem.service.spec.ts b/apps/backend/apps/admin/src/problem/problem.service.spec.ts index 1077a85879..831597f5d9 100644 --- a/apps/backend/apps/admin/src/problem/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/problem/problem.service.spec.ts @@ -11,7 +11,7 @@ import { UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' -import { S3Provider } from '@admin/storage/s3.provider' +import { S3MediaProvider, S3Provider } from '@admin/storage/s3.provider' import { StorageService } from '@admin/storage/storage.service' import { exampleContest, @@ -96,6 +96,7 @@ describe('ProblemService', () => { StorageService, ConfigService, S3Provider, + S3MediaProvider, { provide: CACHE_MANAGER, useValue: { del: () => null } } ] }).compile() diff --git a/apps/backend/apps/admin/src/problem/problem.service.ts b/apps/backend/apps/admin/src/problem/problem.service.ts index bdfb67ba90..688b35b208 100644 --- a/apps/backend/apps/admin/src/problem/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/problem.service.ts @@ -5,12 +5,17 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' import { Language } from '@generated' import type { ContestProblem, Tag, WorkbookProblem } from '@generated' import { Level } from '@generated' import type { ProblemWhereInput } from '@generated' import { Prisma } from '@prisma/client' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' +import { randomUUID } from 'crypto' import { Workbook } from 'exceljs' +import type { ReadStream } from 'fs' +import { MAX_IMAGE_SIZE } from '@libs/constants' import { DuplicateFoundException, EntityNotExistException, @@ -41,6 +46,7 @@ export class ProblemService { constructor( private readonly prisma: PrismaService, private readonly storageService: StorageService, + private readonly config: ConfigService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache ) {} @@ -133,25 +139,20 @@ export class ProblemService { throw new UnprocessableDataException( 'Extensions except Excel(.xlsx, .xls) are not supported.' ) - const header = {} const problems: CreateProblemInput[] = [] - const workbook = new Workbook() const worksheet = (await workbook.xlsx.read(createReadStream())) .worksheets[0] - worksheet.getRow(1).eachCell((cell, idx) => { if (!ImportedProblemHeader.includes(cell.text)) throw new UnprocessableFileDataException( - `Field ${cell.text} is not supported`, - filename, - 1 + `Field ${cell.text} is not supported: ${1}`, + filename ) header[cell.text] = idx }) worksheet.spliceRows(1, 1) - const unsupportedFields = [ header['InputFileName'], header['InputFilePath'], @@ -162,9 +163,8 @@ export class ProblemService { for (const colNumber of unsupportedFields) { if (row.getCell(colNumber).text !== '') throw new UnprocessableFileDataException( - 'Using inputFile, outputFile is not supported', - filename, - rowNumber + 1 + `Using inputFile, outputFile is not supported: ${rowNumber + 1}`, + filename ) } const title = row.getCell(header['문제제목']).text @@ -174,17 +174,13 @@ export class ProblemService { const languages: Language[] = [] const level: Level = Level['Level' + levelText] const template: Template[] = [] - for (let text of languagesText) { if (text === 'Python') { text = 'Python3' } - if (!(text in Language)) continue - const language = text as keyof typeof Language const code = row.getCell(header[`${language}SampleCode`]).text - template.push({ language, code: [ @@ -197,42 +193,33 @@ export class ProblemService { }) languages.push(Language[language]) } - if (!languages.length) { throw new UnprocessableFileDataException( - 'A problem should support at least one language', - filename, - rowNumber + 1 + `A problem should support at least one language: ${rowNumber + 1}`, + filename ) } - //TODO: specify timeLimit, memoryLimit(default: 2sec, 512mb) - const testCnt = parseInt(row.getCell(header['TestCnt']).text) const inputText = row.getCell(header['Input']).text const outputs = row.getCell(header['Output']).text.split('::') const scoreWeights = row.getCell(header['Score']).text.split('::') - if (testCnt === 0) return - let inputs: string[] = [] if (inputText === '' && testCnt !== 0) { for (let i = 0; i < testCnt; i++) inputs.push('') } else { inputs = inputText.split('::') } - if ( (inputs.length !== testCnt || outputs.length !== testCnt) && inputText != '' ) { throw new UnprocessableFileDataException( - 'TestCnt must match the length of Input and Output. Or Testcases should not include ::.', - filename, - rowNumber + 1 + `TestCnt must match the length of Input and Output. Or Testcases should not include ::. :${rowNumber + 1}`, + filename ) } - const testcaseInput: Testcase[] = [] for (let i = 0; i < testCnt; i++) { testcaseInput.push({ @@ -241,7 +228,6 @@ export class ProblemService { scoreWeight: parseInt(scoreWeights[i]) || undefined }) } - problems.push({ title, description, @@ -260,7 +246,6 @@ export class ProblemService { samples: [] }) }) - return await Promise.all( problems.map(async (data) => { const problem = await this.createProblem(data, userId, groupId) @@ -269,6 +254,92 @@ export class ProblemService { ) } + async uploadImage(input: UploadFileInput, userId: number) { + const { mimetype, createReadStream } = await input.file + const newFilename = randomUUID() + + if (!mimetype.includes('image/')) { + throw new UnprocessableDataException('Only image files can be accepted') + } + + const fileSize = await this.getFileSize(createReadStream()) + try { + await this.storageService.uploadImage( + newFilename, + fileSize, + createReadStream(), + mimetype + ) + await this.prisma.image.create({ + data: { + filename: newFilename, + createdById: userId + } + }) + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + await this.storageService.deleteImage(newFilename) // 이미지가 S3에 업로드되었지만, DB에 이미지 정보 등록을 실패한 경우 rollback + } + throw new UnprocessableFileDataException( + 'Error occurred during image upload.', + newFilename + ) + } + + return { + src: + this.config.get('STORAGE_BUCKET_ENDPOINT_URL') + + '/' + + this.config.get('MEDIA_BUCKET_NAME') + + '/' + + newFilename + } + } + + async deleteImage(filename: string, userId: number) { + const image = this.prisma.image.delete({ + where: { + filename, + createdById: userId + } + }) + const s3ImageDeleteResult = this.storageService.deleteImage(filename) + + const [resolvedImage] = await Promise.all([image, s3ImageDeleteResult]) + return resolvedImage + } + + async getFileSize(readStream: ReadStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + + readStream.on('data', (chunk: Buffer) => { + chunks.push(chunk) + + const totalSize = chunks.reduce((acc, chunk) => acc + chunk.length, 0) + if (totalSize > MAX_IMAGE_SIZE) { + readStream.destroy() + reject( + new UnprocessableDataException('File size exceeds maximum limit') + ) + } + }) + + readStream.on('end', () => { + const fileSize = chunks.reduce((acc, chunk) => acc + chunk.length, 0) + resolve(fileSize) + }) + + readStream.on('error', () => { + reject( + new UnprocessableDataException( + 'Error occurred during calculating image size.' + ) + ) + }) + }) + } + async getProblems( input: FilterProblemsInput, groupId: number, @@ -434,16 +505,43 @@ export class ProblemService { } async deleteProblem(id: number, groupId: number) { - await this.getProblem(id, groupId) + const problem = await this.getProblem(id, groupId) const result = await this.prisma.problem.delete({ where: { id } }) await this.storageService.deleteObject(`${id}.json`) + const uuidImageFileNames = this.extractUUIDs(problem.description) + if (uuidImageFileNames) { + await this.prisma.image.deleteMany({ + where: { + filename: { + in: uuidImageFileNames + } + } + }) + + const deleteFromS3Results = uuidImageFileNames.map((filename: string) => { + return this.storageService.deleteImage(filename) + }) + + await Promise.all(deleteFromS3Results) + } + return result } + extractUUIDs(input: string) { + const uuidRegex = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + const matches = input.match(uuidRegex) + if (!matches) { + return [] + } + return matches + } + async getWorkbookProblems( groupId: number, workbookId: number diff --git a/apps/backend/apps/admin/src/storage/s3.provider.ts b/apps/backend/apps/admin/src/storage/s3.provider.ts index 2048e55b2d..98cd0b91d7 100644 --- a/apps/backend/apps/admin/src/storage/s3.provider.ts +++ b/apps/backend/apps/admin/src/storage/s3.provider.ts @@ -9,7 +9,8 @@ export const S3Provider = { new S3Client({ region: 'ap-northeast-2', // TODO: production 환경에서는 endpoint, forcePathStyle 삭제 - endpoint: config.get('TESTCASE_ENDPOINT_URL'), + // endpoint: config.get('TESTCASE_ENDPOINT_URL'), + endpoint: config.get('STORAGE_BUCKET_ENDPOINT_URL'), forcePathStyle: true, credentials: { accessKeyId: config.get('TESTCASE_ACCESS_KEY') ?? '', @@ -17,3 +18,20 @@ export const S3Provider = { } }) } + +export const S3MediaProvider = { + provide: 'S3_CLIENT_MEDIA', + import: [ConfigModule], + inject: [ConfigService], + useFactory: async (config: ConfigService) => + new S3Client({ + region: 'ap-northeast-2', + // TODO: production 환경에서는 endpoint, forcePathStyle 삭제 + endpoint: config.get('STORAGE_BUCKET_ENDPOINT_URL'), + forcePathStyle: true, + credentials: { + accessKeyId: config.get('MEDIA_ACCESS_KEY') ?? '', + secretAccessKey: config.get('MEDIA_SECRET_KEY') ?? '' + } + }) +} diff --git a/apps/backend/apps/admin/src/storage/storage.module.ts b/apps/backend/apps/admin/src/storage/storage.module.ts index 33a014f8a0..b3e3715b8f 100644 --- a/apps/backend/apps/admin/src/storage/storage.module.ts +++ b/apps/backend/apps/admin/src/storage/storage.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' -import { S3Provider } from './s3.provider' +import { S3MediaProvider, S3Provider } from './s3.provider' import { StorageService } from './storage.service' @Module({ imports: [ConfigModule], - providers: [S3Provider, StorageService], + providers: [S3Provider, S3MediaProvider, StorageService], exports: [StorageService] }) export class StorageModule {} diff --git a/apps/backend/apps/admin/src/storage/storage.service.spec.ts b/apps/backend/apps/admin/src/storage/storage.service.spec.ts index e6c391a186..817e0143fa 100644 --- a/apps/backend/apps/admin/src/storage/storage.service.spec.ts +++ b/apps/backend/apps/admin/src/storage/storage.service.spec.ts @@ -1,7 +1,7 @@ import { ConfigService } from '@nestjs/config' import { Test, type TestingModule } from '@nestjs/testing' import { expect } from 'chai' -import { S3Provider } from './s3.provider' +import { S3MediaProvider, S3Provider } from './s3.provider' import { StorageService } from './storage.service' describe('StorageService', () => { @@ -9,7 +9,7 @@ describe('StorageService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [StorageService, S3Provider, ConfigService] + providers: [StorageService, S3Provider, S3MediaProvider, ConfigService] }).compile() service = module.get(StorageService) diff --git a/apps/backend/apps/admin/src/storage/storage.service.ts b/apps/backend/apps/admin/src/storage/storage.service.ts index e176a0fa0e..efe6cc7425 100644 --- a/apps/backend/apps/admin/src/storage/storage.service.ts +++ b/apps/backend/apps/admin/src/storage/storage.service.ts @@ -6,13 +6,15 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import type { ReadStream } from 'fs' import { type ContentType, ContentTypes } from './content.type' @Injectable() export class StorageService { constructor( private readonly config: ConfigService, - @Inject('S3_CLIENT') private readonly client: S3Client + @Inject('S3_CLIENT') private readonly client: S3Client, + @Inject('S3_CLIENT_MEDIA') private readonly mediaClient: S3Client ) {} async uploadObject(filename: string, content: string, type: ContentType) { @@ -26,6 +28,23 @@ export class StorageService { ) } + async uploadImage( + filename: string, + fileSize: number, + content: ReadStream, + type: string + ) { + await this.mediaClient.send( + new PutObjectCommand({ + Bucket: this.config.get('MEDIA_BUCKET_NAME'), + Key: filename, + Body: content, + ContentType: type, + ContentLength: fileSize + }) + ) + } + // TODO: uploadFile async readObject(filename: string) { @@ -46,4 +65,13 @@ export class StorageService { }) ) } + + async deleteImage(filename: string) { + await this.mediaClient.send( + new DeleteObjectCommand({ + Bucket: this.config.get('MEDIA_BUCKET_NAME'), + Key: filename + }) + ) + } } diff --git a/apps/backend/libs/constants/src/time.constants.ts b/apps/backend/libs/constants/src/time.constants.ts index ed447c0e6e..2945da5883 100644 --- a/apps/backend/libs/constants/src/time.constants.ts +++ b/apps/backend/libs/constants/src/time.constants.ts @@ -23,3 +23,8 @@ export const INVIATION_EXPIRE_TIME = 14 * SECONDS_PER_DAY * 1000 export const OPEN_SPACE_ID = 1 export const PUBLICIZING_REQUEST_KEY = 'publicize' + +/** Image Size Limitation */ +const KILOBYTE = 1024 +const MEGABYTE = 1024 * KILOBYTE +export const MAX_IMAGE_SIZE = 5 * MEGABYTE diff --git a/apps/backend/libs/exception/src/business.exception.ts b/apps/backend/libs/exception/src/business.exception.ts index 2ddbcd17e3..b5a88de540 100644 --- a/apps/backend/libs/exception/src/business.exception.ts +++ b/apps/backend/libs/exception/src/business.exception.ts @@ -78,8 +78,8 @@ export class UnprocessableDataException extends BusinessException { /** [422] Throw when file data is invalid or cannot be processed. */ export class UnprocessableFileDataException extends UnprocessableDataException { - constructor(message, fileName, rowNumber) { - super(`${message} @${fileName}:${rowNumber}`) + constructor(message, fileName) { + super(`${message} @${fileName}`) } } diff --git a/apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql b/apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql new file mode 100644 index 0000000000..6c3bfce9bd --- /dev/null +++ b/apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Image" ( + "filename" TEXT NOT NULL, + "createdById" INTEGER, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Image_pkey" PRIMARY KEY ("filename") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Image_filename_key" ON "Image"("filename"); + +-- AddForeignKey +ALTER TABLE "Image" ADD CONSTRAINT "Image_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index b96f9b6093..a1afc8030d 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -58,6 +58,7 @@ model User { submission Submission[] useroauth UserOAuth? CodeDraft CodeDraft[] + Image Image[] @@map("user") } @@ -193,6 +194,13 @@ model Problem { @@map("problem") } +model Image { + filename String @id @unique + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + createdById Int? + createTime DateTime @default(now()) @map("create_time") +} + enum Level { Level1 Level2 diff --git a/apps/iris/src/loader/s3/source.go b/apps/iris/src/loader/s3/source.go index dc55a40ef4..179b048543 100644 --- a/apps/iris/src/loader/s3/source.go +++ b/apps/iris/src/loader/s3/source.go @@ -18,7 +18,8 @@ type s3DataSource struct { func NewS3DataSource(bucket string) *s3DataSource { var client *s3.Client - endpoint := os.Getenv("TESTCASE_ENDPOINT_URL") + // endpoint := os.Getenv("TESTCASE_ENDPOINT_URL") + endpoint := os.Getenv("STORAGE_BUCKET_ENDPOINT_URL") if endpoint == "" { // Connect to AWS S3 client = s3.New(s3.Options{Region: "ap-northeast-2"}) diff --git a/collection/admin/Problem/Delete Image/NOT_FOUND.bru b/collection/admin/Problem/Delete Image/NOT_FOUND.bru new file mode 100644 index 0000000000..1cf2853622 --- /dev/null +++ b/collection/admin/Problem/Delete Image/NOT_FOUND.bru @@ -0,0 +1,41 @@ +meta { + name: NOT_FOUND + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation DeleteImage ($filename: String!) { + deleteImage(filename: $filename) { + filename + createdById + createTime + } + } +} + +body:graphql:vars { + { + "filename": "not found" + } +} + +assert { + res.body.errors: isDefined + res.body.errors[0].extensions.code: eq NOT_FOUND +} + +docs { + ## Delete Image + S3 Bucket에 업로드된 이미지를 삭제합니다. + + ### Error Cases + #### NOT_FOUND + 존재하는 filename을 사용해야 합니다. +} diff --git a/collection/admin/Problem/Delete Image/Succeed.bru b/collection/admin/Problem/Delete Image/Succeed.bru new file mode 100644 index 0000000000..b0969bfbbb --- /dev/null +++ b/collection/admin/Problem/Delete Image/Succeed.bru @@ -0,0 +1,31 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation DeleteImage ($filename: String!) { + deleteImage(filename: $filename) { + filename + createdById + createTime + } + } +} + +body:graphql:vars { + { + "filename": "fill this field with filename after executing upload-image api" + } +} + +assert { + res.body.data.deleteImage: isDefined +} diff --git a/collection/admin/Problem/Upload Image/Succeed.bru b/collection/admin/Problem/Upload Image/Succeed.bru new file mode 100644 index 0000000000..e645ae3ed0 --- /dev/null +++ b/collection/admin/Problem/Upload Image/Succeed.bru @@ -0,0 +1,39 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +post { + url: {{gqlUrl}} + body: multipartForm + auth: none +} + +headers { + Apollo-Require-Preflight: true +} + +body:multipart-form { + operations: { "query": "mutation($input: UploadFileInput!){uploadImage(input: $input){ src }}","variables": {"input": {"file": null}}} + map: { "nfile" : ["variables.input.file"]} + nfile: @file(Problem/Upload Image/sample.png) +} + +assert { + res.status: eq 200 + res.body.data.uploadImage: isDefined + res.body.data.uploadImage.src: isString +} + +docs { + ## Upload Image + 이미지를 업로드하고, 업로드된 이미지의 URL 주소를 반환합니다. + + ### Error Cases + #### UNPROCESSABLE (1) + 이미지 파일만 업로드가 가능합니다. (mimetype이 image/*) + + #### UNPROCESSABLE (2) + 5MB 이하의 이미지 파일만 업로드가 가능합니다. +} diff --git a/collection/admin/Problem/Upload Image/sample.png b/collection/admin/Problem/Upload Image/sample.png new file mode 100644 index 0000000000000000000000000000000000000000..91be371e4a033f4db6b0bb63d307412e9637ca63 GIT binary patch literal 59634 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