[Youtube clone coding] #8 User Profile

 

#8.0 Edit Profile GET, #8.1 Protector and Public Middlewares

깃허브로 로그인하면 아바타를 가져올 수 있다.

이번 챕터에서는 웹사이트 로그인에서도 아바타를 가져올 수 있도록, 그리고 깃허브 로그인 상태에서도 아바타를 변경할 수 있는 기능을 만들어볼 것이다.

 

가장 먼저, 지금까지 계속 해왔던 것처럼 userRouter.js, usercontroller.js, *.pug를 수정해보자.

  1. userRouter.js
    import express from "express";
    import { edit, logout, see, startGithubLogin, finishGithubLogin, postEdit, getEdit } from "../controllers/usercontroller";
    
    const userRouter = express.Router();
    
    userRouter.route("/edit").get(getEdit).post(postEdit);
    userRouter.get("/logout", logout);
    userRouter.get("/github/start",startGithubLogin);
    userRouter.get("/github/finish",finishGithubLogin);
    userRouter.get("/:id", see);
    
    export default userRouter;

     

  2. usercontroller.js
    export const getEdit = (req, res) => {
        return res.render("edit-profile", {pageTitle : "Edit Profile"});
    }
    export const postEdit = (req, res) => {
        return res.render("edit-profile", {pageTitle : "Edit Profile"});
    }

     

  3. pug 수정
    doctype html 
    html(lang="ko")
        head 
            title #{pageTitle} | #{siteName}
            <link rel="stylesheet" href="https://unpkg.com/mvp.css"> 
        body 
            header 
                nav 
                    ul
                        li 
                            a(href='/') Home
                        li 
                            a(href='/videos/upload') Upload
                        li 
                            a(href='/search') Search 
    
                        if loggedIn
                            li 
                                a(href='/users/edit') Edit Profile
                            li 
                                a(href='/my-profile') Profile
                            li 
                                a(href='/users/logout') Logout  
                        else
                            li 
                                a(href='/join') Join
                            li 
                                a(href='/login') Login
                h1=pageTitle
            main 
                block content
        include partials/footer.pug

    base.pug

    extends base.pug
    
    block content
        form(method='POST')
            input(name='name', placeholder='name',type='text',required)
            input(name='username', placeholder='username',type='text',required)
            input(name='email', placeholder='email',type='email',required)
            input(name='location', placeholder='location',type='text')

    edit-profile.pug

    위처럼 만들어주면 다음의 페이지를 확인할 수 있다.

    Edit Profile 페이지인데 칸에 아무것도 채워져 있지 않은 것은 보기 좋지 않다. 원래 뭐였는지 바로 눈에 보여야 바꾸기도 더 편하지 않겠나??

     

    따라서 edit-profile.pug를 수정해준다.

    extends base.pug
    
    block content
        form(method='POST')
            input(name='name', placeholder='name',type='text',required, value=loggedInUser.name)
            input(name='username', placeholder='username',type='text',required, value=loggedInUser.username)
            input(name='email', placeholder='email',type='email',required, value=loggedInUser.email)
            input(name='location', placeholder='location',type='text', value=loggedInUser.location)
            input(type='submit',value='Edit Profile')

     

    우리는 middleware.js에서 res.locals.loggedInUser를 선언했다. 이는 전역변수로, views 폴더에도 넘어가기 때문에 바로 접근해서 사용할 수 있다.

    이제는 칸이 채워져있다.

    그런데 문제는, 로그인을 안한 상태에서 url을 직접 입력하면 loggedInUser가 undefined이기 때문에 오류가 발생한다는 것이다.

    export const localsMiddleware = (req, res, next) => {
        res.locals.loggedIn = Boolean(req.session.loggedIn);
        res.locals.siteName = "Wetube";
        res.locals.loggedInUser = req.session.user || {};
        next();
    }

     

    오류가 발생하는 건 좋지 않은 현상이기 때문에 위와 같이 ‘|| {}’을 추가하여 로그인이 안된 상태를 처리할 수 있도록 바꿔주었다. 로그인이 안된 상태로 url에 접속하면 빈 객체를 전달하게 된다.

     

    그런데 이러면 로그인을 안한 상태로 접근할 수 있지 않은가??

     

    로그인한 상태에서만 접근할 수 있는 페이지, 로그아웃된 상태에서만 접근할 수 있는 페이지를 구분하기 위해,

    우리는 새로운 middleware를 추가해줄 것이다.

    export const localsMiddleware = (req, res, next) => {
        res.locals.loggedIn = Boolean(req.session.loggedIn);
        res.locals.siteName = "Wetube";
        res.locals.loggedInUser = req.session.user || {};
        next();
    }
    
    export const protectorMiddleware = (req, res, next) => {
        if(req.session.loggedIn){
            next();
        }
        else{
            res.write("<script>alert('LogIn First!')</script>");
            res.write("<script>window.location='http://localhost:4000/login'</script>");
            //res.redirect("/login");
        }
    }
    
    export const publicOnlyMiddleware = (req, res, next) => {
        if(!req.session.loggedIn){
            next();
        }
        else{
            res.write("<script>alert('LogOut First!')</script>");
            res.write("<script>window.location='http://localhost:4000/'</script>");
            //res.redirect("/login");
        }
    }

    middleware.js

    위처럼 protectorMiddleware, publicOnlyMiddleware 함수를 추가해주었다.

    각각은 로그인된 상태에서만 접근할 수 있는 페이지, 로그아웃된 상태에서만 접근할 수 있는 페이지에 사용될 것이다.

    import express from "express";
    import { logout, see, startGithubLogin, finishGithubLogin, postEdit, getEdit } from "../controllers/usercontroller";
    import { protectorMiddleware, publicOnlyMiddleware } from "../middleware";
    
    const userRouter = express.Router();
    
    userRouter.route("/edit").all(protectorMiddleware).get(getEdit).post(postEdit);
    userRouter.get("/logout", protectorMiddleware, logout);
    userRouter.get("/github/start", publicOnlyMiddleware, startGithubLogin);
    userRouter.get("/github/finish", publicOnlyMiddleware, finishGithubLogin);
    userRouter.get("/:id", see);
    
    export default userRouter;

    userRouter.js

    따라서 각 페이지의 용도에 맞게 함수를 알맞게 넣어주었다. 그냥 controller 함수 앞에 넣어줘도 되지만, get과 post를 함께 route 하고 있는 경우는 all method를 사용할 수 있다.

    import express from "express";
    import { home, search} from "../controllers/videocontroller";
    import { getJoin, getLogin, join, login, postJoin, postLogin } from "../controllers/usercontroller";
    import { publicOnlyMiddleware } from "../middleware";
    
    const rootRouter = express.Router();
    
    rootRouter.get("/", home);
    rootRouter.route("/join").all(publicOnlyMiddleware).get(getJoin).post(postJoin);
    rootRouter.route("/login").all(publicOnlyMiddleware).get(getLogin).post(postLogin);
    rootRouter.get("/search", search);
    
    export default rootRouter;

    rootRouter.js

    import express from "express";
    import { watch, getEdit, postEdit, getUpload, postUpload, deleteVideo } from "../controllers/videocontroller";
    import { protectorMiddleware } from "../middleware";
    
    const videoRouter = express.Router();
    
    videoRouter.route("/:id([0-9a-f]{24})").get(watch);
    videoRouter.route("/:id([0-9a-f]{24})/edit").all(protectorMiddleware).get(getEdit).post(postEdit);
    videoRouter.route("/:id([0-9a-f]{24})/delete").all(protectorMiddleware).get(deleteVideo);
    videoRouter.route("/upload").all(protectorMiddleware).get(getUpload).post(postUpload);
    
    export default videoRouter;

    videoRouter.js

    잘 적용된 것을 확인할 수 있다.

    → 로그인된 상태에서 login 페이지에 접속하려는 경우

    → 로그아웃 상태에서 edit 페이지에 접속하려는 경우

     

     

