'use strict'; const express = require('express'); const morgan = require('morgan'); // logging middleware const { check, validationResult, oneOf } = require('express-validator'); // validation middleware const cors = require('cors'); const forumDao = require('./dao-forum'); const dayjs = require('dayjs'); const app = express(); app.use(morgan('dev')); app.use(express.json()); const corsOptions = { origin: 'http://localhost:5173', credentials: true, }; app.use(cors(corsOptions)); /* Passport */ const passport = require('passport'); // authentication middleware const LocalStrategy = require('passport-local'); // authentication strategy (username and password) const base32 = require('thirty-two'); const TotpStrategy = require('passport-totp').Strategy; // totp passport.use(new LocalStrategy(async function verify(username, password, callback) { const user = await forumDao.getUser(username, password) if(!user) return callback(null, false, 'Incorrect username or password'); return callback(null, user); // NOTE: user info in the session (all fields returned by userDao.getUser, i.e, id, username, name) })); passport.serializeUser(function (user, callback) { callback(null, user); }); passport.deserializeUser(function (user, callback) { return callback(null, user); }); const session = require('express-session'); /* The secret field is used to hash the session with HMAC */ app.use(session({ secret: "very l0ng s3cr3t used h4sh with HM4C the sessi0n", resave: false, saveUninitialized: false, })); app.use(passport.authenticate('session')); /* If user does not exist the db will simply throw an error and everything will be fine */ passport.use(new TotpStrategy( async function (user, done) { const secret = await forumDao.getUserSecret(user.id); if(!secret) return done(null, false, {message: "TOTP is not enabled for this account"}); return done(null, base32.decode(secret), 30); }) ) const isLoggedIn = (req, res, next) => { if(req.isAuthenticated()) { return next(); } return res.status(401).json({error: 'Not authorized'}); } /* This one is used because we still need to serve even anonymous user in some queries */ /* If Authenticated is True otherwise is False and therefore anonymous */ const isLoggedInOrAnon = (req, res) => { if(req.isAuthenticated()) { return true; } return false; } function isTotp(req, res, next) { if(req.session.method === 'totp') return next(); return res.status(401).json({ error: 'Missing TOTP authentication'}); } // Created to check this not as a middleware function isLoggedInTotp(req, res){ if(req.session.method === 'totp') return true; return false; } const maxTitleLength = 160; const maxCommentLength = 1000; const maxTextLength = 1500; const errorFormatter = ({ location, msg, param, value, nestedErrors }) => { return `${location}[${param}]: ${msg}`; }; app.get('/api/posts', async (req, res) => { const isLogged = isLoggedInOrAnon(req); try{ let posts = await forumDao.listPosts(); posts = posts.map( async (p) => { let comments; /* If authorid is null the Author of the comment is not searched at all The field author exists only if there is a valid author id */ if( isLogged == true ){ comments = await forumDao.listCommentsOfPost(p.id); comments = comments.map( async (c) => { /* numInterested and isInterested should be visible only to authenticated users. */ /* isInterested should be true ONLY for the exact user that has marked the comment interesting */ c.numInterested = await forumDao.getNumInterested(c.id); c.isInterested = await forumDao.isUserInterested(req.user.id, c.id); if(c.authorid != null){ c.author = await forumDao.getAuthorOfComment(c.id); } return c; }); } else{ comments = await forumDao.listAnonCommentsOfPost(p.id); } p.comments = await Promise.all(comments); p.author = await forumDao.getAuthorOfPost(p.id); p.numComments = await forumDao.getNumCommentsOfPost(p.id); return p; }); const postsWithComments = await Promise.all(posts); res.json(postsWithComments); } catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); /* Create a new post (Only authenticated user) */ /* INPUT: Title, Text, Max number of comments */ app.post('/api/posts', isLoggedIn, [check('title').isLength({min:1, max: maxTitleLength}).withMessage(`The text of the post MUST be >=1 and <= ${maxTitleLength}`), check('text').isLength({min:1, max: maxTextLength}).withMessage(`The title of the post MUST be >=1 and <= ${maxTextLength}`), check('max').isInt({min:-1}).withMessage("The max number of comments should be specificed and >= -1")],async (req, res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } const post = {title: req.body.title, text: req.body.text, publication: dayjs().format("YYYY-MM-DD HH:mm:ss"), maxcomments: req.body.max, authorid: req.user.id}; try{ const result = await forumDao.createPost(post); res.status(201).json(result); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); /* Create a new comment related to a post (Anyone) */ /* INPUT: Post ID, Text*/ app.post('/api/posts/:id/comments', [ check('id').isInt({min: 1}).withMessage("Post ID MUST be an integer >= 1"), check('text').isLength({min: 1, max: maxCommentLength}).withMessage(`The text of the comment MUST be >=1 and <= ${maxCommentLength}`) ],async (req,res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } const comment = {text: req.body.text, publication: dayjs().format("YYYY-MM-DD HH:mm:ss") ,authorid: req.user == null ? null : req.user.id , postid: req.params.id}; try{ const checkPost = await forumDao.getPost(req.params.id); const numcomments = await forumDao.getNumCommentsOfPost(req.params.id); if(checkPost.maxcomments != -1 && numcomments == checkPost.maxcomments){ return res.status(400).json({error: "Reached max number of comments"}); } const result = await forumDao.createComment(comment); res.status(201).json(result); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); /* Toggle true/false Interesting flag on comment (Only authenticated and owner) */ //Handled as POST/DELETE requests because we are creating a new record in a table. app.post('/api/posts/:id/comments/:commentId/interest', isLoggedIn, [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"), check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1")], async (req,res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } try{ const result = await forumDao.setInteresting(req.user.id,req.params.commentId); res.status(201).json({}); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); app.delete('/api/posts/:id/comments/:commentId/interest', [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"), check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1")], async (req,res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } try{ const result = await forumDao.deleteInteresting(req.user.id,req.params.commentId); res.status(201).json(result); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); /* Edit comment (Only authenticated and owner) */ /* INPUT: Text */ app.patch('/api/posts/:id/comments/:commentId', isLoggedIn,[check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"), check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1"), check('text').isLength({min: 1, max: maxCommentLength}).withMessage(`The text of the comment MUST be >=1 and <= ${maxCommentLength}`)], async (req, res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } try{ // Check if user.id is the author id of comment const authorid = await forumDao.getAuthorIDOfComment(req.params.commentId); if( !isLoggedInTotp(req, res) && req.user.id != authorid ) return res.status(401).json({error: 'Not authorized'}); const result = await forumDao.editComment(req.params.commentId, req.body.text); /* Must be 200 and not 204, because 204 implies empty body and the client want a json eachtime even empty*/ res.status(200).json({}); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); /* Delete selected post (Only authenticated totp and owner)*/ /* INPUT: Post ID */ app.delete('/api/posts/:id', isLoggedIn, [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1")], async (req, res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } try{ const authorid = await forumDao.getAuthorIDOfPost(req.params.id); if( !isLoggedInTotp(req,res) && req.user.id != authorid ) return res.status(401).json({error: 'Not authorized'}); const checkPost = await forumDao.getPost(req.params.id); if( checkPost.error != undefined ){ return res.status(404).json({error: checkPost.error}) } const result = await forumDao.deletePost(req.params.id); res.status(200).json({}); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); /* Delete selected comment (Only authenticated and owner)*/ app.delete('/api/posts/:id/comments/:commentId',isLoggedIn, [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"), check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1")], async (req, res) => { const errors = validationResult(req).formatWith(errorFormatter); // format error message if (!errors.isEmpty()) { return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info } try{ const authorid = await forumDao.getAuthorIDOfComment(req.params.commentId); if( !isLoggedInTotp(req, res) && req.user.id != authorid ) return res.status(401).json({error: 'Not authorized'}); const checkComment = await forumDao.getComment(req.params.commentId); if( checkComment.error != undefined ){ return res.status(404).json({error: checkComment.error}) } const result = await forumDao.deleteComment(req.params.commentId); res.status(200).json({}); }catch(err){ console.log(err); res.status(503).json({error: 'Database error'}); } }); function clientUserInfo(req) { const user=req.user; return {id: user.id, username: user.username, canDoTotp: user.secret? true: false, isTotp: req.session.method === 'totp'}; } /* POST /api/sessions Used to login */ app.post('/api/sessions', function(req, res, next) { passport.authenticate('local', (err, user, info) => { if (err) return next(err); if (!user) { return res.status(401).json({ error: info}); } req.login(user, (err) => { if (err) return next(err); return res.json(req.user); }); })(req, res, next); }); /* Perform OTP (only after login) */ app.post('/api/login-totp', isLoggedIn, passport.authenticate('totp'), function(req, res) { req.session.method = 'totp'; res.json({otp: 'authorized'}); } ); /* GET /api/sessions/current This route checks whether the user is logged in or not. */ app.get('/api/sessions/current', (req, res) => { if(req.isAuthenticated()) { res.status(200).json(req.user);} else res.status(401).json({error: 'Not authenticated'}); }); /* DELETE /api/session/current This route is used for loggin out the current user. */ app.delete('/api/sessions/current', isLoggedIn, (req, res) => { req.logout(() => { res.status(200).json({}); }); }); const PORT = 3001; // Activate the server app.listen(PORT, (err) => { if (err) console.log(err); else console.log(`Server listening at http://localhost:${PORT}`); });