URL: https://port-0-easiestcv-6w1j2allypog1y.sel5.cloudtype.app/hongsi
GitHub Repository: https://github.com/SihyeonHong/EasiestCV

우선 이번에야 깨달았는데 GCS에 업로드한 사진을 삭제하는 기능이 없었다.
그래서 새로운 이미지를 업로드하면 기존 이미지는 GCS에서 삭제하는 기능도 새로 구현했다.
pdf에도 마찬가지로 적용된다.
/src/util/gcs.ts
여기에 deleteFile(filename) 함수를 추가했다.
어차피 파일명은 유니크하니까 파일명으로만 찾아도 된다.
export const deleteFile = async (filename: string) => {
try {
const file = bucket.file(filename);
await file.delete();
console.log(`File ${filename} deleted successfully.`);
} catch (error) {
console.error(`Failed to delete file ${filename}.`, error);
throw error;
}
};
/src/pages/api/post/upload.ts
// import { NextApiRequest, NextApiResponse } from "next";
import { query } from "../../../util/database";
import multer from "multer";
import { deleteFile, uploadFile } from "@/util/gcs";
// Disable Next.js's built-in body parser to allow multer to handle the form data
export const config = {
api: {
bodyParser: false,
},
};
const fileFilter = (req: any, file: any, cb: any) => {
// 허용되는 mime types
const allowedTypes = [
"image/jpg",
"image/jpeg",
"image/png",
"image/gif",
"application/pdf",
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true); // 파일을 허용
} else {
cb(null, false); // 파일을 거부
cb(
new Error(
"Invalid file type. Only JPG, JPEG, PNG, GIF and PDF are allowed."
)
);
}
};
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: fileFilter,
}).single("file");
export default function handler(req: any, res: any) {
if (req.method === "POST") {
upload(req, res, async (err) => {
if (err instanceof multer.MulterError) {
console.error("Multer error:", err);
return res.status(500).json({ error: "Multer error." });
} else if (err) {
console.error("Unknown error:", err);
return res.status(500).json({ error: "Unknown error during upload." });
}
let url = "https://storage.googleapis.com/easiest-cv/";
//console.log(req.file);
// Upload the file to GCS
try {
let uniqueFilename;
if (req.file.mimetype === "application/pdf") {
uniqueFilename = `${Date.now()}-${req.file.originalname}`;
url = url + uniqueFilename;
// delete old file
const result = await query(
"SELECT pdf FROM `easiest-cv`.userinfo WHERE userid = ?",
[req.body.userid]
);
console.log("result", result);
if (result.pdf) {
await deleteFile(result.pdf);
}
// upload new file
await uploadFile(uniqueFilename, req.file.buffer, "pdf");
const result2 = await query(
"UPDATE `easiest-cv`.userinfo SET pdf = ? WHERE userid = ?",
[url, req.body.userid]
);
console.log("result2", result2);
res.status(200).json({ pdfUrl: url });
return;
} else {
// image
uniqueFilename = `${Date.now()}-${req.file.originalname}`;
url = url + uniqueFilename;
try {
await uploadFile(uniqueFilename, req.file.buffer, "image");
} catch (error) {
console.error("Failed to upload image to GCS:", error);
return res
.status(500)
.json({ error: "Failed to upload image to GCS." });
}
let prevImageUrl;
try {
const result = await query(
"SELECT img FROM `easiest-cv`.userinfo WHERE userid = ?",
[req.body.userid]
);
prevImageUrl = result?.img;
} catch (error) {
console.error("Failed to fetch previous image URL:", error);
return res
.status(500)
.json({ error: "Failed to fetch previous image URL." });
}
if (prevImageUrl) {
try {
await deleteFile(prevImageUrl);
} catch (error) {
console.error("Failed to delete previous image from GCS:", error);
}
}
try {
await query(
"UPDATE `easiest-cv`.userinfo SET img = ? WHERE userid = ?",
[url, req.body.userid]
);
} catch (error) {
console.error("Failed to update image URL in database:", error);
return res
.status(500)
.json({ error: "Failed to update image URL in database." });
}
res.status(200).json({ imageUrl: url });
}
} catch (gcsError) {
console.error("Failed to upload image to GCS:", gcsError);
res.status(500).json({ error: "Failed to upload image to GCS." });
return;
}
});
} else {
res.status(405).json({ error: "Method not allowed" }); // Only POST method is allowed
}
}
"image/*" 라고 설정해 놨었는데, multer의 fileFilter에서는 이런 와일드카드 패턴이 안 된다고 한다. 그래서 일일이 이미지 파일 형식을 fileFilter 에 추가해놨다. 이 에러를 찾아내기 위한 코드가 upload() 함수 안쪽 맨 위에 들어있다. 도대체 무슨 에러인가 했다...optional chaining 문법을 배웠다. let imageUrl = result && result.img ? result.img : undefined; 이걸 줄여서 let imageUrl = result?.img; /src/app/[userid]/admin/AdminPage.tsx
const handlePDFUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedPDF(e.target.files[0]);
const formData = new FormData();
formData.append("file", e.target.files[0]);
formData.append("userid", userinfo.userid);
const res = axios
.post("/api/post/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((res) => {
alert("PDF 파일이 업로드되었습니다.");
if (res.data.pdfUrl) {
dispatch(setUserInfo({ ...userinfo, pdf: res.data.pdfUrl }));
}
})
.catch((err) => {
console.log(err);
});
}
};
...
<Nav.Item>
<div className="nav-pdf">
PDF{" "}
<input
type="file"
accept="application/pdf"
onChange={handlePDFUpload}
/>
</div>
</Nav.Item>
UPLOAD IMAGE 버튼을 없앴다. const handlePDF = async () => {
if (userinfo.pdf) {
window.open(userinfo.pdf, "_blank");
} else {
alert("PDF 파일이 없습니다.");
}
};
...
<Nav.Item>
<Nav.Link onClick={handlePDF}>
PDF
</Nav.Link>
</Nav.Item>
이제 PDF 버튼을 누르면 관리자가 업로드해둔 PDF를 다운받을 수 있다.
userid는 수정이 불가능하다. 도메인이니까.
일단 시작 전에 맨 위 타이틀에 userid가 아니라 username이 뜨게끔 수정했다.
<h1 className="title">
{username ? username.toUpperCase() : userid.toUpperCase()}
</h1>
페이지 열자마자 getUsers()로 유저 정보를 가져와서,
만약 존재하는 유저면 유저 이름이 타이틀로 뜨고 그 밑에 해당 유저의 CV 페이지가 뜨며,
존재하지 않는 유저면 그 존재하지 않는 아이디가 타이틀로 뜨고 그 밑에 No Such User라는 메시지만 뜨는 것이다.

