From 2d31ae8892c8e7550c994f896ab4d13dc1f6c899 Mon Sep 17 00:00:00 2001 From: sejin Date: Sat, 17 May 2025 19:07:43 +0900 Subject: [PATCH 01/21] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/userController.js | 125 ++++++++++++++++++++++++++++++++++ middlewares/profileDelete.js | 54 +++++++++++++++ middlewares/profileUpload.js | 53 ++++++++++++++ models/User.js | 3 + routes/userRoutes.js | 9 +++ 5 files changed, 244 insertions(+) create mode 100644 middlewares/profileDelete.js create mode 100644 middlewares/profileUpload.js diff --git a/controllers/userController.js b/controllers/userController.js index 2a1ae39..cbc1f45 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -8,6 +8,8 @@ import { smtpTransport } from '../config/email.js'; import jwt from 'jsonwebtoken'; import { authenticate } from '../middlewares/authenticate.js'; import validators from '../middlewares/validators.js'; +import { deleteProfileImageByUrl } from '../middlewares/profileDelete.js'; +import { saveProfileImage } from '../middlewares/profileUpload.js'; const { validateName, validateEmail, validatePassword, validateNewPassword, validateConfirmPassword, validateNewConfirmPassword, validateMiddleware } = validators; @@ -378,3 +380,126 @@ export const resetPassword = [ } }, ]; + +// 마이페이지 +export const myPage = async (req, res) => { + try { + const userId = req.user.id; + const user = await User.findById(userId); + + if(!user) { + return res.status(400).send('사용자를 찾을 수 없습니다.'); + } + + let profileImage = user.profileImage; + if(!profileImage) { + profileImage = "default image" + } + + return res.status(200).json({ + name : user.name, + email : user.email, + profileImage + }) + } catch (err) { + console.log(err); + return res.status(500).json({ message: "마이페이지에서 오류가 발생했습니다." }); + } + +} + +// 프로필 이미지 변경 +export const resetProfileImage = async (req, res) => { + try { + const userId = req.user.id; + const file = req.file; + + const user = await User.findById(userId); + if (!user) { + return res.status(400).json({ message: "사용자를 찾을 수 없습니다." }); + } + + if (!file) { + return res.status(400).json({ message: "이미지 파일이 없습니다." }); + } + + if (!user.profileImage) { + console.log("기존 이미지가 존재하지 않습니다. 이미지 업로드만 진행.") + } else { + // 기존 이미지 s3에서 삭제 + const beforeImage = user.profileImage; + await deleteProfileImageByUrl(beforeImage); + } + + // 새로운 이미지 등록 + const profileImage = await saveProfileImage(req, file); + const result = await User.updateOne( + { _id: userId }, + { profileImage: profileImage.url } + ); + return res.status(200).json({ message: "프로필 이미지를 변경하였습니다." }); + } catch (err) { + console.error("프로필 이미지 업로드 실패:", err.message); + return res.status(500).json({ message: "프로필 이미지 업로드 중 오류가 발생했습니다." }); + } +} + +// 프로필 이미지 삭제 +export const deleteProfileImage = async (req, res) => { + try { + const userId = req.user.id; + + const user = await User.findById(userId); + if (!user) { + return res.status(400).json({ message: "사용자를 찾을 수 없습니다." }); + } + + if (!user.profileImage) { + console.log("기존 이미지가 존재하지 않습니다. ") + } else { + // 기존 이미지 s3에서 삭제 + const beforeImage = user.profileImage; + await deleteProfileImageByUrl(beforeImage); + await User.updateOne( + { _id: userId }, + { $unset: { profileImage: "" } } + ); + } + return res.status(200).json({ message: "프로필 이미지를 삭제하였습니다." }); + + } catch (err) { + return res.status(500).json({ message: "프로필 삭제 중 오류가 발생했습니다." }); + } +} + + +// 이름 변경 +export const resetUserName = async (req, res) => { +try { + const { newName } = req.body; + const userId = req.user.id; + const user = await User.findById(userId); + + if (!user) { + return res.status(400).send('사용자를 찾을 수 없습니다.'); + } + + if (!newName) { + return res.status(200).json({ message : "입력값이 존재하지 않습니다. 기존 이름 유지" }); + } else { + const regex = /^[ㄱ-ㅎ|가-힣|a-z|A-Z|]+$/; + if (!regex.test(newName) || newName.length>10) { // 이름 형식 검증 + return res.status(400).json({ message : "이름의 형식이 올바르지 않습니다."}); + } else { + const result = await User.updateOne( + { _id: userId }, + { name: newName } + ); + return res.status(200).json({ message: "사용자 이름을 변경하였습니다."}) + } + } + } catch (err) { + console.error("마이페이지 사용자 이름 변경 중 오류가 발생하였습니다.", err); + return res.status(500).json({ message: "마이페이지 사용자 이름 변경 중 오류가 발생하였습니다."}) + } +}; \ No newline at end of file diff --git a/middlewares/profileDelete.js b/middlewares/profileDelete.js new file mode 100644 index 0000000..924907e --- /dev/null +++ b/middlewares/profileDelete.js @@ -0,0 +1,54 @@ +import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import dotenv from "dotenv"; + +dotenv.config(); + +const s3 = new S3Client({ + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}); + +// S3 URL에서 key 추출 +export const extractKeyFromS3Url = (profileUrl) => { + try{ + if (!profileUrl) return null; + const url = new URL(profileUrl); + + // url에서 key 추출 + const key = decodeURIComponent(url.pathname.substring(1)); + return key + } catch (err) { + console.log("S3 프로필 이미지 key 추출 오류: ", err.message); + } +} + +export const deleteProfileImageByUrl = async (profileUrl) => { + try{ + const key = extractKeyFromS3Url(profileUrl); + if (key) { + await deleteKeyFromS3(key); + console.log(`프로필 이미지 삭제 완료: ${key}`); + } else { + console.warn(`유효하지 않은 프로필 URL: ${profileUrl}`); + } + } catch (err){ + console.log(`S3 프로필 삭제 실패 ${profileUrl}:`, err.message); + } +} + +export const deleteKeyFromS3 = async (key) => { + try { + const command = new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + }); + + await s3.send(command); + console.log(`S3 프로필 이미지 삭제 완료: ${key}`); + } catch (error) { + console.error(`S3 프로필 이미지 삭제 실패: ${error.message}`); + } + }; \ No newline at end of file diff --git a/middlewares/profileUpload.js b/middlewares/profileUpload.js new file mode 100644 index 0000000..fb9d181 --- /dev/null +++ b/middlewares/profileUpload.js @@ -0,0 +1,53 @@ +import multer from "multer"; +import * as dotenv from "dotenv"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { randomUUID } from "crypto"; + +dotenv.config(); + + // AWS S3 클라이언트 설정 + const s3 = new S3Client({ + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + +// Multer 설정 +const storage = multer.memoryStorage(); +export const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png/; + const isValid = allowedTypes.test(file.mimetype); + if (isValid) { + cb(null, true); // 업로드 허용 + } else { + cb(new Error("JPG, PNG 형식의 파일만 첨부 가능합니다."), false); + } + }, +}); + +export const saveProfileImage = async (req, file) => { + try { + const userId = req.user.id; + const fileName = `profileImage/${randomUUID()}-${userId}.png`; // UUID로 파일명 중복 방지 + const uploadParams = { + Bucket: process.env.S3_BUCKET_NAME, + Key: fileName, + Body: file.buffer, + ContentType: file.mimetype, + }; + + await s3.send(new PutObjectCommand(uploadParams)); + return { + url: `${process.env.S3_BASE_URL}${fileName}`, + fileName, + }; + } catch (error) { + console.error("profile image s3에 업로드 실패:", error.message); + throw new Error("프로필 이미지 업로드 중 오류가 발생했습니다."); + } +} \ No newline at end of file diff --git a/models/User.js b/models/User.js index d6508e1..58de54d 100644 --- a/models/User.js +++ b/models/User.js @@ -22,6 +22,9 @@ const userSchema = new mongoose.Schema({ }, deleteRequestDate: { type: Date, + }, + profileImage: { + type: String, } }, { versionKey: false } diff --git a/routes/userRoutes.js b/routes/userRoutes.js index cf32151..4b7bb18 100644 --- a/routes/userRoutes.js +++ b/routes/userRoutes.js @@ -1,6 +1,9 @@ import express from 'express'; import * as userController from '../controllers/userController.js'; +import { authMiddleware } from "../middlewares/authMiddleware.js"; +import { upload } from "../middlewares/profileUpload.js" + const router = express.Router(); // 이메일 인증번호 발송 라우터 @@ -22,4 +25,10 @@ router.post('/token', userController.refreshAccessToken); router.post('/password/email', userController.resetPasswordEmail); router.post('/password/reset', userController.resetPassword); +// 마이페이지 +router.get('/my-page', authMiddleware, userController.myPage); +router.patch('/profile-image', authMiddleware, upload.single("file"), userController.resetProfileImage); +router.delete('/profile-image', authMiddleware, userController.deleteProfileImage); +router.patch('/user-name', authMiddleware, userController.resetUserName); + export default router; \ No newline at end of file From 187e2099fe2519731bb7d524dd4762f6d72c251d Mon Sep 17 00:00:00 2001 From: ljm1102 <127467536+ljm1102@users.noreply.github.com> Date: Sun, 18 May 2025 02:37:57 +0900 Subject: [PATCH 02/21] =?UTF-8?q?Fix:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20deco?= =?UTF-8?q?deURIComponent=20=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middlewares/fileDownload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/middlewares/fileDownload.js b/middlewares/fileDownload.js index dae5676..62b1917 100644 --- a/middlewares/fileDownload.js +++ b/middlewares/fileDownload.js @@ -22,7 +22,7 @@ export const downloadFileFromS3 = async (req, res) => { } // URL에서 S3 키 추출 - 이미 인코딩된 URL 디코딩 - const fileKey = decodeURIComponent(new URL(fileUrl).pathname.substring(1)); + const fileKey = new URL(fileUrl).pathname.substring(1); console.log("S3에서 검색할 파일 키:", fileKey); // S3에서 파일 가져오기 From a3a5819df7e553bc1ef6ffbad48efc97bec811be Mon Sep 17 00:00:00 2001 From: SONG Date: Sun, 18 May 2025 20:42:29 +0900 Subject: [PATCH 03/21] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 2 + controllers/kakaoController.js | 21 ++++++++ package-lock.json | 89 ++++++++++++++++++++++++++++++++++ package.json | 3 ++ passport/kakaoStrategy.js | 36 ++++++++++++++ routes/userRoutes.js | 8 +++ 6 files changed, 159 insertions(+) create mode 100644 controllers/kakaoController.js create mode 100644 passport/kakaoStrategy.js diff --git a/app.js b/app.js index 3323043..224b31a 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ import express from "express"; import cors from "cors"; import connectDB from "./db.js"; import dotenv from "dotenv" +import passport from 'passport'; import userRoutes from "./routes/userRoutes.js"; import collectionRoutes from "./routes/collectionRoutes.js"; @@ -31,6 +32,7 @@ app.use( ); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" })); +app.use(passport.initialize()); // DB 연결 connectDB(); diff --git a/controllers/kakaoController.js b/controllers/kakaoController.js new file mode 100644 index 0000000..5306ff7 --- /dev/null +++ b/controllers/kakaoController.js @@ -0,0 +1,21 @@ +import passport from 'passport'; +import jwt from 'jsonwebtoken'; + +export const kakaoLogin = passport.authenticate('kakao', { + scope: ['account_email', 'profile_nickname'], +}); + +export const kakaoCallback = (req, res) => { + const user = req.user; + + const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { + expiresIn: '1h', + }); + + const redirectBase = + process.env.NODE_ENV === 'production' + ? 'https://www.refhub.my' + : 'http://localhost:5173'; + + res.redirect(`${redirectBase}/users/kakao-login?token=${token}`); +}; diff --git a/package-lock.json b/package-lock.json index 729a069..7a9d4f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,9 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^6.10.0", "open-graph-scraper": "^6.9.0", + "passport": "^0.7.0", + "passport-kakao": "^1.0.1", + "passport-local": "^1.0.0", "path": "^0.12.7", "pdf-thumbnail": "^1.0.6", "pdf-to-img": "^4.4.0", @@ -6768,6 +6771,12 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6962,6 +6971,66 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-kakao": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/passport-kakao/-/passport-kakao-1.0.1.tgz", + "integrity": "sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "~1.1.2", + "pkginfo": "~0.3.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.1.2.tgz", + "integrity": "sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==", + "dependencies": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path": { "version": "0.12.7", "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", @@ -7020,6 +7089,11 @@ "node": ">=6" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pdf-thumbnail": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/pdf-thumbnail/-/pdf-thumbnail-1.0.6.tgz", @@ -7153,6 +7227,15 @@ "node": ">=8" } }, + "node_modules/pkginfo": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", + "integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -8189,6 +8272,12 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 4f68ef3..e0a7767 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^6.10.0", "open-graph-scraper": "^6.9.0", + "passport": "^0.7.0", + "passport-kakao": "^1.0.1", + "passport-local": "^1.0.0", "path": "^0.12.7", "pdf-thumbnail": "^1.0.6", "pdf-to-img": "^4.4.0", diff --git a/passport/kakaoStrategy.js b/passport/kakaoStrategy.js new file mode 100644 index 0000000..884ee08 --- /dev/null +++ b/passport/kakaoStrategy.js @@ -0,0 +1,36 @@ +import passport from 'passport'; +import { Strategy as KakaoStrategy } from 'passport-kakao'; +import User from '../models/User.js'; + +passport.use( + new KakaoStrategy( + { + clientID: process.env.KAKAO_REST_API_KEY, + callbackURL: process.env.KAKAO_REDIRECT_URI, + }, + async (accessToken, refreshToken, profile, done) => { + try { + const kakaoEmail = profile._json.kakao_account.email; + const name = profile.displayName; + const profileImage = + profile._json.kakao_account.profile?.profile_image_url || null; + + let user = await User.findOne({ email: kakaoEmail }); + + if (!user) { + user = await User.create({ + name, + email: kakaoEmail, + password: '', + profileImage, + }); + } + + return done(null, user); + } catch (err) { + return done(err, null); + } + } + ) +); + diff --git a/routes/userRoutes.js b/routes/userRoutes.js index 4b7bb18..d70e913 100644 --- a/routes/userRoutes.js +++ b/routes/userRoutes.js @@ -25,6 +25,14 @@ router.post('/token', userController.refreshAccessToken); router.post('/password/email', userController.resetPasswordEmail); router.post('/password/reset', userController.resetPassword); +// 카카오 로그인 라우터 +router.get('/kakao', kakaoController.kakaoLogin); +router.get( + '/kakao/callback', + passport.authenticate('kakao', { failureRedirect: '/login', session: false }), + kakaoController.kakaoCallback +); + // 마이페이지 router.get('/my-page', authMiddleware, userController.myPage); router.patch('/profile-image', authMiddleware, upload.single("file"), userController.resetProfileImage); From 0f3c1240a879fed51d78095a9cb9cbd8a801bf28 Mon Sep 17 00:00:00 2001 From: SONG Date: Sun, 18 May 2025 20:44:27 +0900 Subject: [PATCH 04/21] =?UTF-8?q?Fix:=20userRoutes=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/userRoutes.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routes/userRoutes.js b/routes/userRoutes.js index d70e913..92d4da3 100644 --- a/routes/userRoutes.js +++ b/routes/userRoutes.js @@ -1,5 +1,8 @@ import express from 'express'; +import passport from 'passport'; + import * as userController from '../controllers/userController.js'; +import * as kakaoController from '../controllers/kakaoController.js'; import { authMiddleware } from "../middlewares/authMiddleware.js"; import { upload } from "../middlewares/profileUpload.js" From 20ca7cf71c0baa13b0a4d30fc8857a6908a06e0e Mon Sep 17 00:00:00 2001 From: SONG <145523888+soooong7@users.noreply.github.com> Date: Sun, 18 May 2025 20:54:20 +0900 Subject: [PATCH 05/21] =?UTF-8?q?Chore:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CICD.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 45b5955..96b8ea9 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -57,7 +57,9 @@ jobs: AWS_SECRET_ACCESS_KEY: '${{ secrets.AWS_SECRET_ACCESS_KEY }}', S3_BUCKET_NAME: '${{ secrets.S3_BUCKET_NAME }}', S3_BASE_URL: '${{ secrets.S3_BASE_URL }}', - EXTENSION_ID: '${{ vars.EXTENSION_ID }}' + EXTENSION_ID: '${{ vars.EXTENSION_ID }}', + KAKAO_REST_API_KEY: '${{ secrets.KAKAO_REST_API_KEY }}', + KAKAO_REDIRECT_URI: '${{ secrets.KAKAO_REDIRECT_URI }}' } }] };" > ecosystem.prod.config.cjs @@ -84,7 +86,9 @@ jobs: AWS_SECRET_ACCESS_KEY: '${{ secrets.AWS_SECRET_ACCESS_KEY }}', S3_BUCKET_NAME: '${{ secrets.S3_BUCKET_NAME }}', S3_BASE_URL: '${{ secrets.S3_BASE_URL }}', - EXTENSION_ID: '${{ vars.EXTENSION_ID }}' + EXTENSION_ID: '${{ vars.EXTENSION_ID }}', + KAKAO_REST_API_KEY: '${{ secrets.KAKAO_REST_API_KEY }}', + KAKAO_REDIRECT_URI: '${{ secrets.KAKAO_REDIRECT_URI }}' } }] };" > ecosystem.dev.config.cjs From bda24215ced1e63c9e3aac10baf8a0da30fbfd31 Mon Sep 17 00:00:00 2001 From: SONG <145523888+soooong7@users.noreply.github.com> Date: Sun, 18 May 2025 21:21:29 +0900 Subject: [PATCH 06/21] Update CICD.yml --- .github/workflows/CICD.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 96b8ea9..f1751fc 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -59,7 +59,7 @@ jobs: S3_BASE_URL: '${{ secrets.S3_BASE_URL }}', EXTENSION_ID: '${{ vars.EXTENSION_ID }}', KAKAO_REST_API_KEY: '${{ secrets.KAKAO_REST_API_KEY }}', - KAKAO_REDIRECT_URI: '${{ secrets.KAKAO_REDIRECT_URI }}' + KAKAO_REDIRECT_URI: '${{ secrets.KAKAO_REDIRECT_URI_PROD }}' } }] };" > ecosystem.prod.config.cjs @@ -88,7 +88,7 @@ jobs: S3_BASE_URL: '${{ secrets.S3_BASE_URL }}', EXTENSION_ID: '${{ vars.EXTENSION_ID }}', KAKAO_REST_API_KEY: '${{ secrets.KAKAO_REST_API_KEY }}', - KAKAO_REDIRECT_URI: '${{ secrets.KAKAO_REDIRECT_URI }}' + KAKAO_REDIRECT_URI: '${{ secrets.KAKAO_REDIRECT_URI_DEV }}' } }] };" > ecosystem.dev.config.cjs From 0d837029425f3e3aa7c45eb3436fc3f12bae75f8 Mon Sep 17 00:00:00 2001 From: SONG Date: Mon, 19 May 2025 21:37:20 +0900 Subject: [PATCH 07/21] =?UTF-8?q?Fix:=20userRoutes=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/userRoutes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/userRoutes.js b/routes/userRoutes.js index 92d4da3..8b242aa 100644 --- a/routes/userRoutes.js +++ b/routes/userRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; import passport from 'passport'; +import '../passport/kakaoStrategy.js'; import * as userController from '../controllers/userController.js'; import * as kakaoController from '../controllers/kakaoController.js'; From f5ab7f1bc68cc4d89b4ea38946cc47ab42369d61 Mon Sep 17 00:00:00 2001 From: SONG Date: Mon, 19 May 2025 22:01:23 +0900 Subject: [PATCH 08/21] =?UTF-8?q?Feat:=20User=20provider=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/User.js | 7 ++++++- passport/kakaoStrategy.js | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/models/User.js b/models/User.js index 58de54d..1843c34 100644 --- a/models/User.js +++ b/models/User.js @@ -25,7 +25,12 @@ const userSchema = new mongoose.Schema({ }, profileImage: { type: String, - } + }, + provider: { + type: String, + enum: ['local', 'kakao'], + default: 'local', + }, }, { versionKey: false } ); diff --git a/passport/kakaoStrategy.js b/passport/kakaoStrategy.js index 884ee08..e6f8906 100644 --- a/passport/kakaoStrategy.js +++ b/passport/kakaoStrategy.js @@ -23,6 +23,7 @@ passport.use( email: kakaoEmail, password: '', profileImage, + provider: 'kakao', }); } From 2f4e7eab1536b7e0f942347d2b78c745a7961994 Mon Sep 17 00:00:00 2001 From: SONG Date: Mon, 19 May 2025 22:05:45 +0900 Subject: [PATCH 09/21] =?UTF-8?q?Feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=99=95=EC=9D=B8=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- passport/kakaoStrategy.js | 23 +++++++++++++---------- routes/userRoutes.js | 11 ++++++++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/passport/kakaoStrategy.js b/passport/kakaoStrategy.js index e6f8906..96ad94f 100644 --- a/passport/kakaoStrategy.js +++ b/passport/kakaoStrategy.js @@ -10,28 +10,31 @@ passport.use( }, async (accessToken, refreshToken, profile, done) => { try { - const kakaoEmail = profile._json.kakao_account.email; - const name = profile.displayName; - const profileImage = - profile._json.kakao_account.profile?.profile_image_url || null; + const kakaoAccount = profile._json.kakao_account; + console.log('카카오 로그인 이메일:', kakaoAccount.email); - let user = await User.findOne({ email: kakaoEmail }); + if (!kakaoAccount.email) { + console.error('이메일 없음'); + return done(new Error('이메일 누락'), null); + } + + let user = await User.findOne({ email: kakaoAccount.email }); if (!user) { user = await User.create({ - name, - email: kakaoEmail, + name: profile.displayName, + email: kakaoAccount.email, password: '', - profileImage, + profileImage: kakaoAccount.profile?.profile_image_url, provider: 'kakao', }); } return done(null, user); } catch (err) { + console.error('카카오 전략 내부 에러:', err); return done(err, null); } } ) -); - +); \ No newline at end of file diff --git a/routes/userRoutes.js b/routes/userRoutes.js index 8b242aa..83bf1d7 100644 --- a/routes/userRoutes.js +++ b/routes/userRoutes.js @@ -33,7 +33,16 @@ router.post('/password/reset', userController.resetPassword); router.get('/kakao', kakaoController.kakaoLogin); router.get( '/kakao/callback', - passport.authenticate('kakao', { failureRedirect: '/login', session: false }), + (req, res, next) => { + passport.authenticate('kakao', { session: false }, (err, user, info) => { + if (err || !user) { + console.error('카카오 로그인 실패:', err || 'user 없음'); + return res.redirect('/login'); + } + req.user = user; + next(); + })(req, res, next); + }, kakaoController.kakaoCallback ); From 2902b04ee65681e9ae3bfa683272869a14fe06ec Mon Sep 17 00:00:00 2001 From: SONG Date: Mon, 19 May 2025 23:39:18 +0900 Subject: [PATCH 10/21] =?UTF-8?q?Feat:=20kakaoStrategy=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- passport/kakaoStrategy.js | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/passport/kakaoStrategy.js b/passport/kakaoStrategy.js index 96ad94f..4894162 100644 --- a/passport/kakaoStrategy.js +++ b/passport/kakaoStrategy.js @@ -11,30 +11,41 @@ passport.use( async (accessToken, refreshToken, profile, done) => { try { const kakaoAccount = profile._json.kakao_account; - console.log('카카오 로그인 이메일:', kakaoAccount.email); + const kakaoEmail = kakaoAccount?.email; - if (!kakaoAccount.email) { - console.error('이메일 없음'); - return done(new Error('이메일 누락'), null); + console.log('카카오 로그인 이메일:', kakaoEmail); + + if (!kakaoEmail) { + console.error('이메일 정보가 없습니다.'); + return done(new Error('카카오 계정에 이메일이 없습니다.'), null); } - let user = await User.findOne({ email: kakaoAccount.email }); + let user = await User.findOne({ email: kakaoEmail }); + + // if (user && user.provider !== 'kakao') { + // console.error('이미 이메일로 가입된 유저입니다.'); + // return done(new Error('이미 가입된 이메일입니다. 이메일 로그인을 이용하세요.'), null); + // } if (!user) { user = await User.create({ name: profile.displayName, - email: kakaoAccount.email, + email: kakaoEmail, password: '', - profileImage: kakaoAccount.profile?.profile_image_url, + profileImage: kakaoAccount.profile?.profile_image_url || '', provider: 'kakao', }); + + console.log('신규 유저 생성 완료:', user.email); + } else { + console.log('기존 유저 로그인:', user.email); } return done(null, user); } catch (err) { - console.error('카카오 전략 내부 에러:', err); + console.error('카카오 전략 내부 에러:', err.message); return done(err, null); } } ) -); \ No newline at end of file +); From 8187e918c99cdbf03f218271fe4c489a70623655 Mon Sep 17 00:00:00 2001 From: SONG Date: Tue, 20 May 2025 14:28:28 +0900 Subject: [PATCH 11/21] =?UTF-8?q?Feat:=20=EC=A4=91=EB=B3=B5=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/kakaoController.js | 62 ++++++++++++++++++++++++++++++++++ controllers/userController.js | 12 +++++++ passport/kakaoStrategy.js | 48 ++++++++++++++------------ routes/userRoutes.js | 16 ++------- 4 files changed, 102 insertions(+), 36 deletions(-) diff --git a/controllers/kakaoController.js b/controllers/kakaoController.js index 5306ff7..7e16ad8 100644 --- a/controllers/kakaoController.js +++ b/controllers/kakaoController.js @@ -1,10 +1,43 @@ import passport from 'passport'; import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; +// 카카오 로그인 export const kakaoLogin = passport.authenticate('kakao', { scope: ['account_email', 'profile_nickname'], }); +// 카카오 로그인 콜백 +export const kakaoCallbackHandler = (req, res, next) => { + passport.authenticate('kakao', { session: false }, (err, user, info) => { + const redirectBase = + process.env.NODE_ENV === 'production' + ? 'https://www.refhub.my' + : 'http://localhost:5173'; + + if (info?.message === 'link_required') { + const email = info.email; + const profileImage = info.profileData?.profile_image_url || ''; + const name = info.profileData?.name || ''; + + return res.redirect( + `${redirectBase}/users/kakao-login?link=true&email=${email}&name=${encodeURIComponent( + name + )}&profileImage=${encodeURIComponent(profileImage)}` + ); + } + + if (err || !user) { + console.error('카카오 로그인 실패:', err || 'user 없음'); + return res.redirect(`${redirectBase}/login`); + } + + req.user = user; + next(); + })(req, res, next); +}; + +// 카카오 로그인 완료 후 JWT 발급 export const kakaoCallback = (req, res) => { const user = req.user; @@ -19,3 +52,32 @@ export const kakaoCallback = (req, res) => { res.redirect(`${redirectBase}/users/kakao-login?token=${token}`); }; + +// 카카오 계정 연동 API +export const linkKakaoAccount = async (req, res) => { + const { email, name, profileImage } = req.body; + + try { + const user = await User.findOne({ email }); + + if (!user || user.provider !== 'local') { + return res.status(400).json({ error: '카카오 계정으로 연동할 수 없는 계정입니다.' }); + } + + user.provider = 'kakao'; + user.password = ''; + user.name = name || user.name; + user.profileImage = profileImage || user.profileImage; + + await user.save(); + + const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { + expiresIn: '1h', + }); + + res.status(200).json({ message: '연동이 완료되었습니다.', token }); + } catch (err) { + console.error('카카오 계정 연동 오류:', err); + res.status(500).json({ error: '카카오 계정 연동 중 오류가 발생했습니다.' }); + } +}; \ No newline at end of file diff --git a/controllers/userController.js b/controllers/userController.js index cbc1f45..cdc5bb5 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -59,6 +59,10 @@ export const authEmail = [ try { const existingUser = await User.findOne({ email }); + if (existingUser && existingUser.provider !== 'local') { + return res.status(400).send('카카오 로그인으로 가입된 이메일입니다.'); + } + if (existingUser && existingUser.password) { return res.status(400).send('이미 가입된 이메일입니다.'); } @@ -132,6 +136,10 @@ export const createUser = [ return res.status(400).send('사용자를 찾을 수 없습니다.'); } + if (user.provider !== 'local') { + return res.status(400).send('카카오 로그인으로 가입된 이메일입니다.'); + } + const hashedPassword = await bcrypt.hash(password, 10); user.password = hashedPassword; @@ -199,6 +207,10 @@ export const loginUser = [ return res.status(404).send('등록되지 않은 이메일입니다.'); } + if (user.provider !== 'local') { + return res.status(400).send('해당 계정은 카카오 로그인 전용 계정입니다.'); + } + // 탈퇴 요청 후 7일이 지난 경우 계정 삭제 처리 if (user.deleteRequestDate) { const timeElapsed = new Date() - user.deleteRequestDate; diff --git a/passport/kakaoStrategy.js b/passport/kakaoStrategy.js index 4894162..7b93265 100644 --- a/passport/kakaoStrategy.js +++ b/passport/kakaoStrategy.js @@ -16,36 +16,40 @@ passport.use( console.log('카카오 로그인 이메일:', kakaoEmail); if (!kakaoEmail) { - console.error('이메일 정보가 없습니다.'); return done(new Error('카카오 계정에 이메일이 없습니다.'), null); } - let user = await User.findOne({ email: kakaoEmail }); - - // if (user && user.provider !== 'kakao') { - // console.error('이미 이메일로 가입된 유저입니다.'); - // return done(new Error('이미 가입된 이메일입니다. 이메일 로그인을 이용하세요.'), null); - // } - - if (!user) { - user = await User.create({ - name: profile.displayName, - email: kakaoEmail, - password: '', - profileImage: kakaoAccount.profile?.profile_image_url || '', - provider: 'kakao', - }); - - console.log('신규 유저 생성 완료:', user.email); - } else { - console.log('기존 유저 로그인:', user.email); + const user = await User.findOne({ email: kakaoEmail }); + + if (user) { + // 로컬 가입자인 경우 프론트로 연동 필요 전달 + if (user.provider === 'local') { + return done(null, false, { + message: 'link_required', + email: kakaoEmail, + profileData: { + profile_image_url: kakaoAccount.profile?.profile_image_url, + name: profile.displayName, + }, + }); + } + return done(null, user); } - return done(null, user); + const newUser = await User.create({ + name: profile.displayName, + email: kakaoEmail, + password: '', + profileImage: kakaoAccount.profile?.profile_image_url || '', + provider: 'kakao', + }); + + console.log('신규 유저 생성 완료:', newUser.email); + return done(null, newUser); } catch (err) { console.error('카카오 전략 내부 에러:', err.message); return done(err, null); } } ) -); +); \ No newline at end of file diff --git a/routes/userRoutes.js b/routes/userRoutes.js index 83bf1d7..be233d3 100644 --- a/routes/userRoutes.js +++ b/routes/userRoutes.js @@ -31,20 +31,8 @@ router.post('/password/reset', userController.resetPassword); // 카카오 로그인 라우터 router.get('/kakao', kakaoController.kakaoLogin); -router.get( - '/kakao/callback', - (req, res, next) => { - passport.authenticate('kakao', { session: false }, (err, user, info) => { - if (err || !user) { - console.error('카카오 로그인 실패:', err || 'user 없음'); - return res.redirect('/login'); - } - req.user = user; - next(); - })(req, res, next); - }, - kakaoController.kakaoCallback -); +router.get('/kakao/callback', kakaoController.kakaoCallbackHandler, kakaoController.kakaoCallback); +router.post('/kakao/link', kakaoController.linkKakaoAccount); // 마이페이지 router.get('/my-page', authMiddleware, userController.myPage); From 2b87bc57123921ab49ad0ce04e336adb294c571b Mon Sep 17 00:00:00 2001 From: SONG Date: Tue, 20 May 2025 14:34:44 +0900 Subject: [PATCH 12/21] =?UTF-8?q?Feat:=20User=20provider=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migrateProvider.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 scripts/migrateProvider.js diff --git a/scripts/migrateProvider.js b/scripts/migrateProvider.js new file mode 100644 index 0000000..980c079 --- /dev/null +++ b/scripts/migrateProvider.js @@ -0,0 +1,29 @@ +// User provider 필드 업데이트 스크립트 + +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; +import User from '../models/User.js'; + +dotenv.config(); + +const mongoURI = process.env.MONGO_URI_DEV; + +const run = async () => { + try { + await mongoose.connect(mongoURI); + console.log('MongoDB가 연결되었습니다.'); + + const result = await User.updateMany( + { provider: { $exists: false } }, + { $set: { provider: 'local' } } + ); + + console.log(`provider 업데이트 완료: ${result.modifiedCount}개 문서`); + process.exit(0); + } catch (error) { + console.error('오류 발생:', error); + process.exit(1); + } +}; + +run(); \ No newline at end of file From 5151b43eaf7faa8943f1374bd36a05146ea936d4 Mon Sep 17 00:00:00 2001 From: SONG Date: Tue, 20 May 2025 20:13:10 +0900 Subject: [PATCH 13/21] =?UTF-8?q?Feat:=20=ED=83=88=ED=87=B4=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EB=B3=B5=EA=B5=AC=20=EC=97=AC=EB=B6=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/userController.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/controllers/userController.js b/controllers/userController.js index cdc5bb5..4af4e97 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -211,6 +211,8 @@ export const loginUser = [ return res.status(400).send('해당 계정은 카카오 로그인 전용 계정입니다.'); } + let recovered = false; + // 탈퇴 요청 후 7일이 지난 경우 계정 삭제 처리 if (user.deleteRequestDate) { const timeElapsed = new Date() - user.deleteRequestDate; @@ -222,10 +224,12 @@ export const loginUser = [ // 7일 이내 로그인 시 계정 복구 user.deleteRequestDate = undefined; + recovered = true; await user.save(); } req.user = user; + req.recovered = recovered; next(); } catch (error) { console.error('로그인 중 오류가 발생했습니다.:', error); @@ -264,7 +268,7 @@ export const loginUser = [ await user.save(); } - res.status(200).json({ message: '로그인이 완료되었습니다.', accessToken, refreshToken, autoLogin }); + res.status(200).json({ message: '로그인이 완료되었습니다.', accessToken, refreshToken, autoLogin, recovered: req.recovered || false }); } catch (error) { console.error('로그인 중 오류가 발생했습니다.:', error); res.status(500).send('로그인 중 오류가 발생했습니다.'); From 46049e28ceb7cd94aaa943c27d5cfe4b899268c9 Mon Sep 17 00:00:00 2001 From: SONG Date: Tue, 20 May 2025 21:14:07 +0900 Subject: [PATCH 14/21] =?UTF-8?q?Feat:=20=EA=B3=84=EC=A0=95=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EA=B7=B8=20=EC=8B=A4=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/deleteOldUser.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/deleteOldUser.js b/utils/deleteOldUser.js index b0a5d9a..50cf083 100644 --- a/utils/deleteOldUser.js +++ b/utils/deleteOldUser.js @@ -6,7 +6,7 @@ import connectDB from "../db.js"; dotenv.config(); const deleteOldUsers = async () => { - console.log("🛠️ 7일이 지난 탈퇴 요청 계정을 삭제하는 작업을 시작합니다."); + console.log(`🛠️ [${new Date().toISOString()}] 7일이 지난 탈퇴 요청 계정을 삭제하는 작업을 시작합니다.`); await connectDB(); From b4e936d436bd718f72b258cb661206da4d97a947 Mon Sep 17 00:00:00 2001 From: Hanmh111 <96728777+Hanmh111@users.noreply.github.com> Date: Sun, 25 May 2025 13:43:35 +0900 Subject: [PATCH 15/21] Feat: Add profileImage in Collection Sharing --- controllers/sharingController.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/controllers/sharingController.js b/controllers/sharingController.js index f52fa29..3804bbf 100644 --- a/controllers/sharingController.js +++ b/controllers/sharingController.js @@ -108,10 +108,15 @@ const getSharedUsers = async (req, res, next) => { _id: owner._id, name: owner.name, email: owner.email, + profileImage: owner.profileImage || "default image", }; - const sharing = await CollectionShare.find({ collectionId }) - .populate("userId", "name email") - .lean(); + const sharing = (await CollectionShare.find({collectionId}).populate("userId", "name email profileImage").lean()).map((s) => ({ + ...s, + userId: { + ...s.userId, + profileImage: s.userId?.profileImage || "default image", + } + })); return res.status(StatusCodes.OK).json({ owner: modefiedOwner, From adec3fb78e5c4ac052f9b52f19ca4adb0b0d304c Mon Sep 17 00:00:00 2001 From: Hanmh111 <96728777+Hanmh111@users.noreply.github.com> Date: Tue, 27 May 2025 16:49:53 +0900 Subject: [PATCH 16/21] =?UTF-8?q?Feat:=20updatedAt=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EB=8F=99=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/collectionController.js | 272 ++++++++++++++++------------ models/Collection.js | 8 +- routes/collectionRoutes.js | 6 + 3 files changed, 166 insertions(+), 120 deletions(-) diff --git a/controllers/collectionController.js b/controllers/collectionController.js index 51346bb..4f239d6 100644 --- a/controllers/collectionController.js +++ b/controllers/collectionController.js @@ -5,16 +5,15 @@ import Reference from "../models/Reference.js"; import Keyword from "../models/Keyword.js"; import Extension from "../models/Extension.js"; -import { StatusCodes } from "http-status-codes"; -import { deleteFileByUrl } from "../middlewares/fileDelete.js"; -import { deletePreviewByUrl } from "../middlewares/previewDelete.js"; -import { MongoError } from "mongodb"; -import mongoose from "mongoose"; +import {StatusCodes} from "http-status-codes"; +import {deleteFileByUrl} from "../middlewares/fileDelete.js"; +import {deletePreviewByUrl} from "../middlewares/previewDelete.js"; +import {MongoError} from "mongodb"; import ogs from "open-graph-scraper"; async function getOGImage(url) { try { - const { result } = await ogs({ url }); + const {result} = await ogs({url}); return result.ogImage[0]?.url || null; } catch (err) { console.log(`OG Image fetch error (${url}:)`, err.message); @@ -25,7 +24,7 @@ async function getOGImage(url) { // 컬렉션 생성 const createCollection = async (req, res, next) => { try { - const { title } = req.body; + const {title} = req.body; const userId = req.user.id; const [sharedTitles, collectionExists] = await Promise.all([ @@ -38,7 +37,7 @@ const createCollection = async (req, res, next) => { }) .lean() .distinct("title"), - Collection.exists({ title, createdBy: userId }), + Collection.exists({title, createdBy: userId}), ]); if (sharedTitles.includes(title) || collectionExists) { @@ -61,21 +60,19 @@ const createCollection = async (req, res, next) => { // 컬렉션 목록 조회 const getCollection = async (req, res, next) => { try { - const { page = 1, sortBy = "latest", search = "" } = req.query; + const {page = 1, sortBy = "latest", search = ""} = req.query; const pageSize = 15; const userId = req.user.id; // 검색 조건 설정 - const searchCondition = search - ? { title: { $regex: search, $options: "i" } } - : {}; + const searchCondition = search ? {title: {$regex: search, $options: "i"}} : {}; // 정렬 조건 const sortOptions = { - latest: { createdAt: -1 }, - oldest: { createdAt: 1 }, - sortAsc: { title: 1 }, - sortDesc: { title: -1 }, + latest: {updatedAt: -1}, + oldest: {updatedAt: 1}, + sortAsc: {title: 1}, + sortDesc: {title: -1}, }; const sort = sortOptions[sortBy] || sortOptions.latest; @@ -83,12 +80,10 @@ const getCollection = async (req, res, next) => { const collections = await Collection.find({ ...searchCondition, $or: [ - { createdBy: userId }, // 사용자가 만든 컬렉션 + {createdBy: userId}, // 사용자가 만든 컬렉션 { _id: { - $in: await CollectionShare.find({ userId }).distinct( - "collectionId" - ), + $in: await CollectionShare.find({userId}).distinct("collectionId"), }, }, // 사용자가 공유받은 컬렉션 ], @@ -108,86 +103,109 @@ const getCollection = async (req, res, next) => { const totalItemCount = collections.length; const totalPages = Math.ceil(totalItemCount / pageSize); const currentPage = Math.min(Math.max(1, parseInt(page, 10)), totalPages); - const paginatedCollections = collections.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize - ); + const paginatedCollections = collections.slice((currentPage - 1) * pageSize, currentPage * pageSize); // 즐겨찾기, 레퍼런스, 공유 정보 조회 const collectionIds = paginatedCollections.map((item) => item._id); - const [collectionFavorites, references, collectionShared] = - await Promise.all([ - CollectionFavorite.find({ - userId: userId, - collectionId: { $in: collectionIds }, - }).lean(), - Reference.find({ collectionId: { $in: collectionIds } }).lean(), - CollectionShare.find({ collectionId: { $in: collectionIds } }).lean(), - ]); + const [collectionFavorites, allReferences, collectionShared] = await Promise.all([ + CollectionFavorite.find({ + userId: userId, + collectionId: {$in: collectionIds}, + }) + .sort({updatedAt: -1}) // 최신순 정렬 + .lean(), + Reference.find({collectionId: {$in: collectionIds}}).lean(), + CollectionShare.find({collectionId: {$in: collectionIds}}).lean(), + ]); + + const referenceMap = {}; + for (const ref of allReferences) { + const key = ref.collectionId.toString(); + if (!referenceMap[key]) referenceMap[key] = []; + if (referenceMap[key].length < 4) referenceMap[key].push(ref); // 최대 4개만 저장 + } // 반환 데이터 재구성 const modifiedData = await Promise.all( paginatedCollections.map(async (item) => { // 즐겨찾기 여부 - const isFavorite = collectionFavorites.some( - (fav) => fav.collectionId.equals(item._id) && fav.isFavorite - ); - const refList = references.filter((ref) => - ref.collectionId.equals(item._id) - ); + const isFavorite = collectionFavorites.some((fav) => fav.collectionId.equals(item._id) && fav.isFavorite); - // 프리뷰 이미지 - const relevantReferences = refList.slice(-4).reverse(); + const refList = referenceMap[item._id.toString()] || []; - const URLs = []; - for (const ref of relevantReferences) { - if (Array.isArray(ref.files)) { - for (const file of ref.files) { - switch (file.type) { - case "image": - URLs.push( - ...file.previewURLs.map((url) => ({ type: file.type, url })) - ); - break; - case "link": - case "pdf": - default: - URLs.push({ type: file.type, url: file.previewURL }); - break; - } - if (URLs.length >= 4) break; - } - } - if (URLs.length >= 4) break; - } + // 프리뷰 이미지: 각 레퍼런스의 첫 번째 파일만 사용 + const previewImages = await Promise.all( + refList.map(async (ref) => { + const file = Array.isArray(ref.files) ? ref.files[0] : null; + if (!file) return null; - let previewImages = await Promise.all( - URLs.map(async (file) => { try { switch (file.type) { - case "link": - return getOGImage(file.url); + case "link": { + const og = await getOGImage(file.previewURL); + return og || null; + } case "image": + return file.previewURLs?.[0] || null; case "pdf": - return file.url; + case "file": default: - return null; + return file.previewURL || null; } - } catch (err) { - console.error(`OGS error for ${file.url}`, err); + } catch { return null; } }) ); + // const refList = references.filter((ref) => ref.collectionId.equals(item._id)); + + // // 프리뷰 이미지 + // const relevantReferences = refList.slice(-4).reverse(); + + // const URLs = []; + // for (const ref of relevantReferences) { + // if (Array.isArray(ref.files)) { + // for (const file of ref.files) { + // switch (file.type) { + // case "image": + // URLs.push(...file.previewURLs.map((url) => ({type: file.type, url}))); + // break; + // case "link": + // case "pdf": + // default: + // URLs.push({type: file.type, url: file.previewURL}); + // break; + // } + // if (URLs.length >= 4) break; + // } + // } + // if (URLs.length >= 4) break; + // } + + // let previewImages = await Promise.all( + // URLs.map(async (file) => { + // try { + // switch (file.type) { + // case "link": + // return getOGImage(file.url); + // case "image": + // case "pdf": + // return file.url; + // default: + // return null; + // } + // } catch (err) { + // return null; + // } + // }) + // ); + const sharedEntry = collectionShared.find( - (share) => - share.collectionId.equals(item._id) && share.userId.equals(userId) + (share) => share.collectionId.equals(item._id) && share.userId.equals(userId) ); const role = sharedEntry ? sharedEntry.role : null; - const isShared = collectionShared.some((share) => - share.collectionId.equals(item._id) - ); + const isShared = collectionShared.some((share) => share.collectionId.equals(item._id)); const isCreator = item.createdBy.equals(userId); const isViewer = role === "viewer"; const isEditor = role === "editor"; @@ -202,6 +220,7 @@ const getCollection = async (req, res, next) => { editor: isEditor, createdBy: item.createdBy, createdAt: item.createdAt, + updatedAt: item.updatedAt, refCount: refList.length, previewImages: previewImages, }; @@ -223,8 +242,8 @@ const getCollection = async (req, res, next) => { // 컬렉션 수정 const updateCollection = async (req, res, next) => { - const { collectionId } = req.params; - const { title } = req.body; + const {collectionId} = req.params; + const {title} = req.body; const userId = req.user.id; try { @@ -264,7 +283,7 @@ const updateCollection = async (req, res, next) => { }) .lean() .distinct("title"), - Collection.exists({ title, createdBy: userId }), + Collection.exists({title, createdBy: userId}), ]); if (sharedTitles.includes(title) || collectionExists) { @@ -276,9 +295,9 @@ const updateCollection = async (req, res, next) => { // 컬렉션 찾고 업데이트 const [collectionUpdate] = await Promise.all([ Collection.findOneAndUpdate( - { _id: collectionId, createdBy: owner }, - { $set: { title: title } }, - { new: true, runValidators: true } + {_id: collectionId, createdBy: owner}, + {$set: {title: title}}, + {new: true, runValidators: true} ), Extension.findOneAndDelete({ userId: userId, @@ -286,11 +305,11 @@ const updateCollection = async (req, res, next) => { }), ]); - if (!collectionUpdate) { - return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - error: "컬렉션 수정 중 오류가 발생했습니다.", - }); - } + if (!collectionUpdate) { + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "컬렉션 수정 중 오류가 발생했습니다.", + }); + } return res.status(StatusCodes.OK).json(collectionUpdate); } catch (err) { @@ -305,14 +324,14 @@ const updateCollection = async (req, res, next) => { // 컬렉션 이동모드 const moveCollection = async (req, res, next) => { - const { collectionIds } = req.body; - const { newCollection } = req.body; + const {collectionIds} = req.body; + const {newCollection} = req.body; const user = req.user.id; try { // 생성자인지 확인 const collections = await Collection.find({ - _id: { $in: collectionIds }, + _id: {$in: collectionIds}, createdBy: user, }).lean(); @@ -324,7 +343,7 @@ const moveCollection = async (req, res, next) => { // 헤딩 컬렉션 레퍼런스 찾기 const referenceIds = await Reference.find({ - collectionId: { $in: collectionIds }, + collectionId: {$in: collectionIds}, }); if (referenceIds.length === 0) { @@ -334,10 +353,7 @@ const moveCollection = async (req, res, next) => { } // 레퍼런스 컬렉션 아이디 업데이트 - await Reference.updateMany( - { _id: { $in: referenceIds.map((ref) => ref._id) } }, - { $set: { collectionId: newCollection } } - ); + await Reference.updateMany({_id: {$in: referenceIds.map((ref) => ref._id)}}, {$set: {collectionId: newCollection}}); return res.status(StatusCodes.OK).json({ message: "이동이 완료되었습니다.", @@ -350,13 +366,13 @@ const moveCollection = async (req, res, next) => { // 컬렉션 삭제 (삭제모드 포함) const deleteCollection = async (req, res, next) => { - const { collectionIds } = req.body; + const {collectionIds} = req.body; const user = req.user.id; try { // 생성자인지 확인 const collections = await Collection.find({ - _id: { $in: collectionIds }, + _id: {$in: collectionIds}, createdBy: user, }).lean(); @@ -370,7 +386,7 @@ const deleteCollection = async (req, res, next) => { const collectionIdsToDelete = collections.map((item) => item._id); const references = await Reference.find({ - collectionId: { $in: collectionIdsToDelete }, + collectionId: {$in: collectionIdsToDelete}, }).lean(); // 레퍼런스 관련 삭제 @@ -378,7 +394,7 @@ const deleteCollection = async (req, res, next) => { // 키워드 사용 여부 확인 후 삭제 for (const keywordId of reference.keywords) { const keywordUsed = await Reference.findOne({ - _id: { $ne: reference._id }, + _id: {$ne: reference._id}, keywords: keywordId, }); if (!keywordUsed) { @@ -399,9 +415,7 @@ const deleteCollection = async (req, res, next) => { if (file.type !== "link" && file.path) { if (typeof file.path === "string") { // 이미지 리스트 처리: 쉼표(,)가 포함된 경우 개별 URL로 분리하여 삭제 - const filePaths = file.path.includes(",") - ? file.path.split(",").map((path) => path.trim()) - : [file.path]; + const filePaths = file.path.includes(",") ? file.path.split(",").map((path) => path.trim()) : [file.path]; for (const filePath of filePaths) { await deleteFileByUrl(filePath); @@ -415,25 +429,23 @@ const deleteCollection = async (req, res, next) => { await Promise.all([ Reference.deleteMany({ - collectionId: { $in: collectionIdsToDelete }, + collectionId: {$in: collectionIdsToDelete}, }), CollectionShare.deleteMany({ - collectionId: { $in: collectionIdsToDelete }, + collectionId: {$in: collectionIdsToDelete}, }), CollectionFavorite.deleteMany({ - collectionId: { $in: collectionIdsToDelete }, + collectionId: {$in: collectionIdsToDelete}, }), Extension.deleteMany({ - collectionId: { $in: collectionIdsToDelete }, + collectionId: {$in: collectionIdsToDelete}, }), Collection.deleteMany({ - _id: { $in: collectionIdsToDelete }, + _id: {$in: collectionIdsToDelete}, }), ]); - return res - .status(StatusCodes.OK) - .json({ message: "삭제가 완료되었습니다." }); + return res.status(StatusCodes.OK).json({message: "삭제가 완료되었습니다."}); } catch (err) { next(err); } @@ -441,16 +453,14 @@ const deleteCollection = async (req, res, next) => { // 컬렉션 즐겨찾기 const toggleFavorite = async (req, res, next) => { - const { collectionId } = req.params; + const {collectionId} = req.params; const user = req.user.id; try { // 존재 여부 확인 const collection = await Collection.findById(collectionId).lean(); if (!collection) { - return res - .status(StatusCodes.NOT_FOUND) - .json({ error: "존재하지 않습니다." }); + return res.status(StatusCodes.NOT_FOUND).json({error: "존재하지 않습니다."}); } // 즐겨찾기에 정보 있는지 확인 @@ -476,9 +486,7 @@ const toggleFavorite = async (req, res, next) => { favorite.isFavorite = !favorite.isFavorite; await favorite.save(); return res.status(StatusCodes.OK).json({ - message: favorite.isFavorite - ? "컬렉션 즐겨찾기 등록 성공" - : "컬렉션 즐겨찾기 해제 성공", + message: favorite.isFavorite ? "컬렉션 즐겨찾기 등록 성공" : "컬렉션 즐겨찾기 해제 성공", data: favorite.isFavorite, }); } @@ -487,6 +495,37 @@ const toggleFavorite = async (req, res, next) => { } }; +const updateCollectionTime = async (req, res, next) => { + try { + // updatedAt 없는 레퍼런스 업데이트 + const refDocs = await Reference.find({updatedAt: {$exists: false}}); + for (const doc of refDocs) { + await Reference.updateOne({_id: doc.get("_id")}, {$set: {updatedAt: doc.get("createdAt")}}, {timestamps: false}); + } + + //컬렉션 updatedAt 전체 업데이트 + const docs = await Collection.find({}); + for (const doc of docs) { + await Collection.updateOne({_id: doc.get("_id")}, {$set: {updatedAt: doc.get("createdAt")}}, {timestamps: false}); + } + for (const doc of docs) { + const latestRef = await Reference.findOne({collectionId: doc._id}) + .sort({updatedAt: -1}) // 가장 최신 updatedAt + .select("updatedAt"); + if (latestRef) { + await Collection.updateOne( + {_id: doc._id}, + {$set: {updatedAt: latestRef.updatedAt}}, + {timestamps: false} // 자동 updatedAt 덮어쓰기 방지 + ); + } + } + console.log("✅ 컬렉션 updatedAt 동기화 완료"); + } catch (err) { + next(err); + } +}; + export default { createCollection, getCollection, @@ -494,4 +533,5 @@ export default { deleteCollection, moveCollection, toggleFavorite, + updateCollectionTime, }; diff --git a/models/Collection.js b/models/Collection.js index a58a189..b9005f9 100644 --- a/models/Collection.js +++ b/models/Collection.js @@ -11,13 +11,13 @@ const collSchema = new mongoose.Schema( type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, - }, - createdAt: { type: Date, default: () => Date.now() }, + } }, { versionKey: false, - toJSON: { virtuals: true }, - toObject: { virtuals: true }, + toJSON: {virtuals: true}, + toObject: {virtuals: true}, + timestamps: true, } ); diff --git a/routes/collectionRoutes.js b/routes/collectionRoutes.js index 785eaa1..009a8dc 100644 --- a/routes/collectionRoutes.js +++ b/routes/collectionRoutes.js @@ -14,6 +14,12 @@ const { validateMiddleware, } = validators; +router.get( + "/groimtiuhdbvvsrnjuuyffqdbzunuqevnsdlwdglvjoobskviwdddhxbkltitplwdnliczrramqujnyvxvyeziypsuewpqtdzyeqrwuyxtsvkqlupsbebsbxwnpgimjy", + authMiddleware, + Collection.updateCollectionTime +); + router.post( "/", authMiddleware, From 8911c6105ff88b884ca8adfd135fb03b476a4b18 Mon Sep 17 00:00:00 2001 From: Hanmh111 <96728777+Hanmh111@users.noreply.github.com> Date: Tue, 27 May 2025 16:50:40 +0900 Subject: [PATCH 17/21] =?UTF-8?q?Feat:=20=EB=A0=88=ED=8D=BC=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=8B=9C=20=EC=BB=AC=EB=A0=89=EC=85=98=20updatedAt=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/referenceController.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/controllers/referenceController.js b/controllers/referenceController.js index 6bc6bbb..ec87b37 100644 --- a/controllers/referenceController.js +++ b/controllers/referenceController.js @@ -266,6 +266,10 @@ export const addReference = async (req, res) => { }); await reference.save(); + await Collection.updateOne( + { _id: reference.collectionId }, + { $set: { updatedAt: reference.updatedAt } } + ); // 키워드 이름 조회 const populatedKeywords = await Keyword.find({ @@ -471,6 +475,10 @@ export const updateReference = async (req, res) => { reference.files = [...filesToKeep, ...newFiles]; await reference.save(); + await Collection.updateOne( + { _id: reference.collectionId }, + { $set: { updatedAt: reference.updatedAt } } + ); const populatedKeywords = await Keyword.find({ _id: { $in: reference.keywords } }).lean(); const keywordNames = populatedKeywords.map((k) => k.keywordName); From 97be39097f503d55d54c77e7bc9800bb3b2ea2d8 Mon Sep 17 00:00:00 2001 From: Hanmh111 <96728777+Hanmh111@users.noreply.github.com> Date: Tue, 27 May 2025 16:51:31 +0900 Subject: [PATCH 18/21] =?UTF-8?q?Feat:=20=EB=A0=88=ED=8D=BC=EB=9F=B0?= =?UTF-8?q?=EC=8A=A4=20=EC=8A=A4=ED=82=A4=EB=A7=88=EC=97=90=20updatedAt=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=EC=88=9C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/Reference.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/models/Reference.js b/models/Reference.js index 7359b99..f8d356d 100644 --- a/models/Reference.js +++ b/models/Reference.js @@ -28,6 +28,8 @@ const referenceSchema = new mongoose.Schema({ { versionKey: "__v", optimisticConcurrency: true, timestamps: true, } ); +referenceSchema.index({updatedAt: -1}); + function keywordsValidation(keywords) { return keywords.every((kw) => kw.length <= 15); } From 8d7553b5a63ecd791a91e5cf6ee50399c1b798c8 Mon Sep 17 00:00:00 2001 From: Hyunwoo Date: Fri, 30 May 2025 18:03:39 +0900 Subject: [PATCH 19/21] =?UTF-8?q?Feat:=20=EB=A7=88=EC=9D=B4=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20provider=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/userController.js | 339 ++++++++++++++++++++-------------- 1 file changed, 203 insertions(+), 136 deletions(-) diff --git a/controllers/userController.js b/controllers/userController.js index 4af4e97..9d344de 100644 --- a/controllers/userController.js +++ b/controllers/userController.js @@ -1,33 +1,49 @@ -import User from '../models/User.js'; -import Collection from '../models/Collection.js'; -import CollectionShare from '../models/CollectionShare.js'; -import ejs from 'ejs'; -import path from 'path'; -import bcrypt from 'bcrypt'; -import { smtpTransport } from '../config/email.js'; -import jwt from 'jsonwebtoken'; -import { authenticate } from '../middlewares/authenticate.js'; -import validators from '../middlewares/validators.js'; -import { deleteProfileImageByUrl } from '../middlewares/profileDelete.js'; -import { saveProfileImage } from '../middlewares/profileUpload.js'; - -const { validateName, validateEmail, validatePassword, validateNewPassword, validateConfirmPassword, validateNewConfirmPassword, validateMiddleware } = validators; +import User from "../models/User.js"; +import Collection from "../models/Collection.js"; +import CollectionShare from "../models/CollectionShare.js"; +import ejs from "ejs"; +import path from "path"; +import bcrypt from "bcrypt"; +import { smtpTransport } from "../config/email.js"; +import jwt from "jsonwebtoken"; +import { authenticate } from "../middlewares/authenticate.js"; +import validators from "../middlewares/validators.js"; +import { deleteProfileImageByUrl } from "../middlewares/profileDelete.js"; +import { saveProfileImage } from "../middlewares/profileUpload.js"; + +const { + validateName, + validateEmail, + validatePassword, + validateNewPassword, + validateConfirmPassword, + validateNewConfirmPassword, + validateMiddleware, +} = validators; const appDir = path.resolve(); // [회원가입] // 이메일 인증번호 발송 함수 -const sendVerificationEmail = async (name, email, verificationCode, subject) => { +const sendVerificationEmail = async ( + name, + email, + verificationCode, + subject +) => { let templateFile; - - if (subject === '📁RefHub📁 회원가입 인증 번호') { - templateFile = 'authEmail.ejs'; - } else if (subject === '📁RefHub📁 비밀번호 재설정 인증 번호') { - templateFile = 'authPassword.ejs'; - }; - const emailTemplatePath = path.join(appDir, 'templates', templateFile); - const emailTemplate = await ejs.renderFile(emailTemplatePath, { authCode: verificationCode, name }); + if (subject === "📁RefHub📁 회원가입 인증 번호") { + templateFile = "authEmail.ejs"; + } else if (subject === "📁RefHub📁 비밀번호 재설정 인증 번호") { + templateFile = "authPassword.ejs"; + } + + const emailTemplatePath = path.join(appDir, "templates", templateFile); + const emailTemplate = await ejs.renderFile(emailTemplatePath, { + authCode: verificationCode, + name, + }); const mailOptions = { from: process.env.EMAIL_USER, @@ -36,11 +52,11 @@ const sendVerificationEmail = async (name, email, verificationCode, subject) => html: emailTemplate, attachments: [ { - filename: 'logo.png', - path: path.join(appDir, 'templates', 'logo.png'), - cid: 'logo' - } - ] + filename: "logo.png", + path: path.join(appDir, "templates", "logo.png"), + cid: "logo", + }, + ], }; return smtpTransport.sendMail(mailOptions); @@ -59,12 +75,12 @@ export const authEmail = [ try { const existingUser = await User.findOne({ email }); - if (existingUser && existingUser.provider !== 'local') { - return res.status(400).send('카카오 로그인으로 가입된 이메일입니다.'); + if (existingUser && existingUser.provider !== "local") { + return res.status(400).send("카카오 로그인으로 가입된 이메일입니다."); } if (existingUser && existingUser.password) { - return res.status(400).send('이미 가입된 이메일입니다.'); + return res.status(400).send("이미 가입된 이메일입니다."); } if (existingUser) { @@ -72,15 +88,25 @@ export const authEmail = [ existingUser.verificationExpires = verificationExpires; await existingUser.save(); } else { - await User.create({ name, email, verificationCode, verificationExpires }); + await User.create({ + name, + email, + verificationCode, + verificationExpires, + }); } - await sendVerificationEmail(name, email, verificationCode, '📁RefHub📁 회원가입 인증 번호'); + await sendVerificationEmail( + name, + email, + verificationCode, + "📁RefHub📁 회원가입 인증 번호" + ); - res.status(200).send('인증번호 메일이 전송되었습니다.'); + res.status(200).send("인증번호 메일이 전송되었습니다."); } catch (error) { - console.error('인증번호 메일 전송 중 오류가 발생했습니다.:', error); - res.status(500).send('인증번호 메일 전송 중 오류가 발생했습니다.'); + console.error("인증번호 메일 전송 중 오류가 발생했습니다.:", error); + res.status(500).send("인증번호 메일 전송 중 오류가 발생했습니다."); } }, ]; @@ -90,30 +116,30 @@ export const verifyCode = async (req, res) => { const { email, verificationCode } = req.body; if (!email || !verificationCode) { - return res.status(400).send('이메일과 인증번호를 입력해주세요.'); + return res.status(400).send("이메일과 인증번호를 입력해주세요."); } try { const user = await User.findOne({ email }); if (!user) { - return res.status(400).send('사용자를 찾을 수 없습니다.'); + return res.status(400).send("사용자를 찾을 수 없습니다."); } if (user.verificationExpires < Date.now()) { - return res.status(400).send('인증번호가 만료되었습니다.'); + return res.status(400).send("인증번호가 만료되었습니다."); } if (user.verificationCode !== parseInt(verificationCode, 10)) { - return res.status(400).send('인증번호가 일치하지 않습니다.'); + return res.status(400).send("인증번호가 일치하지 않습니다."); } req.body.verifiedEmail = email; - res.status(200).send('인증번호가 확인되었습니다.'); + res.status(200).send("인증번호가 확인되었습니다."); } catch (error) { - console.error('인증번호 검증 중 오류가 발생했습니다.:', error); - res.status(500).send('인증번호 검증 중 오류가 발생했습니다.'); + console.error("인증번호 검증 중 오류가 발생했습니다.:", error); + res.status(500).send("인증번호 검증 중 오류가 발생했습니다."); } }; @@ -123,21 +149,21 @@ export const createUser = [ validateConfirmPassword, validateMiddleware, async (req, res) => { - const { verifiedEmail, password, confirmPassword, } = req.body; + const { verifiedEmail, password, confirmPassword } = req.body; if (!verifiedEmail || !password || !confirmPassword) { - return res.status(400).send('모든 정보를 입력해주세요.'); + return res.status(400).send("모든 정보를 입력해주세요."); } try { const user = await User.findOne({ email: verifiedEmail }); if (!user) { - return res.status(400).send('사용자를 찾을 수 없습니다.'); + return res.status(400).send("사용자를 찾을 수 없습니다."); } - if (user.provider !== 'local') { - return res.status(400).send('카카오 로그인으로 가입된 이메일입니다.'); + if (user.provider !== "local") { + return res.status(400).send("카카오 로그인으로 가입된 이메일입니다."); } const hashedPassword = await bcrypt.hash(password, 10); @@ -148,10 +174,10 @@ export const createUser = [ await user.save(); - res.status(200).send('회원가입이 완료되었습니다.'); + res.status(200).send("회원가입이 완료되었습니다."); } catch (error) { - console.error('회원가입 중 오류가 발생했습니다.:', error); - res.status(500).send('회원가입 중 오류가 발생했습니다.'); + console.error("회원가입 중 오류가 발생했습니다.:", error); + res.status(500).send("회원가입 중 오류가 발생했습니다."); } }, ]; @@ -162,28 +188,36 @@ export const deleteUser = async (req, res) => { const { user } = req; if (!user) { - return res.status(400).send('사용자 정보를 찾을 수 없습니다.'); + return res.status(400).send("사용자 정보를 찾을 수 없습니다."); } try { // 공유 중인 컬렉션이 있는지 확인 - const ownedCollections = await Collection.find({ createdBy: user._id }).distinct("_id"); + const ownedCollections = await Collection.find({ + createdBy: user._id, + }).distinct("_id"); const sharedOwnedCollections = await CollectionShare.find({ collectionId: { $in: ownedCollections }, }); if (sharedOwnedCollections.length > 0) { - return res.status(400).send('공유 중인 컬렉션이 있어 탈퇴할 수 없습니다.'); + return res + .status(400) + .send("공유 중인 컬렉션이 있어 탈퇴할 수 없습니다."); } user.deleteRequestDate = new Date(); await user.save(); - res.status(200).send('탈퇴가 완료되었습니다. 7일 이내에 로그인할 경우, 계정이 복구됩니다.'); + res + .status(200) + .send( + "탈퇴가 완료되었습니다. 7일 이내에 로그인할 경우, 계정이 복구됩니다." + ); } catch (error) { - console.error('회원탈퇴 중 오류가 발생했습니다.', error); - res.status(500).send('회원탈퇴 중 오류가 발생했습니다.'); + console.error("회원탈퇴 중 오류가 발생했습니다.", error); + res.status(500).send("회원탈퇴 중 오류가 발생했습니다."); } }); }; @@ -197,18 +231,20 @@ export const loginUser = [ const { email, password } = req.body; if (!email || !password) { - return res.status(400).send('이메일과 비밀번호를 모두 입력해주세요.'); + return res.status(400).send("이메일과 비밀번호를 모두 입력해주세요."); } try { const user = await User.findOne({ email }); if (!user) { - return res.status(404).send('등록되지 않은 이메일입니다.'); + return res.status(404).send("등록되지 않은 이메일입니다."); } - if (user.provider !== 'local') { - return res.status(400).send('해당 계정은 카카오 로그인 전용 계정입니다.'); + if (user.provider !== "local") { + return res + .status(400) + .send("해당 계정은 카카오 로그인 전용 계정입니다."); } let recovered = false; @@ -216,10 +252,13 @@ export const loginUser = [ // 탈퇴 요청 후 7일이 지난 경우 계정 삭제 처리 if (user.deleteRequestDate) { const timeElapsed = new Date() - user.deleteRequestDate; - - if (timeElapsed >= 7 * 24 * 60 * 60 * 1000) { // 7일 + + if (timeElapsed >= 7 * 24 * 60 * 60 * 1000) { + // 7일 await User.deleteOne({ _id: user._id }); - return res.status(400).send('계정이 삭제되었습니다. 다시 가입해주세요.'); + return res + .status(400) + .send("계정이 삭제되었습니다. 다시 가입해주세요."); } // 7일 이내 로그인 시 계정 복구 @@ -232,8 +271,8 @@ export const loginUser = [ req.recovered = recovered; next(); } catch (error) { - console.error('로그인 중 오류가 발생했습니다.:', error); - res.status(500).send('로그인 중 오류가 발생했습니다.'); + console.error("로그인 중 오류가 발생했습니다.:", error); + res.status(500).send("로그인 중 오류가 발생했습니다."); } }, validatePassword, @@ -246,13 +285,13 @@ export const loginUser = [ const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { - return res.status(400).send('비밀번호가 올바르지 않습니다.'); + return res.status(400).send("비밀번호가 올바르지 않습니다."); } const accessToken = jwt.sign( { id: user._id, email: user.email }, process.env.JWT_SECRET, - { expiresIn: '1h' } + { expiresIn: "1h" } ); let refreshToken = null; @@ -261,17 +300,25 @@ export const loginUser = [ refreshToken = jwt.sign( { id: user._id, email: user.email }, process.env.JWT_REFRESH_SECRET, - { expiresIn: '7d' } + { expiresIn: "7d" } ); user.token = refreshToken; await user.save(); } - res.status(200).json({ message: '로그인이 완료되었습니다.', accessToken, refreshToken, autoLogin, recovered: req.recovered || false }); + res + .status(200) + .json({ + message: "로그인이 완료되었습니다.", + accessToken, + refreshToken, + autoLogin, + recovered: req.recovered || false, + }); } catch (error) { - console.error('로그인 중 오류가 발생했습니다.:', error); - res.status(500).send('로그인 중 오류가 발생했습니다.'); + console.error("로그인 중 오류가 발생했습니다.:", error); + res.status(500).send("로그인 중 오류가 발생했습니다."); } }, ]; @@ -283,7 +330,9 @@ export const logoutUser = async (req, res) => { const { user } = req; if (!user) { - return res.status(400).json({ error: "사용자 정보를 찾을 수 없습니다." }); + return res + .status(400) + .json({ error: "사용자 정보를 찾을 수 없습니다." }); } user.token = ""; @@ -303,7 +352,7 @@ export const refreshAccessToken = async (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { - return res.status(400).send('Refresh Token이 없습니다.'); + return res.status(400).send("Refresh Token이 없습니다."); } try { @@ -314,12 +363,12 @@ export const refreshAccessToken = async (req, res) => { const newAccessToken = jwt.sign( { id: user._id, email: user.email }, process.env.JWT_SECRET, - { expiresIn: '1h' } + { expiresIn: "1h" } ); res.status(200).json({ accessToken: newAccessToken }); } catch (error) { - res.status(403).send('유효하지 않은 Refresh Token입니다.'); + res.status(403).send("유효하지 않은 Refresh Token입니다."); } }; @@ -332,7 +381,7 @@ export const resetPasswordEmail = [ const { email } = req.body; if (!email) { - return res.status(400).send('이메일을 입력해주세요.'); + return res.status(400).send("이메일을 입력해주세요."); } const verificationCode = Math.floor(100000 + Math.random() * 900000); @@ -342,21 +391,30 @@ export const resetPasswordEmail = [ const user = await User.findOne({ email }); if (!user) { - return res.status(404).send('등록되지 않은 이메일입니다.'); + return res.status(404).send("등록되지 않은 이메일입니다."); } user.verificationCode = verificationCode; user.verificationExpires = verificationExpires; await user.save(); - await sendVerificationEmail(user.name, email, verificationCode, '📁RefHub📁 비밀번호 재설정 인증 번호'); + await sendVerificationEmail( + user.name, + email, + verificationCode, + "📁RefHub📁 비밀번호 재설정 인증 번호" + ); - res.status(200).send('비밀번호 재설정을 위한 인증번호가 발송되었습니다.'); + res.status(200).send("비밀번호 재설정을 위한 인증번호가 발송되었습니다."); } catch (error) { - console.error('인증번호 메일 전송 중 오류가 발생했습니다.:', error); - res.status(500).send('비밀번호 재설정을 위한 인증번호 메일 전송 중 오류가 발생했습니다.'); + console.error("인증번호 메일 전송 중 오류가 발생했습니다.:", error); + res + .status(500) + .send( + "비밀번호 재설정을 위한 인증번호 메일 전송 중 오류가 발생했습니다." + ); } - } + }, ]; // 비밀번호 재설정 함수 @@ -371,15 +429,15 @@ export const resetPassword = [ const user = await User.findOne({ email }); if (!user) { - return res.status(400).send('사용자를 찾을 수 없습니다.'); + return res.status(400).send("사용자를 찾을 수 없습니다."); } if (user.verificationExpires < Date.now()) { - return res.status(400).send('인증번호가 만료되었습니다.'); + return res.status(400).send("인증번호가 만료되었습니다."); } if (user.verificationCode !== parseInt(verificationCode, 10)) { - return res.status(400).send('인증번호가 일치하지 않습니다.'); + return res.status(400).send("인증번호가 일치하지 않습니다."); } const hashedPassword = await bcrypt.hash(newPassword, 10); @@ -389,40 +447,42 @@ export const resetPassword = [ await user.save(); - res.status(200).send('비밀번호가 성공적으로 변경되었습니다.'); + res.status(200).send("비밀번호가 성공적으로 변경되었습니다."); } catch (error) { - console.error('비밀번호 변경 중 오류가 발생했습니다.:', error); - res.status(500).send('비밀번호 변경 중 오류가 발생했습니다.'); + console.error("비밀번호 변경 중 오류가 발생했습니다.:", error); + res.status(500).send("비밀번호 변경 중 오류가 발생했습니다."); } }, ]; -// 마이페이지 +// 마이페이지 export const myPage = async (req, res) => { try { const userId = req.user.id; - const user = await User.findById(userId); + const user = await User.findById(userId); - if(!user) { - return res.status(400).send('사용자를 찾을 수 없습니다.'); - } + if (!user) { + return res.status(400).send("사용자를 찾을 수 없습니다."); + } - let profileImage = user.profileImage; - if(!profileImage) { - profileImage = "default image" - } + let profileImage = user.profileImage; + if (!profileImage) { + profileImage = "default image"; + } - return res.status(200).json({ - name : user.name, - email : user.email, - profileImage - }) + return res.status(200).json({ + name: user.name, + email: user.email, + profileImage, + provider: user.provider || "local", // 추가: 로그인 타입 정보 + }); } catch (err) { console.log(err); - return res.status(500).json({ message: "마이페이지에서 오류가 발생했습니다." }); + return res + .status(500) + .json({ message: "마이페이지에서 오류가 발생했습니다." }); } - -} +}; // 프로필 이미지 변경 export const resetProfileImage = async (req, res) => { @@ -440,9 +500,9 @@ export const resetProfileImage = async (req, res) => { } if (!user.profileImage) { - console.log("기존 이미지가 존재하지 않습니다. 이미지 업로드만 진행.") + console.log("기존 이미지가 존재하지 않습니다. 이미지 업로드만 진행."); } else { - // 기존 이미지 s3에서 삭제 + // 기존 이미지 s3에서 삭제 const beforeImage = user.profileImage; await deleteProfileImageByUrl(beforeImage); } @@ -450,15 +510,17 @@ export const resetProfileImage = async (req, res) => { // 새로운 이미지 등록 const profileImage = await saveProfileImage(req, file); const result = await User.updateOne( - { _id: userId }, - { profileImage: profileImage.url } + { _id: userId }, + { profileImage: profileImage.url } ); return res.status(200).json({ message: "프로필 이미지를 변경하였습니다." }); } catch (err) { console.error("프로필 이미지 업로드 실패:", err.message); - return res.status(500).json({ message: "프로필 이미지 업로드 중 오류가 발생했습니다." }); + return res + .status(500) + .json({ message: "프로필 이미지 업로드 중 오류가 발생했습니다." }); } -} +}; // 프로필 이미지 삭제 export const deleteProfileImage = async (req, res) => { @@ -471,51 +533,56 @@ export const deleteProfileImage = async (req, res) => { } if (!user.profileImage) { - console.log("기존 이미지가 존재하지 않습니다. ") + console.log("기존 이미지가 존재하지 않습니다. "); } else { - // 기존 이미지 s3에서 삭제 + // 기존 이미지 s3에서 삭제 const beforeImage = user.profileImage; await deleteProfileImageByUrl(beforeImage); - await User.updateOne( - { _id: userId }, - { $unset: { profileImage: "" } } - ); + await User.updateOne({ _id: userId }, { $unset: { profileImage: "" } }); } return res.status(200).json({ message: "프로필 이미지를 삭제하였습니다." }); - } catch (err) { - return res.status(500).json({ message: "프로필 삭제 중 오류가 발생했습니다." }); + return res + .status(500) + .json({ message: "프로필 삭제 중 오류가 발생했습니다." }); } -} - +}; -// 이름 변경 +// 이름 변경 export const resetUserName = async (req, res) => { -try { + try { const { newName } = req.body; const userId = req.user.id; const user = await User.findById(userId); if (!user) { - return res.status(400).send('사용자를 찾을 수 없습니다.'); - } + return res.status(400).send("사용자를 찾을 수 없습니다."); + } if (!newName) { - return res.status(200).json({ message : "입력값이 존재하지 않습니다. 기존 이름 유지" }); + return res + .status(200) + .json({ message: "입력값이 존재하지 않습니다. 기존 이름 유지" }); } else { const regex = /^[ㄱ-ㅎ|가-힣|a-z|A-Z|]+$/; - if (!regex.test(newName) || newName.length>10) { // 이름 형식 검증 - return res.status(400).json({ message : "이름의 형식이 올바르지 않습니다."}); + if (!regex.test(newName) || newName.length > 10) { + // 이름 형식 검증 + return res + .status(400) + .json({ message: "이름의 형식이 올바르지 않습니다." }); } else { - const result = await User.updateOne( - { _id: userId }, - { name: newName } - ); - return res.status(200).json({ message: "사용자 이름을 변경하였습니다."}) + const result = await User.updateOne({ _id: userId }, { name: newName }); + return res + .status(200) + .json({ message: "사용자 이름을 변경하였습니다." }); } } } catch (err) { console.error("마이페이지 사용자 이름 변경 중 오류가 발생하였습니다.", err); - return res.status(500).json({ message: "마이페이지 사용자 이름 변경 중 오류가 발생하였습니다."}) + return res + .status(500) + .json({ + message: "마이페이지 사용자 이름 변경 중 오류가 발생하였습니다.", + }); } -}; \ No newline at end of file +}; From 1fe4548bbab38e307e82d8de70cbd9d14377c88b Mon Sep 17 00:00:00 2001 From: Hyunwoo Date: Sat, 31 May 2025 00:25:46 +0900 Subject: [PATCH 20/21] =?UTF-8?q?Feat:=20referenceController=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/referenceController.js | 122 ++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 35 deletions(-) diff --git a/controllers/referenceController.js b/controllers/referenceController.js index ec87b37..30fb8bc 100644 --- a/controllers/referenceController.js +++ b/controllers/referenceController.js @@ -148,7 +148,9 @@ export const addReference = async (req, res) => { const key = `images${i}`; if (req.files[key]) { if (totalAttachments >= 5) { - return res.status(400).json({ error: "첨부 자료는 최대 5개까지 가능합니다." }); + return res + .status(400) + .json({ error: "첨부 자료는 최대 5개까지 가능합니다." }); } const imagePaths = []; @@ -167,11 +169,11 @@ export const addReference = async (req, res) => { // 정규화된 파일명으로 업로드 const uploadedImage = await uploadFileToS3({ ...image, - originalname: encodedName + originalname: encodedName, }); const uploadedImagePreview = await savePreviewImage({ ...image, - originalname: encodedName + originalname: encodedName, }); imagePaths.push(uploadedImage.url); @@ -182,7 +184,9 @@ export const addReference = async (req, res) => { files.push({ type: "image", path: imagePaths.join(", "), - size: formatFileSize(images.reduce((total, img) => total + img.size, 0)), + size: formatFileSize( + images.reduce((total, img) => total + img.size, 0) + ), images: imagePaths, previewURLs: previewURLs, filenames: filenames, @@ -196,11 +200,15 @@ export const addReference = async (req, res) => { if (req.files.files) { for (const file of req.files.files) { if (totalAttachments >= 5) { - return res.status(400).json({ error: "첨부 자료는 최대 5개까지 가능합니다." }); + return res + .status(400) + .json({ error: "첨부 자료는 최대 5개까지 가능합니다." }); } if (file.originalname.split(".").pop().toLowerCase() !== "pdf") { - return res.status(400).json({ error: "PDF 파일만 업로드 가능합니다." }); + return res + .status(400) + .json({ error: "PDF 파일만 업로드 가능합니다." }); } // 파일명 정규화 및 인코딩 @@ -209,11 +217,11 @@ export const addReference = async (req, res) => { // 정규화된 파일명으로 업로드 const uploadedFile = await uploadFileToS3({ ...file, - originalname: encodedName + originalname: encodedName, }); const uploadedFilePreview = await convertPdfToImage({ ...file, - originalname: encodedName + originalname: encodedName, }); files.push({ @@ -232,7 +240,9 @@ export const addReference = async (req, res) => { if (req.files.otherFiles) { for (const file of req.files.otherFiles) { if (totalAttachments >= 5) { - return res.status(400).json({ error: "첨부 자료는 최대 5개까지 가능합니다." }); + return res + .status(400) + .json({ error: "첨부 자료는 최대 5개까지 가능합니다." }); } // 파일명 정규화 및 인코딩 @@ -241,7 +251,7 @@ export const addReference = async (req, res) => { // 정규화된 파일명으로 업로드 const uploadedFile = await uploadFileToS3({ ...file, - originalname: encodedName + originalname: encodedName, }); files.push({ @@ -309,28 +319,39 @@ export const updateReference = async (req, res) => { const reference = await Reference.findById(referenceId); if (!reference) { - return res.status(404).json({ error: "해당 레퍼런스를 찾을 수 없습니다." }); + return res + .status(404) + .json({ error: "해당 레퍼런스를 찾을 수 없습니다." }); } const hasAccess = await hasEditorAccess(userId, reference.collectionId); if (!hasAccess) { - return res.status(403).json({ error: "레퍼런스를 수정할 권한이 없습니다." }); + return res + .status(403) + .json({ error: "레퍼런스를 수정할 권한이 없습니다." }); } // 기존 파일 유지 목록 let keepFilePaths = []; try { - keepFilePaths = existingFiles ? JSON.parse(existingFiles) : reference.files.map(f => f.path); + keepFilePaths = existingFiles + ? JSON.parse(existingFiles) + : reference.files.map((f) => f.path); } catch { - return res.status(400).json({ error: "기존 파일 정보 형식이 잘못되었습니다." }); + return res + .status(400) + .json({ error: "기존 파일 정보 형식이 잘못되었습니다." }); } // 유지할 파일 필터링 const filesToKeep = reference.files.filter((file) => { - if (typeof file.path !== 'string') return false; + if (typeof file.path !== "string") return false; return keepFilePaths.some((keepPath) => { - if (file.type === 'image' && file.path.includes(',')) { - return file.path.split(',').map(p => p.trim()).includes(keepPath); + if (file.type === "image" && file.path.includes(",")) { + return file.path + .split(",") + .map((p) => p.trim()) + .includes(keepPath); } return file.path === keepPath; }); @@ -338,7 +359,8 @@ export const updateReference = async (req, res) => { // 삭제 대상 파일 const filesToDelete = reference.files.filter( - (file) => !filesToKeep.find(f => f._id.toString() === file._id.toString()) + (file) => + !filesToKeep.find((f) => f._id.toString() === file._id.toString()) ); for (const file of filesToDelete) { @@ -350,7 +372,9 @@ export const updateReference = async (req, res) => { } } if (file.type !== "link" && file.path) { - const paths = file.path.includes(",") ? file.path.split(",").map(p => p.trim()) : [file.path]; + const paths = file.path.includes(",") + ? file.path.split(",").map((p) => p.trim()) + : [file.path]; for (const path of paths) { await deleteFileByUrl(path); } @@ -360,7 +384,9 @@ export const updateReference = async (req, res) => { // 기존 키워드 유지 let updatedKeywordIds = []; try { - const keepKeywordIds = existingKeywords ? JSON.parse(existingKeywords) : reference.keywords; + const keepKeywordIds = existingKeywords + ? JSON.parse(existingKeywords) + : reference.keywords; updatedKeywordIds = reference.keywords.filter((k) => keepKeywordIds.includes(k.toString()) ); @@ -370,12 +396,16 @@ export const updateReference = async (req, res) => { // 새 키워드 처리 if (keywords) { - const keywordArray = Array.isArray(keywords) ? keywords : keywords.split(" "); + const keywordArray = Array.isArray(keywords) + ? keywords + : keywords.split(" "); for (const word of keywordArray) { if (word.length > 15) continue; let kw = await Keyword.findOne({ keywordName: word }); if (!kw) kw = await Keyword.create({ keywordName: word }); - if (!updatedKeywordIds.some((id) => id.toString() === kw._id.toString())) { + if ( + !updatedKeywordIds.some((id) => id.toString() === kw._id.toString()) + ) { updatedKeywordIds.push(kw._id); } } @@ -407,11 +437,19 @@ export const updateReference = async (req, res) => { const imagePaths = []; const previewURLs = []; const filenames = []; - const images = Array.isArray(req.files[key]) ? req.files[key].slice(0, 5) : [req.files[key]]; + const images = Array.isArray(req.files[key]) + ? req.files[key].slice(0, 5) + : [req.files[key]]; for (const image of images) { const encodedName = normalizeAndEncodeFileName(image.originalname); - const uploaded = await uploadFileToS3({ ...image, originalname: encodedName }); - const preview = await savePreviewImage({ ...image, originalname: encodedName }); + const uploaded = await uploadFileToS3({ + ...image, + originalname: encodedName, + }); + const preview = await savePreviewImage({ + ...image, + originalname: encodedName, + }); imagePaths.push(uploaded.url); previewURLs.push(preview.url); filenames.push(encodedName); @@ -430,12 +468,20 @@ export const updateReference = async (req, res) => { // PDF 처리 if (req.files?.files) { - const pdfs = Array.isArray(req.files.files) ? req.files.files : [req.files.files]; + const pdfs = Array.isArray(req.files.files) + ? req.files.files + : [req.files.files]; for (const file of pdfs) { if (totalAttachments >= 5) break; const encodedName = normalizeAndEncodeFileName(file.originalname); - const uploaded = await uploadFileToS3({ ...file, originalname: encodedName }); - const preview = await convertPdfToImage({ ...file, originalname: encodedName }); + const uploaded = await uploadFileToS3({ + ...file, + originalname: encodedName, + }); + const preview = await convertPdfToImage({ + ...file, + originalname: encodedName, + }); newFiles.push({ type: "pdf", path: uploaded.url, @@ -449,11 +495,16 @@ export const updateReference = async (req, res) => { // 기타 파일 처리 if (req.files?.otherFiles) { - const others = Array.isArray(req.files.otherFiles) ? req.files.otherFiles : [req.files.otherFiles]; + const others = Array.isArray(req.files.otherFiles) + ? req.files.otherFiles + : [req.files.otherFiles]; for (const file of others) { if (totalAttachments >= 5) break; const encodedName = normalizeAndEncodeFileName(file.originalname); - const uploaded = await uploadFileToS3({ ...file, originalname: encodedName }); + const uploaded = await uploadFileToS3({ + ...file, + originalname: encodedName, + }); newFiles.push({ type: "file", path: uploaded.url, @@ -480,7 +531,9 @@ export const updateReference = async (req, res) => { { $set: { updatedAt: reference.updatedAt } } ); - const populatedKeywords = await Keyword.find({ _id: { $in: reference.keywords } }).lean(); + const populatedKeywords = await Keyword.find({ + _id: { $in: reference.keywords }, + }).lean(); const keywordNames = populatedKeywords.map((k) => k.keywordName); res.status(200).json({ @@ -497,7 +550,6 @@ export const updateReference = async (req, res) => { } }; - // 레퍼런스 상세 기능 export const getReferenceDetail = async (req, res) => { try { @@ -697,10 +749,10 @@ export const getReference = async (req, res) => { let sort; switch (sortBy) { case "latest": - sort = { createdAt: -1 }; + sort = { updatedAt: -1 }; // createdAt → updatedAt break; case "oldest": - sort = { createdAt: 1 }; + sort = { updatedAt: 1 }; // createdAt → updatedAt break; case "sortAsc": sort = { title: 1 }; @@ -709,7 +761,7 @@ export const getReference = async (req, res) => { sort = { title: -1 }; break; default: - sort = { createdAt: -1 }; + sort = { updatedAt: -1 }; // createdAt → updatedAt break; } From 9283749ec41e38711f9227d09bdb85ee3f7ddbcb Mon Sep 17 00:00:00 2001 From: Hyunwoo Date: Sun, 1 Jun 2025 01:07:30 +0900 Subject: [PATCH 21/21] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=97=90=EC=84=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EB=B3=B5=EA=B5=AC=20=EC=97=AC=EB=B6=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controllers/kakaoController.js | 8 ++++++-- passport/kakaoStrategy.js | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/controllers/kakaoController.js b/controllers/kakaoController.js index 7e16ad8..664a02b 100644 --- a/controllers/kakaoController.js +++ b/controllers/kakaoController.js @@ -33,6 +33,7 @@ export const kakaoCallbackHandler = (req, res, next) => { } req.user = user; + req.recovered = user.recovered || false; // 복구 여부 전달 next(); })(req, res, next); }; @@ -40,6 +41,7 @@ export const kakaoCallbackHandler = (req, res, next) => { // 카카오 로그인 완료 후 JWT 발급 export const kakaoCallback = (req, res) => { const user = req.user; + const recovered = req.recovered || false; // 복구 여부 확인 const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '1h', @@ -50,7 +52,9 @@ export const kakaoCallback = (req, res) => { ? 'https://www.refhub.my' : 'http://localhost:5173'; - res.redirect(`${redirectBase}/users/kakao-login?token=${token}`); + // recovered 파라미터 추가 + const recoveredParam = recovered ? '&recovered=true' : ''; + res.redirect(`${redirectBase}/users/kakao-login?token=${token}${recoveredParam}`); }; // 카카오 계정 연동 API @@ -80,4 +84,4 @@ export const linkKakaoAccount = async (req, res) => { console.error('카카오 계정 연동 오류:', err); res.status(500).json({ error: '카카오 계정 연동 중 오류가 발생했습니다.' }); } -}; \ No newline at end of file +}; diff --git a/passport/kakaoStrategy.js b/passport/kakaoStrategy.js index 7b93265..1651dcf 100644 --- a/passport/kakaoStrategy.js +++ b/passport/kakaoStrategy.js @@ -22,6 +22,23 @@ passport.use( const user = await User.findOne({ email: kakaoEmail }); if (user) { + // 계정 복구 체크 + let recovered = false; + if (user.deleteRequestDate) { + const timeElapsed = new Date() - user.deleteRequestDate; + + if (timeElapsed >= 7 * 24 * 60 * 60 * 1000) { + // 7일이 지난 경우 계정 삭제 + await User.deleteOne({ _id: user._id }); + return done(new Error('계정이 삭제되었습니다. 다시 가입해주세요.'), null); + } + + // 7일 이내 로그인 시 계정 복구 + user.deleteRequestDate = undefined; + recovered = true; + await user.save(); + } + // 로컬 가입자인 경우 프론트로 연동 필요 전달 if (user.provider === 'local') { return done(null, false, { @@ -33,6 +50,9 @@ passport.use( }, }); } + + // 복구 정보를 user 객체에 추가 + user.recovered = recovered; return done(null, user); } @@ -52,4 +72,4 @@ passport.use( } } ) -); \ No newline at end of file +);