#8.2, #8.3 Edit Profile POST

export const postEdit = async (req, res) => {
    const {
        session : {
            user : _id
        },
        body : {
            name, username, email, location
        }
    } = req;

    await User.findByIdAndUpdate(_id,{
        name, username, email, location
    });
    
    return res.render("edit-profile", {pageTitle : "Edit Profile"});
}

usercontroller.js

postEdit 함수를 바꿔주었다. ES6의 장점!! 하나의 선언문 안에서 여러개의 변수를 정의할 수 있다!!

 

그리고 mongoose의 model인 User에서 findByIdAndUpdate로 req.body로 받아온 친구들로 업데이트 해주었다.

username을 admin → not admin으로 바꿔서 Edit Profile 버튼을 눌러보면,

새로고침 되면서 여전히 admin으로 뜬다. 이유가 뭘까?

 

우리는 현재 session으로 부터 _id를 받아와서 이를 가지고 db에 접근하여 db의 값을 바꾸는 것이다.

그렇기 때문에 db를 확인해보면 username이 성공적으로 업데이트 된 것을 확인할 수 있다.

하지만 session의 값을 없데이트 해주지 않았기 때문에 middleware.js의 localMiddleware 함수에서 res.locals에 값을 업데이트 해주지 못했고, pug의 input의 value에도 값이 업데이트 되지 않은 것이다.

 

export const postEdit = async (req, res) => {
    const {
        session : {
            user : _id
        },
        body : {
            name, username, email, location
        }
    } = req;

    const updatedUser = await User.findByIdAndUpdate(
        _id,
        {
        name, username, email, location
        },
        {new: true}
    );
    
    req.session.user = updatedUser;
    
    return res.redirect("/users/edit")
}

 

위처럼 findByIdAndUpdate의 결과로 updatedUser의 새로운 값을 받아온다! new: true 옵션을 줌으로써

그리고 req.session.user에 updatedUser를 넣어준다. render 대신 redirect로 바꿔준다.

이러면 잘 적용된다.

 

그런데 문제가 있다!!! 만약 바뀐 값이 기존에 존재하는 값이라면 어떡할까? 안된다고 해야겠지….

그런데 또 문제가 있다!!! 만약 전체를 다 바꾸는 게 아니라면? 바꾸지 않은 값 때문에 안된다 그러지 않을까…???

export const postEdit = async (req, res) => {
    const pageTitle = "Edit Profile";
    const {
        session : {
            user : {_id: _id, username: sessionUsername, email : sessionEmail} 
        },
        body : {
            name, username, email, location
        }
    } = req;

    //code challenge - start
    let searchParams = []
    if (sessionUsername !== username)
        searchParams.push({username})
    if (sessionEmail !== email)
        searchParams.push({email})

    if (searchParams.length > 0){
        const foundUser = await User.findOne({$or : searchParams});
        
        if(foundUser){
            return res.status(400).render("edit-profile", {pageTitle, errorMessage : 'The username/email is already taken.'});
        }
    }
    //code chanlleng - end

    const updatedUser = await User.findByIdAndUpdate(
        _id,
        {
        name, username, email, location
        },
        {new: true}
    );
    
    req.session.user = updatedUser;

    return res.redirect("/users/edit")
}

 