username이 수정된 건 타이틀 변경으로, email이 수정된 건 콘솔창에서 확인할 수 있다.
모달창은 회원가입 폼을 그대로 가져왔기 때문에 따로 코드를 첨부하지는 않겠다.
필요하신 분은 맨 위 내 깃허브 레포지토리의 /src/app/[userid]/admin/AdminLayout.tsx를 확인하면 된다.
서버 단 코드도 여태까지 계속 해오던 거라서 생략하겠다.
getUsers() 코드는 /src/pages/api/get/user.ts에 있고,
/src/pages/api/put/user.ts 에는 이름 보면 알겠지만 이 User 데이터베이스의 username과 email을 업데이트하는 코드가 있다.

기존의 6자리 비밀번호를 4자리 비밀번호로 변경하는 모습을 볼 수 있다.
const [changePW, setChangePW] = useState(false);
const handleSave = () => {
const data = {
userid: userid,
username: username,
email: email,
currentPW: currentPW,
newPW: newPW,
};
if (changePW) {
if (currentPW === "") {
alert("현재 비밀번호를 입력해주세요.");
return;
}
if (newPW === "") {
alert("새 비밀번호를 입력해주세요.");
return;
}
if (confirmPW === "") {
alert("새 비밀번호를 다시 입력해주세요.");
return;
}
if (newPW !== confirmPW) {
alert("새 비밀번호가 일치하지 않습니다.");
return;
}
axios
.put("/api/put/password", data)
.then((res) => {
alert("회원정보가 수정되었습니다.");
})
.catch((err) => {
alert(err.response.data.message);
console.log(err);
});
} else {
axios
.put("/api/put/user", data)
.then((res) => {
alert("회원정보가 수정되었습니다.");
})
.catch((err) => {
alert(err.response.data.message);
console.log(err);
});
}
};
...
<Button
variant="dark"
onClick={() => setChangePW(!changePW)}
style={{ marginLeft: "2vw", marginRight: "2vw" }}
>
Change Password?
</Button>
<Modal.Body
className={changePW ? "" : "change-pw-hidden"}
style={{ textAlign: "left" }}
>
<Form.Text className="text-muted">
If you don't want to change your password, click the button above
again.
</Form.Text>
<br></br>
<br></br>
<Form.Label>Current Password</Form.Label>
<Form.Control
type="password"
placeholder="Current Password"
value={currentPW}
onChange={(e) => setCurrentPW(e.target.value)}
/>
<br></br>
<Form.Label>New Password</Form.Label>
<Form.Control
type="password"
placeholder="New Password"
value={newPW}
onChange={(e) => setNewPW(e.target.value)}
/>
<br></br>
<Form.Label>Confirm Password</Form.Label>
<Form.Control
type="password"
placeholder="New Password Again"
value={confirmPW}
onChange={(e) => setConfirmPW(e.target.value)}
/>
<br></br>
</Modal.Body>
<Modal.Footer>
<Button variant="light" onClick={handleClose}>
Cancel
</Button>
<Button variant="dark" onClick={handleSave}>
Save
</Button>
</Modal.Footer>
</Modal>
Change Password? 버튼을 누르면 추가적으로 비밀번호 수정 UI가 아래에 드러나게 했고,
이 버튼이 눌려 있을 때와 그렇지 않을 때의 state를 changePW 라는 이름으로 True or False 값을 넣어서 이 값에 따라 API route가 달라지도록 했다.
비밀번호를 수정할 때는 다른 정보들과 달리 기존 DB에 있던 암호화된 비밀번호를 비교하고 또 새 비밀번호를 암호화하는 과정이 필요해서, 그냥 아예 새로 API route를 만들었다.
/src/pages/api/put/password.ts
import { NextApiRequest, NextApiResponse } from "next";
import { query } from "../../../util/database";
import bcrypt from "bcrypt";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "PUT") {
// console.log(req.body);
let { userid, username, email, currentPW, newPW } = req.body;
// try update DB
try {
const result = await query(
"SELECT * FROM `easiest-cv`.users WHERE userid = ?",
[userid]
);
// console.log("result", result);
if (
result.length > 0 &&
(await bcrypt.compare(currentPW, result[0].password))
) {
// If there's a match of userid and password,
const hash = await bcrypt.hash(newPW, 10);
// console.log("hash", hash);
const result2 = await query(
"UPDATE `easiest-cv`.users SET username = ?, email = ?, password = ? WHERE userid = ?",
[username, email, hash, userid]
);
// console.log("result2", result2);
res.status(200).json("ok");
} else {
res.status(401).json({ message: "Invalid Password!" });
}
} catch (e: any) {
console.log("server error", e);
res.status(500).json({ message: e.message });
}
}
}
기존 비밀번호가 일치하지 않으면 브라우저의 alert창에 "Invalid Password!" 메시지가 뜨게 된다.
기존 비밀번호가 일치하면 새 비밀번호를 암호화해서 DB에 업데이트한다.
이 때 username과 email도 함께 바꿀 수 있다.


