Files
webapp-forumexam/server/index.js
2025-06-28 18:31:48 +02:00

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}`);
});