따라서 위와 같이 확인하는 과정을 넣어주었다.

만약 session 값과 qeury의 값이 다른 것이 있다면 이를 searchParams 배열에 추가해준다.

배열에 추가된 원소들은 실제로 바꾸고자 하는 것이므로, findOne을 통해 이미 해당 username이나 email을 가진 user가 존재하는지 확인한다.

그렇게 해서 유저를 찾을 수 있다면 중복되는 값이 존재한다는 의미이므로 error를 반환한다.

 

#8.4, #8.5 Change Password

항상 해오던 것처럼 change-password를 위한 세가지 수정사항을 적용해준다.

import express from "express";
import { logout, see, startGithubLogin, finishGithubLogin, postEdit, getEdit, getChangePassword, postChangePassword } from "../controllers/usercontroller";
import { protectorMiddleware, publicOnlyMiddleware } from "../middleware";

const userRouter = express.Router();

userRouter.route("/edit").all(protectorMiddleware).get(getEdit).post(postEdit);
userRouter.get("/logout", protectorMiddleware, logout);
userRouter.get("/github/start", publicOnlyMiddleware, startGithubLogin);
userRouter.get("/github/finish", publicOnlyMiddleware, finishGithubLogin);
userRouter.route("/change-password").all(protectorMiddleware).get(getChangePassword).post(postChangePassword);
userRouter.get("/:id", see);

export default userRouter;

userRouter.js

export const getChangePassword = (req, res) => {
    return res.render("users/change-password",{pageTitle: "Change Password"})
};

export const postChangePassword = (req, res) => {
    return res.redirect("/");
};

usercontroller.js

extends ../base.pug

block content 
    form(method='POST')
        input(placeholder='Old Password')
        input(placeholder='New Password')
        input(placeholder='New Password Confirmation')
        input(type='submit',value='Change password')

change-password.pug

 

추가로, views에 파일이 너무 많아져서 users, videos 폴더로 구분해서 정리했다.

이로 인해 base.pug를 extends 할 때 경로를 잘 지켜줘야 한다.

 

그런데 깃허브로 로그인한 경우, password가 존재하지 않기 때문에 해당 페이지를 보여주면 안된다.

export const getChangePassword = (req, res) => {
    if(req.session.user.socialOnly === true)
        return res.redirect("/");
    return res.render("users/change-password",{pageTitle: "Change Password"});
};

change-password.pug

따라서 해당 url에 접속하면 다시 home으로 리다이렉트 시키고,

extends ../base.pug

block content
    if errorMessage 
        span=errorMessage

    form(method='POST')
        input(name='name', placeholder='name',type='text',required, value=loggedInUser.name)
        input(name='username', placeholder='username',type='text',required, value=loggedInUser.username)
        input(name='email', placeholder='email',type='email',required, value=loggedInUser.email)
        input(name='location', placeholder='location',type='text', value=loggedInUser.location)
        input(type='submit',value='Edit Profile')
        if !loggedInUser.socialOnly
            hr
            a(href='change-password') Change Password →

edit-profile.pug

Edit Profile 페이지에서는 아예 버튼이 보이지 않게 만들었다.

 

 

이제 본격적으로 비밀번호를 바꿔보자.

change-password 페이지는 3단계로 나눠서 구현해 볼 것이다.

  1. 새 비밀번호화 비밀번호 확인이 동일한지
    extends ../base.pug
    
    block content 
        if errorMessage 
            span=errorMessage
    
        form(method='POST')
            input(name='oldPW', type='password', placeholder='New Password')
            input(name='newPW', type='password', placeholder='Old Password')
            input(name='newPW2', type='password', placeholder='New Password Confirmation')
            input(type='submit',value='Change password')

    change-password.pug

    먼저 js의 req에서 input 태그의 각 값에 접근할 수 있도록 name 속성을 각각 넣어주었다.

    export const postChangePassword = (req, res) => {
        const pageTitle = "Change Password";
        const {
            session : {
                user : {_id} 
            },
            body : {
                oldPW, newPW, newPW2
            }
        } = req; 
    
        if(newPW !== newPW2){
            return res.status(400).render("user/change-password", 
            {pageTitle, errorMessage : 'Please Enter the same password.'}
            );
        }
        return res.redirect("/");
    };

    usercontroller.js

    newPW와 newPW2를 각각 가져와서 같은지 비교한다.

    일단 첫단계는 성공!

     

  2. 기존 비밀번호가 일치하는지 확인하기
    export const postChangePassword = async (req, res) => {
        const pageTitle = "Change Password";
        const {
            session : {
                user : {_id, password} 
            },
            body : {
                oldPW, newPW, newPW2
            }
        } = req; 
    
        const ok = await bcrypt.compare(oldPW, password);
        if(!ok){
            return res.status(400).render("users/change-password", 
            {pageTitle, errorMessage : 'Wrong password..'}
            );
        }
    
        if(newPW !== newPW2){
            return res.status(400).render("users/change-password", 
            {pageTitle, errorMessage : 'Please Enter the same password.'}
            );
        }
        return res.redirect("/");
    };

     

    session에서 db에 저장된 password를 가져온다. 이는 sha256 해시값이기 때문에, bcrypt 모듈의 compare 함수를 이용하여 비교해준다.

    이거도 성공!

     

  3. 비밀번호 변경하고 db, 세션에 적용하기
    export const postChangePassword = async (req, res) => {
        const pageTitle = "Change Password";
        const {
            session : {
                user : {_id, password} 
            },
            body : {
                oldPW, newPW, newPW2
            }
        } = req; 
    
        const ok = await bcrypt.compare(oldPW, password);
        if(!ok){
            return res.status(400).render("users/change-password", 
            {pageTitle, errorMessage : 'Wrong password..'}
            );
        }
    
        if(newPW !== newPW2){
            return res.status(400).render("users/change-password", 
            {pageTitle, errorMessage : 'Please Enter the same password.'}
            );
        }
    
        const user = await User.findById({_id});
        user.password = newPW;
        await user.save();
        req.session.user.password = user.password;
    
        return res.redirect("/");
    };

     

    마지막으로 실제 변경된 비밀번호를 적용하는 코드를 추가해주었다. 코드 순서는 다음과 같다.

     

    1. _id를 가지고 user를 db에서 찾는다.
    2. user의 password를 newPW로 바꾼다.
    3. 바뀐 내용을 db에 저장해준다. user.save()가 실행되기 전에 pre 함수가 먼저 실행된다!!
      userSchema.pre("save", async function () {
          this.password = await bcrypt.hash(this.password, 5);
      })

      User.js

      비밀번호를 해싱해서 해시값을 저장해주는 것이다.

    4. 세션 역시 새로운 해시값으로 업데이트 해준다.

    Admin의 비밀번호가 달라진 것을 확인할 수 있다.

     