처음 회원가입할 때 넣은 이메일 주소가 맞으면 해당 이메일로 초기화된 임시 비밀번호를 보내준다.
nodemailer 라이브러리를 사용했다.
npm install nodemailer
npm i --save-dev @types/nodemailer
아래 명령어는 타입스크립트 한정.
참고한 블로그:
https://minu0807.tistory.com/155
https://lakelouise.tistory.com/240
우선 .env.local 에 다음 내용을 추가해줬다
email_service=gmail
user=hongsihyeon179@gmail.com
pass=앱비밀번호
user에는 보내는 주소의 이메일(나는 일단 내 개인 이메일을 넣었다)
구글 로그인에 2단계 인증 설정이 되어 있을 경우, 앱비밀번호를 생성하여 저기에 입력해야 한다.

구글 계정 > 보안 > 2단계 인증 > (쭉 내려서) 앱 비밀번호 >

앱 선택에서 기타 선택하고 아무 이름이나 입력.
나는 참고한 블로그 따라서 그대로 nodemailer라고 입력했다.
생성 버튼을 누르면
XXXX XXXX XXXX XXXX
대략 이렇게 생긴 앱 비밀번호가 생성된다. 그대로 복사해서 pass에 붙여넣으면 된다.
띄어쓰기 필요없이 16글자 붙여 쓰면 된다.
/src/pages/api/put/resetPW.ts
import { NextApiRequest, NextApiResponse } from "next";
import { query } from "../../../util/database";
import bcrypt from "bcrypt";
import nodemailer from "nodemailer";
const { email_service, user, pass } = process.env;
const transporter = nodemailer.createTransport({
service: email_service,
auth: {
user: user,
pass: pass,
},
});
function generateRandomPW(): string {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let password = "";
for (let i = 0; i < 10; i++) {
const randomIndex = Math.floor(Math.random() * charset.length);
password += charset[randomIndex];
}
return password;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "PUT") {
// console.log(req.body);
let { userid, email } = req.body;
// make random password
const randomStr = generateRandomPW();
try {
const result = await query(
"SELECT * FROM `easiest-cv`.users WHERE userid = ?",
[userid]
);
// console.log("result", result);
if (result.length > 0 && email === result[0].email) {
// If there's a match of userid and email,
// send email
const mailOptions = {
from: user,
to: email,
subject: "Easiest CV : Your Temporary Password",
text: "Your Temporary Password: " + randomStr,
};
transporter.sendMail(mailOptions, async (error: any, info: any) => {
if (error) {
console.error(error);
res.status(500).json({ message: "Failed to send email." });
} else {
console.log("Email Sent : ", info);
// update DB only when email is sent successfully
const hash = await bcrypt.hash(randomStr, 10);
// console.log("hash", hash);
const result2 = await query(
"UPDATE `easiest-cv`.users SET password = ? WHERE userid = ?",
[hash, userid]
);
// console.log("result2", result2);
res.status(200).json("ok");
}
});
} else {
res.status(401).json({ message: "Invalid Email!" });
}
} catch (e: any) {
console.log("server error", e);
res.status(500).json({ message: e.message });
}
}
}
프론트엔드는 별 내용 없어서 생략하겠다.
해당 코드는 /src/app/LoginForm.tsx에 들어있다.
클라우드타입 들어가서 그냥 시키는 대로 하면 쉽더라.
도메인은 귀찮아서 안 샀다. 클라우드타입에서 임시 도메인 만들어주더라.

