Easiest CV Weekly Progress 10 - 최종

홍시·2023년 9월 5일

EasiestCV

목록 보기
11/16
post-thumbnail

URL: https://port-0-easiestcv-6w1j2allypog1y.sel5.cloudtype.app/hongsi

GitHub Repository: https://github.com/SihyeonHong/EasiestCV

이번 주에 한 일

  1. pdf 저장 기능
  2. 회원정보수정
  3. 배포

1. PDF 저장 기능

우선 이번에야 깨달았는데 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;
  }
};

이미지와 pdf파일 모두 처리하는 api

/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
  }
}
  • img, pdf 모두 single('file')로 받는다
  • image 파일 형식을 처음에 "image/*" 라고 설정해 놨었는데, multer의 fileFilter에서는 이런 와일드카드 패턴이 안 된다고 한다. 그래서 일일이 이미지 파일 형식을 fileFilter 에 추가해놨다. 이 에러를 찾아내기 위한 코드가 upload() 함수 안쪽 맨 위에 들어있다. 도대체 무슨 에러인가 했다...
  • if문으로 pdf인 경우와 img인 경우를 나누어 처리했다.
  • optional chaining 문법을 배웠다. let imageUrl = result && result.img ? result.img : undefined; 이걸 줄여서 let imageUrl = result?.img;
  • 기존 파일 삭제를 위해 기존 파일명을 SELECT하는 쿼리를 하나 더 추가했다.
  • 프론트엔드에서 userid 정보를 추가로 받아와서 쿼리에 이용한다. 저번 주에 이것도 테스트용으로 하드코딩해두고 깜빡했더라.

/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>
  • formData에 저렇게 append로 데이터를 추가하고 백엔드에서 req.body.userid로 받아올 수 있다는 것을 배웠다.
  • 원래 파일 선택 버튼과 업로드 버튼을 따로 뒀었는데, 다음과 같이 하나로 합쳐서 파일 선택을 완료하면 바로 GCS와 DB에 업로드되게끔 했다. 이미지파일도 마찬가지로 수정하고 UPLOAD IMAGE 버튼을 없앴다.
    `
    /src/app/[userid]/NonAdminPage.tsx
  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를 다운받을 수 있다.

2. 회원정보수정

2-1. username, email 수정하기

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을 업데이트하는 코드가 있다.

2-2. 비밀번호 변경

기존의 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도 함께 바꿀 수 있다.

2-3. 비밀번호 재설정 이메일 보내기


처음 회원가입할 때 넣은 이메일 주소가 맞으면 해당 이메일로 초기화된 임시 비밀번호를 보내준다.

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글자 붙여 쓰면 된다.

백엔드 : 메일보내기, DB 업데이트

/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 });
    }
  }
}
  • 맨 위에는 nodemailer 설정과 랜덤으로 비밀번호 생성하는 함수가 있다.
    - charset에 특수문자를 넣으면 특수문자 포함 랜덤으로 생성할 수 있다.
    - for문에 나는 10글자로 하려고 10을 넣었는데 이것도 원하는 글자수만큼 넣으면 된다.
  • 프론트엔드에서 userid와 email을 사용자에게 입력받는다.
  • DB에서 userid로 해당 사용자를 찾는다.
  • 해당 사용자가 존재하고 email 주소가 일치하면
    - 랜덤 비밀번호를 생성해 이메일로 보내준다.
    - 이메일 전송이 성공하면 그 비밀번호를 암호화해 DB에 저장한다.
  • 일치하지 않으면 일치하지 않는다고 에러 메시지를 프론트엔드에 보낸다.

프론트엔드는 별 내용 없어서 생략하겠다.
해당 코드는 /src/app/LoginForm.tsx에 들어있다.

3. 배포

클라우드타입 들어가서 그냥 시키는 대로 하면 쉽더라.
도메인은 귀찮아서 안 샀다. 클라우드타입에서 임시 도메인 만들어주더라.


몰랐던 점은 DB는 로컬에다 만드는 게 아니라 진작 여기다가 배포한 뒤에 사용하는 게 나았을 거라는 점이다.

.env 파일은 어떡하죠?

클라우드타입에 환경변수 적는 곳이 있다.

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


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


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


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

GCS 키 파일 JSON은?

뭐 말하는 건지 모르겠으면 지난 포스팅 참고.


이렇게 적어도 되더라.
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

이게 완성된 사이트니까 아무나 들어가셔서 테스트해보셔도 된다.
조만간 테스트용 계정도 하나 추가할까 싶은데 일단 저건 내 계정이다.
맘대로 회원가입하고 사용해 보셔도 됩니다.

문과 시절의 '내 글 구려' 병과 비슷한 병에 걸렸다. 내가 만든 웹사이트 너무 구린 것 같다... 힝...
단점만 잔뜩 보이고 부끄러워서 어디 내놓질 못하겠다 좀 더 수정해야지

일단 지금 생각나는 건

  • 내 자기소개에 써놓은 대로 자기소개에서 엔터, 링크 안 됨
  • 모바일에서 UI 구림 테이블 튀어나가고 좌우 margin 너무 넓음
  • 탭에 지금은 title과 description만 있는데 원래 배포 후에 image, bullet list도 넣을 수 있게 수정하고 싶다는 계획이 있었음 역시나 글만 쓸 수 있는 지금은 너무 허전해보임
  • 기본 언어를 한글로 할 거냐 영어로 할 거냐 선택해라

한동안 잠시 이력서 쓰기에 집중하다가 주변인 피드백 받으면서 조금씩 수정해봐야겠다.
Weekly Progress는 여기서 끝!

profile
웹프론트엔드

0개의 댓글