diff --git a/api/main_endpoints/models/ChatMessage.js b/api/main_endpoints/models/ChatMessage.js new file mode 100644 index 000000000..57bf95a5f --- /dev/null +++ b/api/main_endpoints/models/ChatMessage.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const ChatMessageSchema = new Schema( + { + createdAt: { + type: Date, + default: Date.now(), + }, + expiresAt: { + type: Date, + default: ()=> Date.now() + 24 * 3600 * 1000, // expires in 24 hours + }, + chatroomId: { + type: String, + required: true, + }, + userId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + text: { + type: String, + required: true, + } + } +); + +ChatMessageSchema.index({chatroomId: 1, createdAt: -1}); // sort by whatever is created most currently +ChatMessageSchema.index({expiresAt: 1}, {expireAfterSeconds: 0}); // TTL index for automatic expiration + +module.exports = mongoose.model('ChatMessage', ChatMessageSchema); + + diff --git a/api/main_endpoints/routes/Advertisement.js b/api/main_endpoints/routes/Advertisement.js index 93a199e4a..a726a992c 100644 --- a/api/main_endpoints/routes/Advertisement.js +++ b/api/main_endpoints/routes/Advertisement.js @@ -5,7 +5,10 @@ const { decodeToken, checkIfTokenSent, } = require('../util/token-functions.js'); +const logger = require('../../util/logger'); const Advertisement = require('../models/Advertisement'); +const AuditLog = require('../models/AuditLog.js'); +const AuditLogActions = require('../util/auditLogActions.js'); router.get('/', async (req, res) => { const count = await Advertisement.countDocuments(); @@ -36,21 +39,35 @@ router.get('/getAllAdvertisements', async (req, res) => { router.post('/createAdvertisement', async (req, res) => { if (!checkIfTokenSent(req)) { return res.sendStatus(FORBIDDEN); - } else if (!await decodeToken(req)) { + } + + const user = await decodeToken(req); + if (!user) { return res.sendStatus(UNAUTHORIZED); } + const newAd = new Advertisement({ message: req.body.message, expireDate: req.body.expireDate }); - Advertisement.create(newAd) - .then((post) => { - return res.json(post); - }) - .catch( - (error) => res.sendStatus(BAD_REQUEST) - ); + try { + const createdAd = await Advertisement.create(newAd); + AuditLog.create({ + userId: user._id, + action: AuditLogActions.CREATE_AD, + details: { + message: createdAd.message, + expireDate: createdAd.expireDate, + advertisementId: createdAd._id + } + }).catch(logger.error); + + res.status(OK).send(createdAd); + } catch (error) { + logger.error('Error creating ad:', error); + res.sendStatus(BAD_REQUEST); + } }); router.post('/deleteAdvertisement', async (req, res) => { @@ -59,17 +76,41 @@ router.post('/deleteAdvertisement', async (req, res) => { } else if (!await decodeToken(req)) { return res.sendStatus(UNAUTHORIZED); } - Advertisement.deleteOne({ _id: req.body._id }) - .then(result => { - if (result.n < 1) { - res.sendStatus(NOT_FOUND); - } else { - res.sendStatus(OK); + + const user = await decodeToken(req); + if (!user) { + return res.sendStatus(UNAUTHORIZED); + } + + try { + const adToDelete = await Advertisement.findById(req.body._id); + + if (!adToDelete) { + return res.sendStatus(NOT_FOUND); + } + + const deleteResult = await Advertisement.deleteOne({_id: req.body._id}); + + if(deleteResult.deletedCount < 1) { + return res.sendStatus(NOT_FOUND); + } + + AuditLog.create({ + userId: user._id, + action: AuditLogActions.DELETE_AD, + details: { + deletedAd: { + id: adToDelete._id, + message: adToDelete.message, + } } - }) - .catch(() => { - res.sendStatus(BAD_REQUEST); - }); + }).catch(logger.error); + + res.sendStatus(OK); + } catch (error) { + logger.error('Error deleting ad:', error); + res.sendStatus(BAD_REQUEST); + } }); module.exports = router; diff --git a/api/main_endpoints/routes/Auth.js b/api/main_endpoints/routes/Auth.js index d4db4c5a9..b8c29feb4 100644 --- a/api/main_endpoints/routes/Auth.js +++ b/api/main_endpoints/routes/Auth.js @@ -146,81 +146,84 @@ router.post('/login', function(req, res) { } if (!user) { - res + return res .status(UNAUTHORIZED) .send({ message: 'Username or password does not match our records.' }); - } else { - // Check if password matches database - user.comparePassword(req.body.password, function(error, isMatch) { - if (isMatch && !error) { - if (user.accessLevel === membershipState.BANNED) { - return res - .status(UNAUTHORIZED) - .send({ - message: 'The account with email ' + + } + + // Check if password matches database + user.comparePassword(req.body.password, function(error, isMatch) { + if (!isMatch && !error) { + return res.status(UNAUTHORIZED).send({ + message: 'Username or password does not match our records.' + }); + } + + if (user.accessLevel === membershipState.BANNED) { + return res + .status(UNAUTHORIZED) + .send({ + message: 'The account with email ' + req.body.email + ' is banned', - }); - } - - // Check if the user's email has been verified - if (!user.emailVerified) { - return res - .status(UNAUTHORIZED) - .send({ message: `The email ${req.body.email} has not been verified` }); - } - - // If the username and password matches the database, assign and - // return a jwt token - const jwtOptions = { - expiresIn: '2h' - }; - - // check here to see if we should reset the pagecount. If so, do it - if (checkIfPageCountResets(user.lastLogin)) { - user.pagesPrinted = 0; - } - - // Include fields from the User model that should - // be passed to the JSON Web Token (JWT) - const userToBeSigned = { - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - accessLevel: user.accessLevel, - pagesPrinted: user.pagesPrinted, - _id: user._id - }; - user - .save() - .then(() => { - const token = jwt.sign( - userToBeSigned, config.secretKey, jwtOptions - ); - // Create audit log on successful sign-in - AuditLog.create({ - userId: user._id, - action: AuditLogActions.LOG_IN, - details: { email: user.email } - }).catch(logger.error); - - res.json({ token: 'JWT ' + token }); - }) - .catch((error) => { - logger.error('unable to login user', error); - res.sendStatus(SERVER_ERROR); - }); - } else { - res.status(UNAUTHORIZED).send({ - message: 'Username or password does not match our records.' }); - } - }); - } - } - ); + } + + // Check if the user's email has been verified + if (!user.emailVerified) { + return res + .status(UNAUTHORIZED) + .send({ message: `The email ${req.body.email} has not been verified` }); + } + + // If the username and password matches the database, assign and + // return a jwt token + const jwtOptions = { + expiresIn: '2h' + }; + + // check here to see if we should reset the pagecount. If so, do it + if (checkIfPageCountResets(user.lastLogin)) { + user.pagesPrinted = 0; + } + + // set last login date here!!!! + user.lastLogin = new Date(); + + + // Include fields from the User model that should + // be passed to the JSON Web Token (JWT) + const userToBeSigned = { + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + accessLevel: user.accessLevel, + pagesPrinted: user.pagesPrinted, + _id: user._id + }; + user + .save() + .then(() => { + const token = jwt.sign( + userToBeSigned, config.secretKey, jwtOptions + ); + // Create audit log on successful sign-in + AuditLog.create({ + userId: user._id, + action: AuditLogActions.LOG_IN, + details: { email: user.email } + }).catch(logger.error); + + res.json({ token: 'JWT ' + token }); + }) + .catch((error) => { + logger.error('unable to login user', error); + res.sendStatus(SERVER_ERROR); + }); + }); + }); }); // Verifies the users session if they have an active jwtToken. diff --git a/api/main_endpoints/routes/LedSign.js b/api/main_endpoints/routes/LedSign.js index 47a67fd3f..a04761432 100644 --- a/api/main_endpoints/routes/LedSign.js +++ b/api/main_endpoints/routes/LedSign.js @@ -11,6 +11,8 @@ const { } = require('../util/token-functions.js'); const logger = require('../../util/logger'); const { updateSign, healthCheck, turnOffSign } = require('../util/LedSign.js'); +const AuditLogActions = require('../util/auditLogActions.js'); +const AuditLog = require('../models/AuditLog.js'); const runningInDevelopment = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'; @@ -36,7 +38,8 @@ router.post('/updateSignText', async (req, res) => { logger.warn('/updateSignText was requested without a token'); return res.sendStatus(UNAUTHORIZED); } - if (!await decodeToken(req)) { + const user = await decodeToken(req); // Store the user here + if (!user) { logger.warn('/updateSignText was requested with an invalid token'); return res.sendStatus(UNAUTHORIZED); } @@ -56,8 +59,16 @@ router.post('/updateSignText', async (req, res) => { if(!result) { status = SERVER_ERROR; } + + AuditLog.create({ + userId: user._id, + action: AuditLogActions.UPDATE_SIGN, + details: { + newSignText: req.body.text, + } + }).catch(logger.error); + return res.sendStatus(status); }); - module.exports = router; diff --git a/api/main_endpoints/routes/Messages.js b/api/main_endpoints/routes/Messages.js index 82adafc8d..885afd46c 100644 --- a/api/main_endpoints/routes/Messages.js +++ b/api/main_endpoints/routes/Messages.js @@ -13,6 +13,7 @@ const logger = require('../../util/logger'); const client = require('prom-client'); const { decodeToken, decodeTokenFromBodyOrQuery } = require('../util/token-functions.js'); const { MetricsHandler, register } = require('../../util/metrics.js'); +const ChatMessage = require('../models/ChatMessage.js'); router.use(bodyParser.json()); @@ -23,7 +24,8 @@ const clients = {}; const numberOfConnections = {}; const lastMessageSent = {}; -const writeMessage = ((roomId, message, username) => { + +const writeMessage = (roomId, message, username) => { const messageObj = { timestamp: Date.now(), @@ -37,12 +39,13 @@ const writeMessage = ((roomId, message, username) => { lastMessageSent[roomId] = JSON.stringify(messageObj); + // increase the total messages sent counter MetricsHandler.totalMessagesSent.inc(); // increase the total amount of messages sent per chatroom counter MetricsHandler.totalChatMessagesPerChatRoom.labels(roomId).inc(); -}); +}; router.post('/send', async (req, res) => { @@ -65,6 +68,7 @@ router.post('/send', async (req, res) => { } let nameToUse = null; + let userId = null; if (apiKey) { try { @@ -73,20 +77,30 @@ router.post('/send', async (req, res) => { return res.sendStatus(UNAUTHORIZED); } nameToUse = result.firstName; + userId = result._id; + await writeToMongo(id, message, userId); } catch (error) { logger.error('Error in /send User.findOne: ', error); return res.sendStatus(SERVER_ERROR); } + } else { + const userObj = decodeToken(req); + if (!userObj) { + return res.sendStatus(UNAUTHORIZED); + } + nameToUse = userObj.firstName; + userId = userObj._id; } - - // Assume user passed a non null/undefined token - const userObj = decodeToken(req); - if (!userObj) { - return res.sendStatus(UNAUTHORIZED); - } - nameToUse = userObj.firstName; try { writeMessage(id, `${message}`, `${nameToUse}:`); + + ChatMessage.create({ + chatroomId: id, + text: message, + userId: userId + }).catch(err => { + logger.error('Error in /send ChatMessage.create: ', err); + }); return res.json({ status: 'Message sent' }); } catch (error) { logger.error('Error in /send writeMessage: ', error); @@ -103,33 +117,23 @@ router.get('/getLatestMessage', async (req, res) => { ]; const missingValue = required.find(({value}) => !value); - if (missingValue){ res.status(BAD_REQUEST).send(`You must specify a ${missingValue.title}`); return; } try { - User.findOne({ apiKey }, (error, result) => { - if (error) { - logger.error('/listen received an invalid API key: ', error); - res.sendStatus(SERVER_ERROR); - return; - } - - if (!result) { // return unauthorized if no api key found - return res.sendStatus(UNAUTHORIZED); - } + const user = await User.findOne({apiKey}); + if(!user){ + return res.sendStatus(UNAUTHORIZED); + } - if (!lastMessageSent[id]) { - return res.status(OK).send('Room closed'); - } + const messages = await ChatMessage.find({chatroomId: id}).sort({createdAt: -1}).limit(20).populate('userId'); - return res.status(OK).send(lastMessageSent[id]); + return res.status(OK).json(messages); - }); } catch (error) { - logger.error('Error in /get: ', error); + logger.error('Error in /getLatestMessage: ', error); res.sendStatus(SERVER_ERROR); } }); @@ -144,7 +148,6 @@ router.get('/listen', async (req, res) => { ]; const missingValue = required.find(({value}) => !value); - if (missingValue){ res.status(BAD_REQUEST).send(`You must specify a ${missingValue.title}`); return; @@ -174,7 +177,6 @@ router.get('/listen', async (req, res) => { } const { _id } = result; - numberOfConnections[_id] = numberOfConnections[_id] ? numberOfConnections[_id] + 1 : 1; const headers = { diff --git a/api/main_endpoints/routes/User.js b/api/main_endpoints/routes/User.js index 0b886be9e..1dffc8daf 100644 --- a/api/main_endpoints/routes/User.js +++ b/api/main_endpoints/routes/User.js @@ -24,6 +24,8 @@ const { SERVER_ERROR, } = require('../../util/constants').STATUS_CODES; const membershipState = require('../../util/constants').MEMBERSHIP_STATE; +const AuditLog = require('../models/AuditLog.js'); +const AuditLogActions = require('../util/auditLogActions.js'); const logger = require('../../util/logger'); @@ -32,9 +34,6 @@ const crypto = require('crypto'); const ROWS_PER_PAGE = 20; -const AuditLogActions = require('../util/auditLogActions.js'); -const AuditLog = require('../models/AuditLog.js'); - // Delete a member router.post('/delete', async (req, res) => { if (!checkIfTokenSent(req)) { @@ -198,6 +197,25 @@ router.post('/edit', async (req, res) => { const query = { _id: req.body._id }; let user = req.body; + // keep track of user in db + const existingUser = await User.findById(req.body._id); + if (!existingUser) { + return res.status(NOT_FOUND).send({ message: 'User not found.' }); + } + + // Track field changes + const fieldChanges = {}; + const fieldsToTrack = ['firstName', 'lastName', 'email', 'accessLevel', 'major', 'discordID', 'emailOptIn', 'membershipValidUntil']; + + fieldsToTrack.forEach(field => { + if (user[field] !== undefined && user[field] !== existingUser[field]) { + fieldChanges[field] = { + from: existingUser[field], + to: user[field] + }; + } + }); + if (typeof req.body.numberOfSemestersToSignUpFor !== 'undefined') { user.membershipValidUntil = getMemberExpirationDate( parseInt(req.body.numberOfSemestersToSignUpFor) @@ -213,6 +231,14 @@ router.post('/edit', async (req, res) => { return res.sendStatus(SERVER_ERROR); } user.password = result; + + // create audit log for password change + AuditLog.create({ + userId: decoded._id, + action: AuditLogActions.CHANGE_PW, + details: { email: existingUser.email, userId: decoded._id }, + }).catch(logger.error); + } else { // omit password from the object if it is falsy // i.e. an empty string, undefined or null @@ -236,24 +262,28 @@ router.post('/edit', async (req, res) => { if (result.nModified < 1) { return res .status(NOT_FOUND) - .send({ message: `${query.email} not found.` }); + .send({ message: `${existingUser.email} not found.` }); } - const sanitizedUser = {...user}; // shallow copy of the user; doesn't affect original + if (Object.keys(fieldChanges).length > 0) { + const sanitizedUser = {...user}; + if ('password' in sanitizedUser) { + sanitizedUser.password = true; + } - if ('password' in sanitizedUser) { - sanitizedUser.password = true; + AuditLog.create({ + userId: decoded._id, // person who did modification + action: AuditLogActions.UPDATE_USER, + documentId: user._id, + details: { + updatedInfo: sanitizedUser, + fieldChanges: fieldChanges + } + }).catch(logger.error); } - AuditLog.create({ - userId: decoded._id, // the user making the update - action: AuditLogActions.UPDATE_USER, - documentId: user._id, // the user affected by the update - details: {updatedInfo: sanitizedUser} - }).catch(logger.error); - return res.status(OK).send({ - message: `${query.email} was updated.`, + message: `${existingUser.email} was updated.`, membershipValidUntil: user.membershipValidUntil }); }); @@ -272,8 +302,9 @@ router.post('/getPagesPrintedCount', (req, res) => { apiEndpoint: 'user/PagesPrintedCount', errorDescription: error }; + logger.error(info); - res.status(BAD_REQUEST).send({ message: 'Bad Request.' }); + return res.status(BAD_REQUEST).send({ message: 'Bad Request.' }); } if (!result) { diff --git a/api/main_endpoints/util/auditLogActions.js b/api/main_endpoints/util/auditLogActions.js index b53ceaf0f..3b18bd9e8 100644 --- a/api/main_endpoints/util/auditLogActions.js +++ b/api/main_endpoints/util/auditLogActions.js @@ -7,6 +7,9 @@ const AuditLogActions = { EMAIL_SENT: 'EMAIL_SENT', CHANGE_PW: 'CHANGE_PW', RESET_PW: 'RESET_PW', + CREATE_AD: 'CREATE_AD', + DELETE_AD: 'DELETE_AD', + UPDATE_SIGN: 'UPDATE_SIGN', VERIFY_CARD: 'VERIFY_CARD', ADD_CARD: 'ADD_CARD', DELETE_CARD: 'DELETE_CARD', diff --git a/api/util/metrics.js b/api/util/metrics.js index 039d1d9aa..31b17a088 100644 --- a/api/util/metrics.js +++ b/api/util/metrics.js @@ -2,28 +2,28 @@ const client = require('prom-client'); const register = new client.Registry(); class MetricsHandler { - endpointHits = new client.Counter({ + endpointHits = new client.Counter({ name: 'endpoint_hits', help: 'Counter for tracking endpoint hits with status codes', labelNames: ['method', 'route', 'statusCode'], - }); + }); - emailSent = new client.Counter({ + emailSent = new client.Counter({ name: 'email_sent', help: 'Counter for tracking emails sent', labelNames: ['type'], - }); + }); - captchaVerificationErrors = new client.Counter({ + captchaVerificationErrors = new client.Counter({ name: 'captcha_verification_errors', help: 'Counter for tracking captcha verification errors', - }); + }); - sshTunnelErrors = new client.Counter({ + sshTunnelErrors = new client.Counter({ name: 'ssh_tunnel_errors', help: 'Counter for tracking ssh tunnel errors', labelNames: ['type'], - }); + }); totalMessagesSent = new client.Counter({ name: 'total_messages_sent', diff --git a/package-lock.json b/package-lock.json index 243134f32..87c93573e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7627,6 +7627,8 @@ "version": "7.32.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "license": "MIT", "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", diff --git a/src/APIFunctions/Auth.js b/src/APIFunctions/Auth.js index 7a3a60f83..ce58636a8 100644 --- a/src/APIFunctions/Auth.js +++ b/src/APIFunctions/Auth.js @@ -1,5 +1,4 @@ import { UserApiResponse, ApiResponse } from './ApiResponses'; -import { updateLastLoginDate } from './User'; import { BASE_API_URL } from '../Enums'; @@ -76,7 +75,6 @@ export async function loginUser(email, password) { const result = await res.json(); if (res.ok) { status.token = result.token; - await updateLastLoginDate(email, result.token); window.location.reload(); return status; } diff --git a/src/APIFunctions/User.js b/src/APIFunctions/User.js index 6b9deaf78..cb072a556 100644 --- a/src/APIFunctions/User.js +++ b/src/APIFunctions/User.js @@ -141,19 +141,6 @@ export async function editUser(userToEdit, token) { return status; } -/** - * Updates the user's last login date when they log in. - * @param {string} email The email of the user - * @param {string} token The JWT token to allow the user to be edited - */ -export async function updateLastLoginDate(email, token) { - await editUser({ email, lastLogin: Date.now() }, { - headers: { - Authorization: `Bearer ${token}` - } - }); -} - /** * Deletes a user by an ID * @param {string} _id The ID of the user to delete diff --git a/src/Components/Navbar/UserNavbar.js b/src/Components/Navbar/UserNavbar.js index 9515a4c00..b865562c7 100644 --- a/src/Components/Navbar/UserNavbar.js +++ b/src/Components/Navbar/UserNavbar.js @@ -3,7 +3,7 @@ import { membershipState } from '../../Enums'; import { useSCE } from '../context/SceContext'; export default function UserNavbar(props) { - const { user, authenticated } = useSCE(); + const { user, authenticated, setModalOpen } = useSCE(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); let initials = ''; @@ -84,12 +84,19 @@ export default function UserNavbar(props) {
-
+ {authenticated && user ? ( <>
diff --git a/src/Components/ShortcutKeyModal/SearchModal.css b/src/Components/ShortcutKeyModal/SearchModal.css index 2f0ccef8c..3017c9167 100644 --- a/src/Components/ShortcutKeyModal/SearchModal.css +++ b/src/Components/ShortcutKeyModal/SearchModal.css @@ -8,8 +8,7 @@ /* blurred background */ display: flex; justify-content: center; - align-items: flex-start; - padding-top: 33vh; + align-items: center; z-index: 9999; } @@ -17,7 +16,9 @@ padding: 8px; background: white; border-radius: 8px; - width: 35rem; + width: 100%; + /* the default is the mobile size */ + max-width: 500px; } .shortcut-search-modal input { @@ -67,3 +68,11 @@ background-color: #1e293b; } } + +/* Desktop mode */ +@media (min-width: 768px) { + .shortcut-search-modal .input-wrapper { + width: 35rem; + max-width: none; + } +} diff --git a/src/Components/ShortcutKeyModal/SearchModal.js b/src/Components/ShortcutKeyModal/SearchModal.js index 09a748317..e431b6189 100644 --- a/src/Components/ShortcutKeyModal/SearchModal.js +++ b/src/Components/ShortcutKeyModal/SearchModal.js @@ -6,7 +6,10 @@ import { useSCE } from '../context/SceContext'; import { searchUsersAndCleezyUrls } from '../../APIFunctions/ShortcutSearch'; export default function SearchModal() { - const [open, setOpen] = useState(false); + const {modalOpen, setModalOpen} = useSCE(); + // kept original open and setOpen state variables + const open = modalOpen; + const setOpen = setModalOpen; const inputRef = useRef(null); const modalRef = useRef(null); const filteredSignedOutRoutes = [...signedOutRoutes].filter(r => !r.hideFromShortcutSuggestions); @@ -77,11 +80,11 @@ export default function SearchModal() { const topFiveItems = suggestions.slice(0, SHORTCUT_MAX_RESULT); return ( -