Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(be): implement image upload #1514

Merged
merged 33 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d5906cd
feat(be): implement image upload to S3
Jaehyeon1020 Feb 27, 2024
3113831
docs(be): add upload image api docs
Jaehyeon1020 Feb 27, 2024
3444125
chore(be): modify image bucket baseurl
Jaehyeon1020 Feb 27, 2024
2088b0d
docs(be): add assert
Jaehyeon1020 Feb 27, 2024
ec52997
chore(be): add alt property to return type of image-upload api
Jaehyeon1020 Feb 28, 2024
14aa19c
chore(infra): rename testcase to storage
k1g99 Feb 29, 2024
89ff0f7
feat(infra): add env for media bucket
k1g99 Feb 29, 2024
e611dd9
chore(be): add sample image and modify return object
Jaehyeon1020 Mar 1, 2024
5d2d4fc
Optimised images with calibre/image-actions
github-actions[bot] Mar 1, 2024
048191f
chore(be): add s3-media-provider to test code
Jaehyeon1020 Mar 1, 2024
b27dbde
chore(be): use relative path for sample image
Jaehyeon1020 Mar 2, 2024
e1aff45
chore(be): replace client with media-client
Jaehyeon1020 Mar 4, 2024
800e5b3
feat(be): add delete-image function
Jaehyeon1020 Mar 4, 2024
061d5ba
refactor: combine bucket setup scripts
k1g99 Mar 7, 2024
7ee87a9
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 Mar 20, 2024
76ee2b9
fix(be): modify modify image size calculating logic
Jaehyeon1020 Mar 20, 2024
8b2b397
chore(be): modify filename to use uuid only
Jaehyeon1020 Mar 20, 2024
6966894
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 Mar 30, 2024
6331762
chore(be): remove duplicated import lines
Jaehyeon1020 Mar 30, 2024
0d32955
Merge branch 'main' into 1495-implement-image-upload
cho-to Mar 31, 2024
af56564
chore(be): remove file extension
Jaehyeon1020 Apr 1, 2024
a1237e4
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 Apr 1, 2024
4a50f98
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 Apr 5, 2024
095c345
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 Apr 6, 2024
fd79060
feat(be): add delete-image and improve image upload, delete logic
Jaehyeon1020 Apr 6, 2024
e5233fe
docs(be): add delete-image api docs
Jaehyeon1020 Apr 6, 2024
06e1cbd
chore(be): parallelize deleting image logic
Jaehyeon1020 Apr 9, 2024
dbc5dfc
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 May 5, 2024
cedef4a
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 May 14, 2024
209c2f9
chore(be): parallelize image-delete api
Jaehyeon1020 May 14, 2024
5f22f91
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 May 27, 2024
f6e48e5
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 May 27, 2024
faa68b4
Merge branch 'main' into 1495-implement-image-upload
Jaehyeon1020 May 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"database",
"cache",
"rabbitmq",
"testcase",
"storage",
"test-database"
],
"workspaceFolder": "/workspace",
Expand Down
11 changes: 10 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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...
2 changes: 1 addition & 1 deletion .gitpod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ ports:
- port: 15672 # RabbitMQ Dashboard
onOpen: ignore

- port: 30000 # Testcase Server
- port: 30000 # Storage Server
onOpen: ignore

tasks:
Expand Down
10 changes: 10 additions & 0 deletions backend/apps/admin/src/problem/model/image.output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql'

@ObjectType({ description: 'image' })
export class Image {
@Field(() => String)
src: string

@Field(() => String)
alt: string
}
Jaehyeon1020 marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 2 additions & 1 deletion backend/apps/admin/src/problem/problem.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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 { ProblemResolver } from './problem.resolver'
import { ProblemService } from './problem.service'

@Module({
imports: [StorageModule],
imports: [StorageModule, ConfigModule],
providers: [ProblemResolver, ProblemTagResolver, TagResolver, ProblemService]
})
export class ProblemModule {}
14 changes: 14 additions & 0 deletions backend/apps/admin/src/problem/problem.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
UnprocessableDataException
} from '@libs/exception'
import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe'
import { Image } from './model/image.output'
import {
CreateProblemInput,
UploadFileInput,
Expand Down Expand Up @@ -105,6 +106,19 @@ export class ProblemResolver {
}
}

