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

Develop to main #54

Merged
merged 9 commits into from
Sep 28, 2024
3,945 changes: 3,053 additions & 892 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,28 @@
},
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.592.0",
"date-fns": "^3.6.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"lodash": "^4.17.21",
"multer": "^1.4.5-lts.1",
"pino": "^9.1.0",
"pino-pretty": "^11.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"ts-node": "^10.9.2",
"yamljs": "^0.3.0"
"yamljs": "^0.3.0",
"heic-convert": "^2.1.0",
"sharp": "^0.33.4"
},
"devDependencies": {
"@types/dotenv": "^8.2.0",
"@types/heic-convert": "^1.2.3",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/lodash": "^4.14.202",
"@types/multer": "^1.4.12",
"@types/node": "^20.12.2",
"@types/pino": "^7.0.5",
"@types/pino-pretty": "^5.0.0",
Expand Down
17 changes: 14 additions & 3 deletions src/controller/meme.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,20 @@ const getMemeWithKeywords = async (req: CustomRequest, res: Response, next: Next
}
};

const createMeme = async (req: Request, res: Response, next: NextFunction) => {
const createMeme = async (req: CustomRequest, res: Response, next: NextFunction) => {
const user = req.requestedUser;

const image = req.file;

if (_.isUndefined(image)) {
return next(new CustomError(`'file' should be provided.`, HttpCode.BAD_REQUEST));
}

if (!_.has(req.body, 'title')) {
return next(new CustomError(`'title' field should be provided`, HttpCode.BAD_REQUEST));
}

if (!_.has(req.body, 'image')) {
if (!req.file) {
return next(new CustomError(`'image' field should be provided`, HttpCode.BAD_REQUEST));
}

Expand All @@ -78,7 +86,10 @@ const createMeme = async (req: Request, res: Response, next: NextFunction) => {
}

const createPayload: IMemeCreatePayload = {
...req.body,
deviceId: user.deviceId,
title: req.body.title,
image: image.location,
source: req.body.source,
keywordIds: req.body.keywordIds.map((id: string) => new Types.ObjectId(id)),
};

Expand Down
1 change: 1 addition & 0 deletions src/middleware/requestedInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface CustomRequest extends Request {
requestedKeyword?: IKeywordDocument;
requestedKeywordCategory?: IKeywordCategoryDocument;
requestedMemeInteraction?: IMemeInteraction;
file?: any;
}

export const getRequestedMemeInfo = async (
Expand Down
5 changes: 5 additions & 0 deletions src/model/meme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import mongoose, { Schema, Document, Types } from 'mongoose';
import { IKeywordGetResponse } from './keyword';

export interface IMemeCreatePayload {
deviceId: string;
title: string;
keywordIds: Types.ObjectId[];
image: string;
Expand All @@ -17,6 +18,7 @@ export interface IMemeUpdatePayload {
}

export interface IMeme {
deviceId: string;
title: string;
keywordIds: Types.ObjectId[];
image: string;
Expand All @@ -27,6 +29,7 @@ export interface IMeme {

export interface IMemeGetResponse {
_id: Types.ObjectId;
deviceId: string;
title: string;
image: string;
reaction: number;
Expand All @@ -42,6 +45,7 @@ export interface IMemeGetResponse {

export interface IMemeDocument extends Document {
_id: Types.ObjectId;
deviceId: string;
title: string;
keywordIds: Types.ObjectId[];
image: string;
Expand All @@ -55,6 +59,7 @@ export interface IMemeDocument extends Document {

const MemeSchema: Schema = new Schema(
{
deviceId: { type: String, required: true },
title: { type: String, required: true },
keywordIds: { type: [Types.ObjectId], ref: 'Keyword', required: true, default: [] },
image: { type: String, required: true },
Expand Down
121 changes: 47 additions & 74 deletions src/routes/meme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ import {
getRequestedUserInfo,
getRequestedMemeSaveInfo,
} from '../middleware/requestedInfo';
import { compressAndUploadImageToS3, upload } from '../util/image';

const router = express.Router();

/**
* @swagger
* /api/meme/list:
Expand Down Expand Up @@ -611,134 +611,107 @@ router.get('/search/:name', getRequestedUserInfo, searchMemeList); // 밈 검색
* @swagger
* /api/meme:
* post:
* summary: "밈 등록"
* tags: [Meme]
* summary: 밈 생성 (백오피스)
* description: 밈을 생성한다. (백오피스)
* parameters:
* - in: header
* name: x-device-id
* required: true
* schema:
* type: string
* description: "유저의 고유한 deviceId"
* requestBody:
* required: true
* content:
* application/json:
* multipart/form-data:
* schema:
* type: object
* properties:
* title:
* type: string
* example: "무한도전 정총무"
* description: 밈 제목
* description: "밈 제목"
* image:
* type: string
* example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png"
* description: 밈 이미지 주소
* type: file
* description: "밈 이미지 파일"
* source:
* type: string
* example: "무한도전 102화"
* description: 밈 출처
* description: "밈 출처"
* keywordIds:
* type: array
* items:
* type: string
* example: "667fee6dc58681a42d57dc37"
* description: 밈의 키워드 id 목록
* description: "키워드의 ObjectId"
* example: ["667fa549239eeaf786f9aa75", "667fa3c824fc9c25eaf3b911"]
* description: "등록할 키워드의 ObjectId 배열"
* responses:
* 201:
* description: 생성된 밈 정보
* description: "Meme uploaded successfully"
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: success
* example: "success"
* code:
* type: integer
* example: 201
* message:
* type: string
* example: Create Meme
* example: "Create Meme"
* data:
* type: object
* properties:
* _id:
* deviceId:
* type: string
* example: "6686af56f7c49ec21e3ef1c1"
* description: 밈 id
* example: "deviceId"
* title:
* type: string
* example: "무한도전 정총무"
* description: 밈 제목
* image:
* type: string
* example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png"
* description: 밈 이미지 주소
* source:
* type: string
* example: "무한도전 102화"
* description: 밈 출처
* example: "폰보는 루피"
* keywordIds:
* type: array
* items:
* type: string
* example: "667fee6dc58681a42d57dc37"
* description: 밈의 키워드 id 목록
* example: "667ff3d1239eeaf78630a283"
* image:
* type: string
* example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/1727269791268"
* reaction:
* type: integer
* example: 0
* description: ㅋㅋㅋ 리액션 수 (생성 시 기본값 0)
* source:
* type: string
* example: "google"
* isTodayMeme:
* type: boolean
* example: false
* description: 추천 밈 여부
* isDeleted:
* type: boolean
* example: false
* _id:
* type: string
* example: "66f40b9f775ec854840d0519"
* createdAt:
* type: string
* format: date-time
* example: "2024-07-04T14:19:02.918Z"
* description: 생성 시각
* example: "2024-09-25T13:09:51.472Z"
* updatedAt:
* type: string
* format: date-time
* example: "2024-07-04T14:19:02.918Z"
* description: 업데이트 시각
* example: "2024-09-25T13:09:51.472Z"
* 400:
* description: 잘못된 요청 - requestBody 형식 확인 필요
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: error
* code:
* type: integer
* example: 400
* message:
* type: string
* example: title field should be provided
* data:
* type: null
* example: null
* description: "Bad request (missing fields)"
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: error
* code:
* type: integer
* example: 500
* message:
* type: string
* example: Internal server error
* data:
* type: null
* example: null
* description: "Internal server error"
*/
router.post('/', createMeme); // meme 생성
router.post(
'/',
getRequestedUserInfo,
upload.single('image'),
compressAndUploadImageToS3,
createMeme,
);

/**
* @swagger
Expand Down
2 changes: 1 addition & 1 deletion src/util/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const PORT = process.env.PORT;
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
const AWS_REGION = process.env.AWS_REGION;
const AWS_BUCKET_NAME = process.env.AWS_BUCKET_NAME;
const AWS_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;

// FIREBASE
const FCM_PROJECT_ID = process.env.FCM_PROJECT_ID;
Expand Down
83 changes: 83 additions & 0 deletions src/util/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import path from 'path';

import { PutObjectCommand } from '@aws-sdk/client-s3';
import { NextFunction, Response } from 'express';
import heicConvert from 'heic-convert';
import multer from 'multer';
import sharp from 'sharp';

import config from './config';
import { logger } from './logger';
import { s3 } from './s3';
import { CustomRequest } from '../middleware/requestedInfo';

const storage = multer.memoryStorage();
export const upload = multer({
storage: storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter(_req, file, cb) {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
logger.warn('Invalid file type: ' + file.mimetype);
cb(null, false);
}
},
});

export const compressAndUploadImageToS3 = async (
req: CustomRequest,
res: Response,
next: NextFunction,
) => {
if (!req.file) return next();

const { buffer, originalname, mimetype } = req.file;

let compressedBuffer: Buffer;
let newMimetype = mimetype;

// 70% 압축
try {
// iPhone (heic, heif -> jpeg)
if (mimetype === 'image/heic' || mimetype === 'image/heif') {
const outputBuffer = await heicConvert({
buffer,
format: 'JPEG',
quality: 1,
});
compressedBuffer = await sharp(outputBuffer).jpeg({ quality: 70 }).toBuffer();
newMimetype = 'image/jpeg';
} else if (mimetype === 'image/png') {
compressedBuffer = await sharp(buffer).png({ quality: 70 }).toBuffer();
} else if (mimetype === 'image/webp') {
compressedBuffer = await sharp(buffer).webp({ quality: 70 }).toBuffer();
} else if (mimetype === 'image/tiff' || mimetype === 'image/tif') {
compressedBuffer = await sharp(buffer).tiff({ quality: 70 }).toBuffer();
} else if (mimetype === 'image/gif') {
compressedBuffer = await sharp(buffer, { animated: true }).webp({ quality: 70 }).toBuffer();
} else {
// Default to jpeg if not png, gif, webp, tiff, heic or heif
compressedBuffer = await sharp(buffer).jpeg({ quality: 70 }).toBuffer();
}

const ext = path.extname(originalname);
const key = `${Date.now()}${newMimetype === 'image/jpeg' ? '.jpg' : ext}`;

// Upload the compressed image to S3
const command = new PutObjectCommand({
Bucket: config.AWS_BUCKET_NAME,
Key: key,
Body: compressedBuffer,
ACL: 'public-read',
ContentType: newMimetype,
});

await s3.send(command);
req.file.location = `https://${config.AWS_BUCKET_NAME}.s3.${config.AWS_REGION}.amazonaws.com/${key}`;
next();
} catch (err) {
logger.error(err);
res.status(500).json({ error: err.message });
}
};
Loading