chat-application-322 / server.js
server.js
Raw
const path = require('path');
const express = require('express');
const WebSocket = require('ws');
const Database = require('./Database.js');
const SessionManager = require("./SessionManager.js");
const crypto = require('crypto');
const GPT4All = require('./GPT4All.js');

const aiModel = new GPT4All();

const MAX_INAP_MESSAGE = 3

const host = 'localhost';
const port = 3000;
const clientApp = path.join(__dirname, 'client');
const cpen322 = require('./cpen322-tester.js');
const broker = new WebSocket.Server({ port: 8000 });

const db = new Database('mongodb://127.0.0.1:27017', 'cpen322-messenger');

function logRequest(req, res, next){
	console.log(`${new Date()}  ${req.ip} : ${req.method} ${req.path}`);
	next();
}

function isCorrectPassword(password, saltedHash){
	const salt = saltedHash.substring(0, 20);
	const originalHash = saltedHash.substring(20);

	const saltedPassword = password + salt;

	const hash = crypto.createHash('sha256').update(saltedPassword).digest('base64');
	
	return hash === originalHash;
}

function errorHandler (err, req, res, next) {
	if(err instanceof SessionManager.Error) {
		console.error(req);
		if(req.headers.accept === 'application/json') {
			res.status(401).json({ error: err.message });
		}else{
			res.redirect('/login');
		}
	}else{
		res.status(500).send('Server Error');
	}
}

function middlewareForIndex(req, res, next) {
    const requestedPath = req.path;
    if (requestedPath === '/' || requestedPath.includes('index')) {
        sessionManager.middleware(req, res, next);
    } else {
        next();
    }
}

function sanitizeText(text){
	return text.replace(/</g, '').replace(/>/g, '');
}

const messages = {};
const messageBlockSize = 10;

// Generate a notification for the user that their account has been restricted
const generateRestrictedNotification = (message, request, shouldRemoveLastMessage = true) => {
	const notification = {
		type: 'restricted-alert',
		alert: `Your account has been restricted, you will no longer be able to send messages due to repeated inappropriate content violations.`,
		originalMessage: {
			username: request.username,
			text: message.text
		},
		roomId: message.roomId,
		shouldRemoveLastMessage: shouldRemoveLastMessage
	}
	
	return notification;
}

// Generate a notification for the user that their message has been filtered due to inappropriate content
const generateInappropriateNotification = (message, request, justification, remainingAttempts) => {
	const notification = {
		type: 'inappropriate-alert',
		originalMessage: {
			username: request.username,
			text: message.text
		},
		moderatedMessage: {
			username: request.username,
			text: `Message removed because: ${justification}`
		},
		roomId: message.roomId,
		remainingAttempts: remainingAttempts
	};

	if(remainingAttempts >= 1){
		notification.alert = `Your message was removed because: ${justification}. You have ${remainingAttempts} warning(s) left before your account will be restricted.`;
	} else if(remainingAttempts == 0){
		notification.alert = `Your message was removed because: ${justification}. This is your last warning, any further inappropriate messages will result in your account being restricted.`;
	} else if(remainingAttempts < 0){
		notification.alert = `This message was removed because: ${justification}. Your account has been restricted due to repeated inappropriate content violations.`;
	}
	return notification;
};

broker.on('connection', function(socket, request) {
	const next = (error) => {
        // Close socket if the cookie cannot be validated
		if (error) {
            socket.close();
        } else {
			socket.on('message', function(data) {
				// Make sure that the data is a string so that it can be parsed by JSON
				if (data instanceof Buffer || data instanceof ArrayBuffer) {
					data = Buffer.from(data).toString();
				}
		
				const message = JSON.parse(data);
				message.type = 'chat';

				db.getInappropriateAttempts(message.username).then(attempts => {
					const remainingAttempts = MAX_INAP_MESSAGE - attempts;
					// If user has already sent enough messages to be restricted
					if(remainingAttempts < 0){
						const restrictedNotification = generateRestrictedNotification(message, request);
						socket.send(JSON.stringify(restrictedNotification));
						return;
					}
					
					// Call API to check if message is inappropriate via AI Model
		    		const url = `http://localhost:${port}/api/check-message`;
					fetch(url, {
						method: 'POST',
						headers: {
							'Content-Type': 'application/json',
						},
						body: JSON.stringify({ message: message.text }),
					})
					.then(response => response.json())
					.then(result => {
						if (result.isInappropriate) {
							db.incrementInappropriateAttempts(message.username).then(() => {
								// Get updated number of inappropriate attempts
								db.getInappropriateAttempts(message.username).then(attempts => {
									const remainingAttempts = MAX_INAP_MESSAGE - attempts;
									
									// Generate notification for the user that their message has been filtered
									const notification = generateInappropriateNotification(message, request, result.justification, remainingAttempts);
									socket.send(JSON.stringify(notification));
									
									// If the user has no more attempts left, restrict their account
									if(remainingAttempts < 0){
										const restrictedNotification = generateRestrictedNotification(message, request, false);
										socket.send(JSON.stringify(restrictedNotification));
									}
									
									// Send filtered message to other users
									message.text = `Message removed because: ${result.justification}`;
									sendMessageToClient(socket, message, request);

								}).catch(error => {
									console.error('Error fetching inappropriate attempts', error);
								});
							}).catch(error => {
								console.error('Error incrementing inappropriate attempts', error);
							});
						}
						// If the message is not inappropriate just send messages to other users
						else{
							sendMessageToClient(socket, message, request);
						}
					
					})
					.catch(error => {
						console.error('Error while checking whether message is inappropriate.', error);
					});
				});
			});
		}
    };

	const response = {};

	sessionManager.middleware(request, response, next);
});