@Mutation(() => Image)
async uploadImage(@Args('input') input: UploadFileInput) {
try {
return await this.problemService.uploadImage(input)
} catch (error) {
if (error instanceof UnprocessableDataException) {
throw error.convert2HTTPException()
}
this.logger.error(error)
throw new InternalServerErrorException()
}
}

@Query(() => [Problem])
async getProblems(
@Args(
Expand Down
100 changes: 71 additions & 29 deletions backend/apps/admin/src/problem/problem.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Cache } from '@nestjs/cache-manager'
import { Inject, Injectable } 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 { randomUUID } from 'crypto'
import { Workbook } from 'exceljs'
import type { ReadStream } from 'fs'
import { MAX_IMAGE_SIZE } from '@libs/constants'
import {
DuplicateFoundException,
EntityNotExistException,
Expand Down Expand Up @@ -36,6 +40,7 @@ export class ProblemService {
constructor(
private readonly prisma: PrismaService,
private readonly storageService: StorageService,
private readonly config: ConfigService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache
) {}

Expand Down Expand Up @@ -128,25 +133,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'],
Expand All @@ -157,9 +157,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
Expand All @@ -169,17 +168,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: [
Expand All @@ -192,42 +187,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({
Expand All @@ -236,7 +222,6 @@ export class ProblemService {
scoreWeight: parseInt(scoreWeights[i]) || undefined
})
}

problems.push({
title,
description,
Expand All @@ -255,7 +240,6 @@ export class ProblemService {
samples: []
})
})

return await Promise.all(
problems.map(async (data) => {
const problem = await this.createProblem(data, userId, groupId)
Expand All @@ -264,6 +248,64 @@ export class ProblemService {
)
}

async uploadImage(input: UploadFileInput) {
const { filename, mimetype, createReadStream } = await input.file
const newFilename = randomUUID() + filename
Jaehyeon1020 marked this conversation as resolved.
Show resolved Hide resolved

if (!mimetype.includes('image/')) {
throw new UnprocessableDataException('Only image files can be accepted')
}

const fileSize = await this.getFileSize(createReadStream())
if (fileSize > MAX_IMAGE_SIZE) {
throw new UnprocessableDataException('Image size limitation exceeded')
}

try {
await this.storageService.uploadImage(
newFilename,
fileSize,
createReadStream(),
mimetype
)
} catch (error) {
throw new UnprocessableFileDataException(
'Error occurred during image upload.',
newFilename
)
}

return {
imageUrl:
this.config.get('STORAGE_BUCKET_ENDPOINT_URL') +
'/' +
this.config.get('MEDIA_BUCKET_NAME') +
'/' +
newFilename
}
}

async getFileSize(readStream: ReadStream): Promise<number> {
Jaehyeon1020 marked this conversation as resolved.
Show resolved Hide resolved
return new Promise((resolve) => {
const chunks: Buffer[] = []

readStream.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})

readStream.on('end', () => {
const fileSize = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
resolve(fileSize)
})

readStream.on('error', () => {
throw new UnprocessableDataException(
'Error occurred during calculating image size.'
)
})
})
}

async getProblems(
input: FilterProblemsInput,
groupId: number,
Expand Down
20 changes: 19 additions & 1 deletion backend/apps/admin/src/storage/s3.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,29 @@ 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') ?? '',
secretAccessKey: config.get('TESTCASE_SECRET_KEY') ?? ''
}
})
}

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') ?? ''
}
})
}
4 changes: 2 additions & 2 deletions backend/apps/admin/src/storage/storage.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
21 changes: 20 additions & 1 deletion backend/apps/admin/src/storage/storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,6 +28,23 @@ export class StorageService {
)
}

async uploadImage(
filename: string,
fileSize: number,
content: ReadStream,
type: string
) {
await this.client.send(
new PutObjectCommand({
Bucket: this.config.get('MEDIA_BUCKET_NAME'),
Key: filename,
Body: content,
ContentType: type,
ContentLength: fileSize
})
)
}

// TODO: uploadFile

async readObject(filename: string) {
Expand Down
Loading
Loading