354 lines
13 KiB
JavaScript
354 lines
13 KiB
JavaScript
'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}`);
|
|
});
|