const sendMessageToClient = (socket, message, request) => {
	// Sanitize message before forwarding to clients or adding to database
	if (message.text) {
		message.text = sanitizeText(message.text);
	}

	message.username = request.username; 

	if(messages[message.roomId]) {
		
		messages[message.roomId].push(message);

		if(messages[message.roomId].length === messageBlockSize) {
			const conversation = {
				room_id: message.roomId,
				timestamp: Date.now(),
				messages: messages[message.roomId]
			};

			db.addConversation(conversation).then(() => {
				messages[message.roomId] = [];
			}).catch(error => {	
				console.error('Failed to add conversation to database', error);
			});
		}
	}

	// Iterate through clients
	broker.clients.forEach(client => {
		if(client !== socket && client.readyState === WebSocket.OPEN){
			const updatedData = JSON.stringify(message);
			client.send(updatedData);
		}
	});
};

const sessionManager = new SessionManager();

// express app
let app = express();

app.use(express.json()) 						// to parse application/json
app.use(express.urlencoded({ extended: true })) // to parse application/x-www-form-urlencoded
app.use(logRequest);							// logging for debug
app.use(errorHandler);
app.use(middlewareForIndex);

// serve static files (client-side)
app.use('/app.js', sessionManager.middleware, express.static("public/app.js"), errorHandler);
app.use('/', middlewareForIndex, express.static(clientApp, { extensions: ['html'] }), errorHandler);

app.listen(port, () => {
	console.log(`${new Date()}  App Started. Listening on ${host}:${port}, serving ${clientApp}`);
});

db.getRooms().then(rooms => {
	rooms.forEach(room => {
		messages[room._id] = [];
	});
});


app.get('/chat', sessionManager.middleware, (req, res) => {
	db.getRooms().then(rooms => {
		const chatData = rooms.map(room => {
			return {
				...room,
				messages: messages[room._id]
			};
		});
		res.json(chatData);
	})
}, errorHandler);

app.get('/chat/:room_id', sessionManager.middleware, (req, res) => {
	const room_id = req.params.room_id;
	db.getRoom(room_id).then(room => {
		if(room) {
			res.json(room);
		}else{
			res.status(404).send(`Chatroom ${room_id} not found`);
		}
	})
}, errorHandler);

app.get('/chat/:room_id/messages', sessionManager.middleware, (req, res) => {
	const room_id = req.params.room_id;
	// Parse before query to integer if it is not null
	const before = req.query.before ? parseInt(req.query.before) : null;
	
	if(before) {
		db.getLastConversation(room_id, before).then(messages => {
			res.json(messages);
		}).catch(error => {
			res.status(500).send('Error while fetching messages');
		})
	}else{ // If no before query, just fetch the latest conversation
		db.getLastConversation(room_id).then(messages => {
			res.json(messages);
		}).catch(error => {
			res.status(500).send('Error while fetching messages');
		})
	}
}, errorHandler);

app.get('/profile', sessionManager.middleware, (req, res) => {
	if (req.username) {
        res.json({ username: req.username });
    } else {
        res.status(401).json({ error: 'User could not be authenticated' });
    }
}, errorHandler)

app.get('/logout', sessionManager.middleware, (req, res) => {
	sessionManager.deleteSession(req);
	res.redirect('/login');
}, errorHandler)

app.post('/chat', sessionManager.middleware, (req, res) => {

	if(!req.body || !req.body.name) {
		res.status(400).send('Chatroom name is required');
		return;
	};

	const chatroom = {
		name: req.body.name,
		image: req.body.image ?? "assets/chat-icon.png"
	};

	db.addRoom(chatroom).then(addedChatRoom => {
		messages[addedChatRoom._id] = [];
		res.status(200).json(addedChatRoom);
	})
	.catch(error => {
		console.error('Failed to add chatroom to database', error);
		res.status(500).send('Error while creating chatroom.');
	})
}, errorHandler)

app.post('/login', (req, res) => {
	const { username, password } = req.body;
	db.getUser(username).then(user => {
		if(isCorrectPassword(password, user.password)) {
			sessionManager.createSession(res, username);
			res.redirect('/');
		}else{
			res.redirect('/login');
		}
	}).catch(error => {
		res.redirect('/login');
	});
	
});

// AI Feature API: Check if a message is inappropriate
app.post('/api/check-message', async (req, res) => {
	const {message} = req.body;
	try {
		const result = await aiModel.checkIfMessageInappropriate(message);
		res.json(result);
	} catch (error) {
		console.error("Error processing AI check:", error);
        res.status(500).send("Internal Server Error");
	}
});

cpen322.connect('http://3.98.223.41/cpen322/test-a5-server.js');
cpen322.export(__filename, { app, messages, broker, db, messageBlockSize, sessionManager, isCorrectPassword });