몰랐던 점은 DB는 로컬에다 만드는 게 아니라 진작 여기다가 배포한 뒤에 사용하는 게 나았을 거라는 점이다.
클라우드타입에 환경변수 적는 곳이 있다.

저기 맨 오른쪽 톱니바퀴(설정) 버튼 클릭해서

여기에 시크릿에다가 비공개 정보를 적고

배포한 Next.js 프로젝트에 들어가서 설정 탭을 누르고

NODE_ENV 맨 위에 있는 건 그대로 냅둔 상태에서
필요한 환경변수를 이렇게 찾아오면 된다.
저 열쇠 클릭하면 아까 시크릿이나 공통변수에 저장해둔 name들이 뜬다.
나는 편하게 환경변수 이름과 똑같게 설정해뒀다.
뭐 말하는 건지 모르겠으면 지난 포스팅 참고.

이렇게 적어도 되더라.
JSON 키 파일 내부의 내용을 그대로 긁어온 거다.
그리고 gcs.ts 코드는 이렇게 변경해서 재배포했다.
import { Storage } from "@google-cloud/storage";
const storage = new Storage({
// keyFilename: "easiest-cv-5889be812d3a.json",
credentials: JSON.parse(
process.env.GCS_CREDENTIALS ? process.env.GCS_CREDENTIALS : ""
),
projectId: "easiest-cv",
});
타입스크립트라 타입에러가 나서 조건문을 대충 넣었다.
https://port-0-easiestcv-6w1j2allypog1y.sel5.cloudtype.app/hongsi
이게 완성된 사이트니까 아무나 들어가셔서 테스트해보셔도 된다.
조만간 테스트용 계정도 하나 추가할까 싶은데 일단 저건 내 계정이다.
맘대로 회원가입하고 사용해 보셔도 됩니다.
문과 시절의 '내 글 구려' 병과 비슷한 병에 걸렸다. 내가 만든 웹사이트 너무 구린 것 같다... 힝...
단점만 잔뜩 보이고 부끄러워서 어디 내놓질 못하겠다 좀 더 수정해야지
일단 지금 생각나는 건
한동안 잠시 이력서 쓰기에 집중하다가 주변인 피드백 받으면서 조금씩 수정해봐야겠다.
Weekly Progress는 여기서 끝!