#8.6, #8.7, #8.8 File Uploads

먼저 image를 받을 input 태그를 만들어보자!

extends ../base.pug

block content 
    if errorMessage 
        span=errorMessage

    form(method='POST')
        label(for='avatar') Avatar
        input(type='file', id='avatar', name='avatar', accept='image/*')
        input(name='oldPW', type='password', placeholder='New Password')
        input(name='newPW', type='password', placeholder='Old Password')
        input(name='newPW2', type='password', placeholder='New Password Confirmation')
        input(type='submit',value='Change password')

edit-profile.pug

label 태그와 함께 type이 file인 input 태그를 추가해줬다.

accept에 image/*라고 하면 jpg, png 와 같은 이미지 파일만을 받을 수 있다.

위와 같이 파일을 업로드할 수 있는 input 태그가 만들어졌다.

 

다음으로, 파일 업로드를 도와줄 middleware를 사용해보자.

→ npm i multer

 

우리는 multer라는 middleware를 사용할 것이다. 그런데 multer는 multipart가 아닌 다른 form을 허용하지 않는다고 한다.

extends ../base.pug

block content
    if errorMessage 
        span=errorMessage

    form(method='POST', enctype='multipart/form-data')
        label(for='avatar') Avatar
        input(type='file', id='avatar', name='avatar', accept='image/*')
        input(name='name', placeholder='name',type='text',required, value=loggedInUser.name)
        input(name='username', placeholder='username',type='text',required, value=loggedInUser.username)
        input(name='email', placeholder='email',type='email',required, value=loggedInUser.email)
        input(name='location', placeholder='location',type='text', value=loggedInUser.location)
        input(type='submit',value='Edit Profile')
        if !loggedInUser.socialOnly
            hr
            a(href='change-password') Change Password →

 

따라서 form 태그에 enctype=’multipart/form-data’ 를 추가해줬다.

이는 파일을 백엔드로 보내기 위한 encryption type을 설정해주는 것이다.

 

export const uploadFile = multer({dest : 'uploads/'})

middleware.js

middleware.js에 uploadFile 함수를 만들어줬다. multer는 req, res, next 등의 선언이 필요없다.

dest는 multer가 input 태그로부터 받은 파일을 실제로 올리는 폴더이다.

코드를 저장했더니 자동으로 uploads 폴더가 생겼다!

import express from "express";
import { logout, see, startGithubLogin, finishGithubLogin, postEdit, getEdit, getChangePassword, postChangePassword } from "../controllers/usercontroller";
import { protectorMiddleware, publicOnlyMiddleware, uploadFile } from "../middleware";

const userRouter = express.Router();

userRouter.route("/edit").all(protectorMiddleware).get(getEdit).post(uploadFile.single('avatar') ,postEdit);
userRouter.get("/logout", protectorMiddleware, logout);
userRouter.get("/github/start", publicOnlyMiddleware, startGithubLogin);
userRouter.get("/github/finish", publicOnlyMiddleware, finishGithubLogin);
userRouter.route("/change-password").all(protectorMiddleware).get(getChangePassword).post(postChangePassword);
userRouter.get("/:id", see);

export default userRouter;

userRouter.js

uploadFile 함수를 /edit의 post에서 postEdit 함수 앞에 넣어줬다. 이렇게 되면 middleware 실행 후 postEdit이 실행된다.

우리는 하나의 파일만 보내기에 single 메소드를 사용했고, input 태그의 name인 ‘avatar’를 넣어줬다.

이제 usercontroller에서(postEdit 함수) req.file로 해당 파일에 접근할 수 있다고 한다.

 

export const postEdit = async (req, res) => {
    const pageTitle = "Edit Profile";
    const {
        session : {
            user : {_id: _id, username: sessionUsername, email : sessionEmail} 
        },
        body : {
            name, username, email, location
        }
    } = req;
    console.log(req.file);

		...
}

usercontroller.js

실제로 확인을 위해 console.log로 req.file을 찍어봤다.

아무 사진이나 골라서 올렸더니

file 객체를 보여줬다! 내가 올린 file에 대한 다양한 정보를 확인할 수 있었다.

실제로 uploads 폴더에 가보면 올라간 파일이 있는 것을 확인할 수 있었다.

 

 

const userSchema = new mongoose.Schema({
    avatarURL : {type: String},
    name: { type: String, required: true},
    socialOnly : {type: Boolean, default : false},
    username: { type: String, required: true, unique: true},
    password: {type: String},
    email: { type: String, required: true, unique: true},
    location: {type: String},
})

User.js

userSchema를 확인해보면 avatarURL이 존재한다.

export const postEdit = async (req, res) => {
    const pageTitle = "Edit Profile";
    const {
        session : {
            user : {_id: _id, username: sessionUsername, email : sessionEmail, 
										avatarURL : sessionAvatarURL} 
        },
        body : {
            name, username, email, location
        },
        file
    } = req;
    console.log(file);

		...
		
		const updatedUser = await User.findByIdAndUpdate(
        _id,
        {
        avatarURL : file ? file.path : sessionAvatarURL,
        name, username, email, location
        },
        {new: true}
    );
    
    req.session.user = updatedUser;

    return res.redirect("/users/edit")
}

 

따라서 세션으로부터 기존의 avatarURL을 받아온다.(sessionAvatarURL)

updatedUser에 avatarURL을 적용시켜 준다.

이때 file이 전송되지 않는 경우도 존재할 수 있으므로 (항상 아바타를 바꾸는 것은 아니므로),

삼항연산자를 사용해서

→ 아바타 변경 시 : file 객체의 path로 avatarURL 변경

→ 아바타 변경 x : 기존의 avatarURL 유지 by sessionAvatarURL

을 적용해준다.

위와 같이 file 객체를 확인할 수 있고,

db에 avatarURL이 잘 업데이트 된 것을 확인할 수 있다.

 

여기서 명심해야 할 것이 있다.

절대 파일을 DB에 저장하지 않는다!!! DB에는 파일의 경로만 저장한다!!!

 

extends ../base.pug

block content
    if errorMessage 
        span=errorMessage

    img(src='/' + loggedInUser.avatarURL, width='100', height='100')
    form(method='POST', enctype='multipart/form-data')
        label(for='avatar') Avatar
        input(type='file', id='avatar', name='avatar', accept='image/*')
        input(name='name', placeholder='name',type='text',required, value=loggedInUser.name)
        input(name='username', placeholder='username',type='text',required, value=loggedInUser.username)
        input(name='email', placeholder='email',type='email',required, value=loggedInUser.email)
        input(name='location', placeholder='location',type='text', value=loggedInUser.location)
        input(type='submit',value='Edit Profile')
        if !loggedInUser.socialOnly
            hr
            a(href='change-password') Change Password →

edit-profile.pug

이제 이미지 파일을 확인해보기 위해 img 태그를 pug에 넣어줬다.

여기서 중요한 점은, 이미지가 저장되는 곳이 uploads/* 이기 때문에 절대경로를 사용해야 한다. 만약 상대경로를 사용하게 되면 users/uploads/*이 되고, 이러한 경로(폴더)는 존재하지 않는다..

 

새로고침 해봤는데 이미지를 불러오지 못하고 있다.

그 이유는 우리가 아직 uploads/* url과 관련된 작업을 안해줬기 때문이다.

 

app.use(localsMiddleware);
app.use("uploads", express.static('uploads'));
app.use("/",rootRouter);
app.use("/videos",videoRouter);
app.use("/users",userRouter);

export default app;

server.js

따라서 server.js에 위의 코드 한 줄을 추가해줬다.

우리는 현재 서버에 파일을 저장하고 있다. (uploads 디렉토리)

그리고 위의 코드는, 서버에 저장한 파일을 브라우저에 노출시켜 주는 것이다.

이제 이미지가 제대로 보인다!

서버의 /uploads 폴더를 브라우저에 노출시켜서, 해당 폴더에서 avatarURL을 통해 이미지 파일을 가져온 것이다.

그런데 이 방법은 그다지 좋은 방법은 아니다. 따라서 나중에 수정해줘야 한다.

 

 

+추가로, uploads 폴더는 github에 올리지 않는 것이 좋으므로 .gitignore에 추가해준다.

/node_modules
.env
/uploads

.gitignore

 

#8.9 Video Upload

이제 실제 비디오를 업로드해보자.

먼저 upload.pug를 수정해주었다.

extends ../base.pug

block content 
    if errorMessage 
        span=errorMessage
    form(method='POST', enctype='multipart/form-data')
        label(for='video') Video 
        input(type='file', id='video', name='video', accept='video/*', required)
        input(name='title', placeholder='Title',type='text',required, maxLength=80)
        input(name='description', placeholder='Description',type='text',required, minLength=20)
        input(name='hashtags', placeholder='Hashtags separated by comma',type='text',required)
        input(type='submit',value='Upload')

videos/upload.pug

edit-profile.pug와 비슷하게 enctype, label태그, input 태그를 추가해주었다.

export const uploadAvatar = multer({
    dest : 'uploads/avatar/',
    limits: {
        fileSize : 1000000 // 1MB
    },
})

export const uploadVideo = multer({
    dest : 'uploads/videos/',
    limits: {
        fileSize : 10000000 // 10MB
    }
})

middleware.js

multer는 올리는 file size의 크기를 제한할 수 있다.

위처럼 객체 안에 limits를 넣어주면 된ㄷㅏ.

 

import express from "express";
import { watch, getEdit, postEdit, getUpload, postUpload, deleteVideo } from "../controllers/videocontroller";
import { protectorMiddleware, uploadVideo } from "../middleware";

const videoRouter = express.Router();

videoRouter.route("/:id([0-9a-f]{24})").get(watch);
videoRouter.route("/:id([0-9a-f]{24})/edit").all(protectorMiddleware).get(getEdit).post(postEdit);
videoRouter.route("/:id([0-9a-f]{24})/delete").all(protectorMiddleware).get(deleteVideo);
videoRouter.route("/upload").all(protectorMiddleware).get(getUpload).post(uploadVideo.single('video'), postUpload);

export default videoRouter;

videoRouter.js

videoRouter.js의 /upload url에 middleware를 추가했고,

const videoSchema = new mongoose.Schema({
    videoURL: {type: String},
    title: { type: String, required: true, trim: true, maxLength: 80},
    description: { type: String, required: true, trim: true, minLength: 20},
    createdAt: {type: Date, required: true, default: Date.now},
    hashtags: [{type: String, required: true, trim: true} ],
    meta:{
        views: {type: Number, default: 0},
        rating: {type: Number, default: 0},
    }
})

Video.js

videoSchema에 videoURL도 추가해주었다.

export const postUpload = async(req, res) => {
    const {title, description, hashtags} = req.body;
    const { path: videoURL } = req.file;

    try{
				videoURL,
        await Video.create({
        title,
        description,
        hashtags: Video.formatHashtag(hashtags),
        });
        return res.redirect('/');
    }catch(err){
        return res.status(400).render("videos/upload",{pageTitle: 'Upload Video', errorMessage: err._message});
    }
};

videocontroller.js

마지막으로 videocontroller.js에서 req.file에서 path를 받아와서 videoURL로 설정해줬다. (ES6!!)

 

맘에 드는 영상을 골라서,

Upload 해줬더니

잘 반영되었다!

db를 확인해보면 videoURL 역시 잘 들어가있는 것을 확인할 수 있다.

 

extends ../base.pug
    
block content 
    video(src=`/${video.videoURL}`, controls)
    div
        p=video.description
        small=video.createdAt
    a(href=`${video.id}/edit`) Edit Video →
    br
    a(href=`${video.id}/delete`) Delete Video →

watch.pug

watch.pug에 video 태그를 넣어주고, videoURL로부터 받아와서 src를 설정해주면!

실제로 비디오를 확인할 수 있다!!

 

#8.10 User Profile

이번에는 user의 profile을 확인할 수 있는 페이지를 만들어보자.

이미 너무 많이 해온 작업이다. Router, controller, pug를 차례로 수정해주면 된다.

import express from "express";
import { logout, see, startGithubLogin, finishGithubLogin, postEdit, getEdit, getChangePassword, postChangePassword } from "../controllers/usercontroller";
import { protectorMiddleware, publicOnlyMiddleware, uploadAvatar } from "../middleware";

const userRouter = express.Router();

userRouter.route("/edit").all(protectorMiddleware).get(getEdit).post(uploadAvatar.single('avatar') ,postEdit);
userRouter.get("/logout", protectorMiddleware, logout);
userRouter.get("/github/start", publicOnlyMiddleware, startGithubLogin);
userRouter.get("/github/finish", publicOnlyMiddleware, finishGithubLogin);
userRouter.route("/change-password").all(protectorMiddleware).get(getChangePassword).post(postChangePassword);
userRouter.get("/:id", see);

export default userRouter;

userRouter.js

export const see = async (req, res) => {
    const { id } = req.params;
    const user = await User.findById(id);
    if(!user){
        return res.status(404).render("404",{pageTitle: 'User not found...'})
    }
    return res.render("users/profile", {pageTitle:`${user.name}'s Profile`, user:user});
};

usercontroller.js

복습! pug에서 user에 접근하기 위해서 render에 인자로 넣어준다. (res.locals에서 안 가져와도 됨.)

extends ../base.pug

block content

users/profile.pug

 

다 만들어줬는데 캡쳐를 깜빡함! ㅋㅋ

 

 

이건 404오류 페이지. user, videos 모두 삭제해줬다.

 

 

#8.11, #8.12 Video Owner

이제 Video와 User 스키마를 연결시켜보자!

const videoSchema = new mongoose.Schema({
    videoURL: {type: String},
    title: { type: String, required: true, trim: true, maxLength: 80},
    description: { type: String, required: true, trim: true, minLength: 20},
    createdAt: {type: Date, required: true, default: Date.now},
    hashtags: [{type: String, required: true, trim: true} ],
    meta:{
        views: {type: Number, default: 0},
        rating: {type: Number, default: 0},
    },
    owner: {type: mongoose.Schema.Types.ObjectID, required: true, ref: "User"},
})

Video.js

videoSchema에 다음과 같이 owner를 넣어줬다. 우리는 userSchema의 _id를 가져올 것이기 때문에, type은 mongoose.Schema.types.ObjectID로 해준다. 그리고 ref에 ‘User’를 추가해준다. 이는 videoSchema가 userSchema의 foreign key가 되는 것임을 알려주는 것이다.

export const postUpload = async(req, res) => {
    const { user: _id } = req.session;
    const { title, description, hashtags } = req.body;
    const { path : videoURL } = req.file;
    
    try{
        await Video.create({
        videoURL,
        title,
        description,
        hashtags: Video.formatHashtag(hashtags),
        owner: _id,
        });
        return res.redirect('/');
    }catch(err){
        return res.status(400).render("videos/upload",{pageTitle: 'Upload Video', errorMessage: err._message});
    }
};

videocontroller.js

videocontroller.js에서는 req.session으로부터 user의 _id를 받아오고, 이를 Video의 owner 속성에 넣어준다.

비디오를 하나 업로드했더니

owner 값이 제대로 들어간 것을 확인할 수 있었다.

 

추가로, 위에 보이는 Edit Video, Delete Video 버튼을 video의 owner만 볼 수 있도록 바꿔보자.

extends ../base.pug
    
block content 
    video(src=`/${video.videoURL}`, controls)
    div
        p=video.description
        small=video.createdAt
    div 
        
    if(String(video.owner) === String(loggedInUser._id) )
        a(href=`${video.id}/edit`) Edit Video →
        br
        a(href=`${video.id}/delete`) Delete Video →

watch.pug

pug에 video.owner와 loggedInUser._id를 비교하는 if문을 추가해줬다.

시크릿모드로 들어가봤더니 위의 두 버튼이 보이지 않는 것을 확인할 수 있었다.

 

이제 해당 watch.pug에 owner의 name을 작성해보자.

export const watch = async(req, res) => {
    const { id } = req.params;
    const video = await Video.findById(id);
    const owner = await User.findById(video.owner);
    if(!video){
        return res.status(404).render("404", {pageTitle: "Video Not Found."});
    }
    return res.render("videos/watch", {pageTitle: video.title, video, owner});
};

videocontroller.js

id를 가지고 User를 받아온 뒤, 해당 owner를 watch.pug로 보내줬다.

extends ../base.pug
    
block content 
    video(src=`/${video.videoURL}`, controls)
    div
        p=video.description
        small=video.createdAt
    div 
        small=owner.name 
        
    if(String(video.owner) === String(loggedInUser._id) )
        a(href=`${video.id}/edit`) Edit Video →
        br
        a(href=`${video.id}/delete`) Delete Video →

 

그다음에 owner.name을 넣어주면

성공!

 

근데 위의 코드는 최선의 선택은 아니다. findById를 두번이나 호출해서 db에 두번이나 접근하기 때문이다. 이는 비효율적이다.

따라서 우리는 populate() 를 이용하여 코드를 조금 더 간결하게 만들어줄 것이다.

위에서 우리는 Video 스키마의 owner가 User 스키마를 ref하는 것을 확인했다. populate는 이렇게 ref하고 있는 스키마의 정보를 가져오는 것이라고 보면 된다.

export const watch = async(req, res) => {
    const { id } = req.params;
    const video = await Video.findById(id).populate('owner');
    if(!video){
        return res.status(404).render("404", {pageTitle: "Video Not Found."});
    }
    return res.render("videos/watch", {pageTitle: video.title, video});
};

videocontroller.js

video 변수를 가져올 때 populate를 이용해서 User 스키마의 데이터를 owner에 넣어준다.

 

extends ../base.pug
    
block content 
    video(src=`/${video.videoURL}`, controls)
    div
        p=video.description
        small=video.createdAt
    div 
        small=`Uploaded by ${video.owner.name}` 

    if(String(video.owner._id) === String(loggedInUser._id) )
        a(href=`${video.id}/edit`) Edit Video →
        br
        a(href=`${video.id}/delete`) Delete Video →

watch.pug

템플릿에서는 video.owner.*로 User 스키마의 속성에 접근할 수 있다.

 

console.log(video)로 확인해보니, owner에 user 정보가 모두 들어가 있는 것을 확인할 수 있었다. 이제는 _id 뿐만 아니라 name까지 가져올 수 있다.

잘 적용된 것을 확인 가능하다.

extends ../base.pug
    
block content 
    video(src=`/${video.videoURL}`, controls)
    div
        p=video.description
        small=video.createdAt
    div 
        small=`Uploaded by `
        a(href=`/users/${video.owner._id}`)=video.owner.name

    if(String(video.owner._id) === String(loggedInUser._id) )
        a(href=`${video.id}/edit`) Edit Video →
        br
        a(href=`${video.id}/delete`) Delete Video →

watch.pug

a 태그를 넣어서 owner의 name을 클릭하면 profile로 옮겨가도록 설정해줬다.

 

이제 특정 사람이 업로드한 video들을 모두 확인할 수 있도록 만들어보자.

export const see = async (req, res) => {
    const { id } = req.params;
    const user = await User.findById(id);
    if(!user){
        return res.status(404).render("404",{pageTitle: 'User not found...'});
    }

    const videos = await Video.find({owner : user._id});
    return res.render("users/profile", {
        pageTitle:`${user.name}'s Profile`, 
        user,
        videos,
    });
};

usercontroller.js

Video.find를 활용해서 owner가 user._id와 동일한 모든 video를 videos에 배열로 저장한다.

그리고 videos를 profile.pug로 보내주고,

extends ../base.pug
include ../mixins/video

block content 
    each i in videos 
        +video(i)

profile.pug

profile.pug에서는 home.pug에서 사용한 것처럼 videos 배열을 돌면서 출력해준다.

성공!

 

그런데 이 방법은 코드가 좀 길다. 따라서 다음 챕터에서 코드를 더 줄여볼 것이다.

 

 

#8.13 User’s videos

이제는 User 스키마에 videos 속성을 넣어주자! 여러개가 될 수 있으므로 배열을 선언해준다.

const userSchema = new mongoose.Schema({
    avatarURL : {type: String},
    name: { type: String, required: true},
    socialOnly : {type: Boolean, default : false},
    username: { type: String, required: true, unique: true},
    password: {type: String},
    email: { type: String, required: true, unique: true},
    location: {type: String},
    videos: [{type: mongoose.Schema.Types.ObjectID, ref: "Video"}],
})

User.js

export const postUpload = async(req, res) => {
    const { user: _id } = req.session;
    const { title, description, hashtags } = req.body;
    const { path : videoURL } = req.file;
    
    try{
        const newVideo = await Video.create({
        videoURL,
        title,
        description,
        hashtags: Video.formatHashtag(hashtags),
        owner: _id,
        });
        const user = User.findById(_id);
        user.videos.push(newVideo._id);
        user.save();
        return res.redirect('/');
    }catch(err){
        return res.status(400).render("videos/upload",{pageTitle: 'Upload Video', errorMessage: err._message});
    }
};

usercontroller.js

usercontroller.js의 postUpload 함수에서, _id로 user를 찾아서, user의 videos 배열에 newVideo의 _id를 push해줬다!

 

영상 하나를 upload해서 db의 user를 확인해봤더니 videos에 추가된 것을 확인할 수 있었다.

 

추가로 profile에서 확인하는 방법도 relationship을 이용하도록 바꿔보자.

export const see = async (req, res) => {
    const { id } = req.params;
    const user = await User.findById(id).populate('videos');
    if(!user){
        return res.status(404).render("404",{pageTitle: 'User not found...'});
    }

    return res.render("users/profile", {
        pageTitle:`${user.name}'s Profile`, 
        user,
    });
};

usercontroller.js

User 스키마에서 id로 찾아서 가져올 때, videos 속성에 populate를 사용하여 Video 스키마의 데이터를 저장할 수 있도록 만들어줬다.

extends ../base.pug
include ../mixins/video

block content 
    each i in user.videos 
        +video(i)

 

이렇게 되면 user 변수만 보내줘도 해당 변수의 videos 속성에 접근하면 해당 owner가 upload한 모든 videos를 확인할 수 있게 된다.

 

#8.14 BugFix

지금까지의 만드는 과정으로 인해서 생긴 bug를 처리해주자.

  1. owner가 아닌데 video를 edit, delete할 수 있는 문제
  2. password hashing

 

먼저 첫번째 버그부터 살펴보자.

우리는 이전에 watch.pug를 수정해서, owner가 아니면 edit video, delete video 버튼이 보이지 않게 만들어줬다.

그런데 이 방법은 버튼만 보이지 않게 하는 것이다. owner가 아닌 사람도 url만 잘 입력하면 해당 페이지에 접근할 수 있다.

 

 

export const getEdit = async(req, res) => {
    const { id } = req.params;
    const video = await Video.findById(id);
    const { user: {_id} } = req.session;
    if(!video){
        return res.status(404).render("404", {pageTitle: "Video Not Found."});
    }

    if(String(video.owner) !== String(_id)){
        return res.status(403).redirect('/');
    }

    return res.render("videos/edit", {pageTitle: `Editing '${video.title}'`,video});
};

videocontroller.js

export const postEdit = async(req, res) => {
    const { id } = req.params;
    const {title, description, hashtags} = req.body;
    const { user: {_id} } = req.session;
    const video = await Video.exists({_id: id});
    if(!video){
        return res.status(404).render("404", {pageTitle: "Video Not Found."});
    }

    if(String(video.owner) !== String(_id)){
        return res.status(403).redirect('/');
    }
    
    await Video.findByIdAndUpdate(id,{
        title,
        description,
        hashtags: Video.formatHashtag(hashtags),
    })

    return res.redirect(`/videos/${id}`);
};

videocontroller.js

export const deleteVideo = async(req, res) => {
    const { id } = req.params;
    const { user: {_id} } = req.session;
    const video = await Video.findById(id);
    
    if(String(video.owner) !== String(_id)){
        return res.status(403).redirect('/');
    }
    
    await Video.findByIdAndDelete(id)
    return res.redirect('/');
}

videocontroller.js

따라서 videocontroller.js의 getEdit, postEdit, deleteVideo 함수를 모두 수정해줬다. video 스키마의 owner와 세션의 _id가 동일한지 확인하는 부분이다.

 

두번째 버그는 password hashing 문제이다.

userSchema.pre("save", async function () {
    this.password = await bcrypt.hash(this.password, 5);
})

User.js

우리는 User.js에 pre 함수를 선언해서, save 전에 항상 password를 해싱하도록 만들었다.

근데 이렇게 해버리면 video를 upload해서 user의 videos 속성을 업데이트 할 때에도 기존의 password를 해싱하게 된다. 해싱된 값을 또 해싱하는 것이기 때문에, 이후로 유저가 로그인을 할 수 없게 된다.

userSchema.pre("save", async function (){
    if(this.isModified("password")){
        this.password = await bcrypt.hash(this.password, 5);
    }
})

User.js

해결방법은 위와 같다. isModified 옵션을 줘서, password가 수정되었을 때만 hashing 해주면 된다.

 

 


 

** 본 글은 노마드 코더의 ‘유튜브 클론코딩’ 강의를 참조하여 작성하였습니다. **

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