{
"name": "nodebird",
"version": "0.0.1",
"description": "익스프레스로 만드는 SNS 서비스",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "yeobi01",
"license": "MIT"
}
$ npm i sequelize mysql2 sequelize-cli
$ npx sequelize init
$ npm i express cookie-parser express-session morgan multer dotenv nunjucks
$ npm i -D nodemon
const express = require('express');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const path = require('path');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
dotenv.config(); // process.env
const pageRouter = require('./routes/page');
const app = express();
app.set('port', process.env.PORT || 8001);
app.set('view engine', 'html');
nunjucks.configure('views', {
express: app,
watch: true,
});
app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session( {
resave: false,
saveUninitialized: false,
secret: process.env.COKKIE_SECRET,
cookie: {
httpOnly: true,
secure: false,
}
}))
app.use('/', pageRouter);
app.use((req, res, next) => { // 404 NOT FOUND
const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
error.status = 404;
next(error);
});
app.use((err, req, res, next) => {
res.locals.message = err.message;
res.locals.error = process.env.NODE_ENV !== 'production' ? err : {}; // 에러 로그를 서비스한테 넘기기
res.status(err.status || 500);
res.render('error');
});
app.listen(app.get('port'), () => {
console.log(app.get('port'), '번 포트에서 대기 중');
});
routes/page.js
const express = require('express');
const router = express.Router();
const { renderJoin, renderMain, renderProfile } = require('../controllers/page');
router.use((req, res, next) => {
res.locals.user = null;
res.locals.followerCount = 0;
res.locals.followingCount = 0;
res.locals.followingIdList = [];
next();
});
router.get('/profile', renderProfile);
router.get('/join', renderJoin);
router.get('/', renderMain);
module.exports = router;
controllers/page.js
exports.renderProfile = (req, res, next) => {
res.render('profile', { title: '내 정보 - NodeBird'});
};
exports.renderJoin = (req, res, next) => {
res.render('join', { title: '회원 가입 - NodeBird'});
};
exports.renderMain = (req, res, next) => {
res.render('main', {
title: 'NodeBird',
twits: [],
});
};
// 컨트롤러 : 서비스를 호출함
// 라우터 -> 컨트롤러(요청, 응답 안다) -> 서비스(요청, 응답 모른다)
models/user.js
const Sequelize = require('sequelize');
class User extends Sequelize.Model {
static initiate(sequelize){
User.init({
email: {
type: Sequelize.STRING(40),
allowNull: true,
unique: true,
},
nick: {
type: Sequelize.STRING(15),
allowNull: false,
},
password: {
type: Sequelize.STRING(100),
allowNull: true,
},
provider: {
type: Sequelize.ENUM('local', 'kakao'),
allowNull: false,
defaultValue: 'local'
},
snsId: {
type: Sequelize.STRING(30),
allowNull: true,
}
}, {
sequelize,
timestamps: true, // createAt, updateAt
underscored: false,
modelName: 'User',
tableName: 'users',
paranoid: true, // deleteAt 유저 삭제일 -> soft delete
charset: 'utf8',
collate: 'utf8_general_ci',
})
}
static associate(db) {
db.User.hasMany(db.Post);
db.User.belongsToMany(db.User, { // 팔로워
foreignKey: 'followingId',
as: 'Followers',
through: 'Follow'
})
db.User.belongsToMany(db.User, { // 팔로잉
foreignKey: 'followerId',
as: 'Followings',
through: 'Follow'
})
}
}
module.exports = User;
models/post.js
const Sequelize = require('sequelize');
class Post extends Sequelize.Model {
static initiate(sequelize){
Post.init({
content: {
type: Sequelize.STRING(40),
allowNull: false,
},
img: {
type: Sequelize.STRING(200),
allowNull: true,
}
}, {
sequelize,
timestamps: true,
underscored: false,
paranoid: false,
modelName: 'Post',
tableName: 'posts',
charset: 'utf8mb4',
collate: 'utf8mb4_general_ci'
})
}
static associate(db){
db.Post.belongsTo(db.User);
db.Post.belongsToMany(db.Hashtag, { through: 'PostHashtag' });
}
}
module.exports = Post;
models/hashtag.js
const Sequelize = require('sequelize');
class Hashtag extends Sequelize.Model {
static initiate(sequelize){
Hashtag.init({
title: {
type: Sequelize.STRING(15),
allowNull: false,
unique: true,
}
}, {
sequelize,
timestamps: true,
underscored: false,
paranoid: false,
modelName: 'Hashtag',
tableName: 'hashtags',
charset: 'utf8mb4',
collate: 'utf8mb4_general_ci'
})
}
static associate(db){
db.Hashtag.belongsToMany(db.Post, { through: 'PostHashtag'});
}
}
module.exports = Hashtag;
const User = require('./user');
const Post = require('./post');
const Hashtag = require('./hashtag');
const db = {};
...
db.User = User;
db.Post = Post;
db.Hashtag = Hashtag;
User.initiate(sequelize);
Post.initiate(sequelize);
Hashtag.initiate(sequelize);
User.associate(db);
Post.associate(db);
Hashtag.associate(db);
module.exports = db;
const fs = require('fs');
const path = require('path');
const db = {};
...
const basename = path.basename(__filename);
fs.readdirSync(__dirname)
.filter(file => {
return file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js';
})
.forEach((file) => {
const modle = require(path.join(__dirname, file));
console.log(file, model.name);
db[model.name] = model;
model.initiate(sequelize);
});
Object.keys(db).forEach(modelName => {
if(db[modelName].associate){
db[modelName].associate(db);
}
})
module.exports = db;
sequelize.sync()
.then(() => {
console.log('데이터베이스 연결 성공')
})
.catch((err) => {
console.error(err);
})
$ npm install passport passport-local passport-kakao express-session
app.use(passport.initialize());
app.use(passport.session());
passportConfig();
const passport = require('passport');
...
const passportConfig = require('./passport');
const app = express();
passportConfig(); // 패스포트 설정
...
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
cookie: {
httpOnly: true,
secure: false,
},
}));
app.use(passport.initialize());
app.use(passport.session()); // session 미들웨어 코드 뒤에 적용
/passport/index.js
const passport = require('passport');
const local = require('./localStrategy');
const kakao = require('./kakaoStrategy');
const User = require('../models/user');
module.exports = () => {
passport.serializeUser((user, done) => { // user === exUser
done(null, user.id); // user id만 추출
});
passport.deserializeUser((id, done) => {
User.findOne({
where: { id },
include: [
{
model: User,
attributes: ['id', 'nick'],
as: 'Followers',
}, // 팔로잉
{
model: User,
attributes: ['id', 'nick'],
as: 'Followings',
}, // 팔로워
]
})
.then((user) => done(null, user))
.catch(err => done(err));
});
local();
kakao();
};
done(null, user)
에서 user 객체를 전달받아 세션(req.session.passport.user)에 저장done(null, user)
을 통해 req.user에 저장/passport/localStrategy.js
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const bcrypt = require('bcrypt');
const User = require('../models/user');
module.exports = () => {
passport.use(new LocalStrategy({
usernameField: 'email', // req.body.email
passwordField: 'password', // req.body.password
// session: true, // 세션에 저장 여부
passReqToCallback: false
}, async(email, password, done) => {
try {
const exUser = await User.findOne({ where: { email }});
if(exUser) {
const result = await bcrypt.compare(password, exUser.password);
if(result){
done(null, exUser);
} else{
done(null, false, { message: '비밀번호 일치하지 않습니다.' })
}
} else{
done(null, false, { message: '가입되지 않은 회원입니다.' })
}
} catch (error) {
console.error(error);
done(error);
}
}));
};
{ id: 'zerocho', pw: 'pswd' }
로 전송되면, 콜백 함수의 id와 password 값이 각각 zerocho, pswd가 됨/passport/kakaoStrategy.js
const passport = require('passport');
const { Strategy: KakaoStrategy } = require('passport-kakao');
const User = require('../models/user');
module.exports = () => {
passport.use(new KakaoStrategy({
clientID: process.env.KAKAO_ID,
callbackURL: '/auth/kakao/callback',
}, async (accessToken, refreshToken, profile, done) => {
console.log('profile', profile);
try {
const exUser = await User.findOne({
where: { snsId: profile.id, provider: 'kakao' }
})
if(exUser){
done(null, exUser);
} else{
const newUser = await User.create({
email: profile._json?.kakao_account?.email,
nick: profile.displayName,
snsId: profile.id,
provider: 'kakao',
})
done(null, newUser);
}
} catch (error){
console.error(error);
done(error);
}
}));
};
/routes/auth.js
const express = require('express');
const passport = require('passport');
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const { join, login, logout } = require('../controllers/auth');
const router = express.Router();
// POST /auth/join
router.post('/join', isNotLoggedIn, join);
// POST /auth/login
router.post('/login', isNotLoggedIn, login);
// GET /auth/logout
router.get('/logout', isLoggedIn, logout);
// /auth/kakao
router.get('/kakao', passport.authenticate('kakao'));
// /auth/kakao/callback
router.get('/kakao/callback', passport.authenticate('kakao', {
failureRedirect: '/?loginError=카카오로그인 실패',
}), (req, res) => {
res.redirect('/');
});
module.exports = router;
/controllers/auth.js
const User = require('../models/user');
const bcrypt = require('bcrypt');
const passport = require('passport');
exports.join = async (req, res, body) => {
const { nick, email, password } = req.body;
try {
const exUser = await User.findOne({ where: { email }});
if(exUser) {
return res.redirect(`/join?error=exist`);
}
const hash = await bcrypt.hash(password, 12);
await User.create({
email,
nick,
password: hash,
});
return res.redirect('/');
} catch(error) {
console.error(error);
next(error);
}
}
exports.login = (req, res, next) => {
passport.authenticate('local', (authError, user, info) => {
if(authError){ // 서버실패
console.error(authError);
return next(authError);
}
if(!user){ // 로직실패
return res.redirect(`/?loginError=${info.message}`);
}
return req.login(user, (loginError) => { // 로그인성공
if(loginError){
console.error(loginError);
return next(loginError);
}
return res.redirect('/');
});
})(req, res, next);
}
exports.logout = (req, res, next) => {
req.logout(() => {
res.redirect('/');
})
}
/middlewares/index.js
exports.isLoggedIn = (req, res, next) => {
if(req.isAuthenticated()) { // 패스포트 통해서 로그인 했는지
next();
} else{
res.status(403).send('로그인 필요');
}
};
exports.isNotLoggedIn = (req, res, next) => {
if(!req.isAuthenticated()) { // 패스포트 통해서 로그인 안했는지
next();
} else{
const message = encodeURIComponent('로그인한 상태입니다.');
res.redirect(`/?error=${message}`);
}
}
routes/post.js
const express = require('express');
const router = express.Router();
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const fs = require('fs');
const multer = require('multer');
const path = require('path');
const { afterUploadImage, uploadPost } = require('../controllers/post');
try{
fs.readdirSync('uploads');
} catch(error){
fs.mkdirSync('uploads');
}
const upload = multer({
storage: multer.diskStorage({
destination(req, file, cb){
cb(null, 'uploads/');
},
filename(req, file, cb){
const ext = path.extname(file.originalname); // 이미지.png -> 이미지1234321.png
cb(null, path.basename(file.originalname, ext) + Date.now() + ext);
}
}),
limits: { fileSize: 5 * 1024 * 1024 },
});
router.post('/img', isLoggedIn, upload.single('img'), afterUploadImage);
const upload2 = multer();
router.post('/', isLoggedIn, upload2.none(), uploadPost);
module.exports = router;
controllers/post.js
const Post = require('../models/post');
const User = require('../models/user');
const Hashtag = require('../models/hashtag');
exports.afterUploadImage = (req, res) => {
console.log(req.file);
res.json({ url: `/img/${req.file.filename}` });
};
exports.uploadPost = async (req, res, next) => {
try{
const post = await Post.create({
content: req.body.content,
img: req.body.url,
UserId: req.user.id,
});
// 해쉬태그를 뽑아내기 위해선 정규표현식을 공부해야함
// /#[^\s#]*/g 이 해쉬태그의 정규표현식 | 샵과 공백이 아닌 나머지를 뜻함
const hashtags = req.body.content.match(/#[^\s#]*/g);
if(hashtags) {
const result = await Promise.all(hashtags.map((tag) => {
return Hashtag.findOrCreate({
where: { title: tag.slice(1).toLowerCase() }
});
}));
await post.addHashtags(result.map(r => r[0]));
}
res.redirect('/');
} catch (error) {
console.error(error);
next(error);
}
};
controllers/page.js의 renderMain
exports.renderMain = async (req, res, next) => {
try {
const posts = await Post.findAll({
include: {
model: User,
attributes: ['id', 'nick'],
}
,
order: [['createdAt', 'DESC']]
})
res.render('main', {
title: 'NodeBird',
twits: posts,
});
} catch(error){
console.error(error);
}
};
routes/user.js
const express = require('express');
const router = express.Router();
const { isLoggedIn } = require('../middlewares');
const { follow, unfollow } = require('../controllers/user');
router.post('/:id/follow', isLoggedIn, follow);
router.post('/:id/unfollow', isLoggedIn, unfollow);
module.exports = router;
controllers/user.js
const User = require('../models/user');
exports.follow = async (req, res, next) => {
try {
const user = await User.findOne({ where: { id: req.user.id }});
if(user){
await user.addFollowing(parseInt(req.params.id, 10));
res.send('success');
} else{
res.status(404).send('no user');
}
} catch(error){
console.error(error);
next(error);
}
}
routes/page.js
const express = require('express');
const router = express.Router();
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const { renderJoin, updateProfile, renderMain, renderProfile, renderHashtag } = require('../controllers/page');
router.use((req, res, next) => {
res.locals.user = req.user;
res.locals.followerCount = req.user?.Followers?.length || 0;
res.locals.followingCount = req.user?.Followings?.length || 0;
res.locals.followingIdList = req.user?.Following?.map(f => f.id) || [];
res.locals.likeCount = req.user?.Likes?.length || 0;
next();
});
...
router.get('/hashtag', renderHashtag); // hashtag?hashtag=고양이
module.exports = router;
router.get('/hashtag', renderHashtag);
추가하기controllers/page.js의 renderHashtag
exports.renderHashtag = async (req, res, next) => {
const query = req.query.hashtag;
if(!query) {
return res.redirect('/');
}
try{
const hashtag = await Hashtag.findOne({ where: { title: query }});
let posts = [];
if(hashtag) {
posts = await hashtag.getPosts({
include: [{ model: User, attributes: ['id', 'nick']}],
order: [['createdAt', 'DESC']]
});
}
res.render('main', {
title: `${query} | NodeBird`,
twits: posts, // 프론트에서 받아서 처리
})
} catch(error){
console.error(error);
next(error);
}
}
팔로우취소
버튼 만들기 axois.post('/user/${userId}/unfollow')
로 post 요청 보내기router.post('/:id/unfollow', isLoggedIn, unfollow);
추가하기controllers/user.js
exports.unfollow = async (req, res, next) => {
try {
const user = await User.findOne({ where: { id: req.user.id }});
if(user){
await user.removeFollowing(parseInt(req.params.id, 10));
res.send('success');
} else{
res.status(404).send('no user');
}
} catch(error){
console.error(error);
next(error);
}
}
<a id="profile-update" href="/update" class="btn">프로필 수정</a>
추가하기router.get('/update', isLoggedIn, updateProfile);
추가하기exports.updateProfile = (req, res, next) => { res.render('update', { title: '정보 수정 - NodeBird'}); }
추가하기views/update.html
{% extends 'layout.html' %}
{% block content %}
<div class="timeline">
<form id="update-form" action="/profile/update" method="post">
<div class="input-group">
<label for="update-email">이메일</label>
<input id="update-email" type="email" name="email"></div>
<div class="input-group">
<label for="update-nick">닉네임</label>
<input id="update-nick" type="text" name="nick"></div>
<div class="input-group">
<label for="update-password">비밀번호</label>
<input id="update-password" type="password" name="password">
</div>
<button id="update-btn" type="submit" class="btn">정보수정</button>
</form>
</div>
{% endblock %}
/profile/update
를 요청한다.routes/profile.js
const express = require('express');
const { isLoggedIn, isNotLoggedIn } = require('../middlewares');
const { update } = require('../controllers/profile');
const router = express.Router();
// POST /profile/update
router.post('/update', isLoggedIn, update);
module.exports = router;
controllers/profile.js
const User = require('../models/user');
const bcrypt = require('bcrypt');
const passport = require('passport');
exports.update = async (req, res, body) => {
const { nick, email, password } = req.body;
try {
const exUser = await User.findOne({ where: { email }});
if(exUser) {
const hash = await bcrypt.hash(password,12);
await exUser.update({
email: email,
nick: nick,
password: hash,
})
return res.redirect('/');
} else {
}
} catch(error) {
console.error(error);
next(error);
}
}
게시물 삭제
버튼 만들기 axios.post('/post/${postId}/delete')
로 post 요청 보내기router.post('/:id/delete', isLoggedIn, deletePost);
추가하기controllers/post.js
exports.deletePost = async (req, res, next) => {
try {
console.log(req.url);
let idx = req.url.indexOf('/', 1);
let postId = req.url.substring(1, idx);
await Post.destroy({ where: { id: postId }});
res.redirect('/');
} catch(error){
console.error(error);
next(error);
}
}
window.location.replace('/content?id=${userId}');
로 사용자 아이디를 쿼리스트링으로 포함하여 redirect 해주기router.get('/content', renderContent);
추가하기controllers/page.js
exports.renderContent = async (req, res, next) => {
const query = req.query.id;
if(!query) {
return res.redirect('/');
}
try{
const user = await User.findOne({ where: { id: query }});
let posts = [];
if(user) {
posts = await user.getPosts({
include: [{ model: User, attributes: ['id', 'nick']}],
order: [['createdAt', 'DESC']]
});
}
res.render('main', {
title: `${user.nick} | NodeBird`,
twits: posts,
})
} catch(error){
console.error(error);
next(error);
}
}