├── .env ├── uploads └── .gitkeep ├── .husky ├── .gitignore └── pre-commit ├── defaultFiles └── default.png ├── .stylelintrc.json ├── .gitignore ├── server ├── controllers │ ├── wrap.ts │ ├── postController.ts │ └── userController.ts ├── errorHandler.ts ├── notifications │ ├── User.ts │ ├── Publisher.ts │ └── index.ts ├── schemas │ ├── postSchema.ts │ ├── userSchema.ts │ └── postRequestSchema.ts ├── validation │ ├── verifyUser.ts │ ├── validateRefreshJWT.ts │ ├── validateUsernameParam.ts │ ├── validateAccessJWT.ts │ ├── validateUsernameCookie.ts │ └── validateFile.ts ├── db │ ├── JobManager.ts │ ├── initDB.ts │ ├── models.ts │ ├── UserManager.ts │ └── PostManager.ts ├── routes │ ├── posts │ │ └── index.ts │ └── users │ │ └── index.ts ├── index.ts └── scheduling │ └── scheduler.ts ├── .prettierrc.json ├── client ├── public │ └── index.html ├── src │ ├── Validation │ │ ├── validateDate.js │ │ ├── validateUsername.js │ │ └── validatePassword.js │ ├── Worker.js │ ├── Components │ │ ├── PostMenu │ │ │ ├── PostMenu.module.css │ │ │ └── index.js │ │ ├── Popup │ │ │ └── index.js │ │ ├── Post │ │ │ ├── Post.module.css │ │ │ └── index.js │ │ ├── App │ │ │ ├── styles.css │ │ │ └── index.js │ │ ├── UserDashboard │ │ │ ├── UserDashboard.module.css │ │ │ └── index.js │ │ ├── Navbar │ │ │ └── index.js │ │ ├── Pagination │ │ │ └── index.js │ │ ├── LoginPage │ │ │ └── index.js │ │ ├── Reactions │ │ │ └── index.js │ │ ├── Notifications │ │ │ └── index.js │ │ ├── SignupPage │ │ │ └── index.js │ │ ├── PostCreator │ │ │ └── index.js │ │ ├── PostPage │ │ │ └── index.js │ │ └── Comment │ │ │ └── index.js │ ├── constants.js │ ├── Hooks │ │ ├── useSinglePostFetch.js │ │ ├── usePostsNumberFetch.js │ │ ├── useUserDataFetch.js │ │ ├── useReactionsFetch.js │ │ ├── useCommentsFetch.js │ │ └── usePostsPaginationFetch.js │ ├── Requests │ │ ├── users.js │ │ └── posts.js │ ├── index.js │ └── api.js ├── .gitignore └── package.json ├── local.env ├── .eslintrc.json ├── tests ├── mocks │ └── mockInitDB.ts ├── postController.ts ├── userController.ts └── middleWare.ts ├── package.json ├── README.md └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /defaultFiles/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlexG/nodejs-react-forum/HEAD/defaultFiles/default.png -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "indentation": "tab" 5 | } 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | 4 | # exclude images 5 | uploads/* 6 | 7 | # exception to the rule 8 | !uploads/.gitkeep 9 | -------------------------------------------------------------------------------- /server/controllers/wrap.ts: -------------------------------------------------------------------------------- 1 | export default (fn) => (req, res, next) => { 2 | Promise.resolve(fn(req, res, next)).catch(next); 3 | }; 4 | -------------------------------------------------------------------------------- /server/errorHandler.ts: -------------------------------------------------------------------------------- 1 | export function errorHandler(err, req, res, next) { 2 | console.error(err.stack); 3 | res.sendStatus(500); 4 | } 5 | -------------------------------------------------------------------------------- /server/notifications/User.ts: -------------------------------------------------------------------------------- 1 | export default class User { 2 | notify: (message: string) => void; 3 | constructor(notify) { 4 | this.notify = notify; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabs": true, 4 | "semi": true, 5 | "useTabs": true, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Forum 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/Validation/validateDate.js: -------------------------------------------------------------------------------- 1 | export default function validateDate (date) { 2 | if (date === '') { 3 | return true; 4 | } else if (new Date(date).toString() === 'Invalid Date') { 5 | return false; 6 | } 7 | return true; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/Validation/validateUsername.js: -------------------------------------------------------------------------------- 1 | export default function validateUsername (username) { 2 | if (username.length < 4) { 3 | return 'username is too short'; 4 | } 5 | if (username.length > 15) { 6 | return 'username is too long'; 7 | } 8 | return 'valid'; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/Worker.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { REFRESH_TOKEN_TIME } from './constants.js'; 3 | setInterval(() => { 4 | // try to get the accessToken, retry is true to have the interceptor ignore this request 5 | axios.get('/api/v1/token', { _retry: true }); 6 | }, REFRESH_TOKEN_TIME); 7 | -------------------------------------------------------------------------------- /server/schemas/postSchema.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Segments } from 'celebrate'; 2 | 3 | export default { 4 | [Segments.BODY]: Joi.object().keys({ 5 | title: Joi.string().required(), 6 | body: Joi.string().required(), 7 | parent: Joi.string().optional(), 8 | date: Joi.date().optional() 9 | }) 10 | }; 11 | -------------------------------------------------------------------------------- /server/schemas/userSchema.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Segments } from 'celebrate'; 2 | 3 | export default { 4 | [Segments.BODY]: Joi.object().keys({ 5 | username: Joi.string().required(), 6 | password: Joi.string().min(8).required().messages({ 7 | 'string.min': 'your password is less then 8 characters long' 8 | }) 9 | }) 10 | }; 11 | -------------------------------------------------------------------------------- /client/src/Components/PostMenu/PostMenu.module.css: -------------------------------------------------------------------------------- 1 | .menu { 2 | border-radius: 10px 10px 0 0; 3 | border: 1px solid #dfdfdf; 4 | } 5 | 6 | .menuText { 7 | padding-left: 1em; 8 | padding-right: 1em; 9 | margin-top: 1em; 10 | } 11 | 12 | .menuButton { 13 | padding-left: 1em; 14 | padding-right: 1em; 15 | margin-right: 1em; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/Components/Popup/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'bootstrap/dist/css/bootstrap.css'; 3 | 4 | const Popup = (props) => { 5 | return ( 6 |
10 | {props.message} 11 |
12 | ); 13 | }; 14 | 15 | export default Popup; 16 | -------------------------------------------------------------------------------- /client/src/Components/Post/Post.module.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | height: 0; 3 | padding: 0; 4 | opacity: 0; 5 | transition: height 0.5s ease-out; 6 | } 7 | 8 | .dropdown_hover { 9 | transition: opacity 0.5s, height 0.25s ease-out; 10 | margin-bottom: 15px; 11 | margin-left: 15px; 12 | margin-right: 15px; 13 | opacity: 1; 14 | } 15 | 16 | .post_title { 17 | font-size: 1.5rem; 18 | } 19 | -------------------------------------------------------------------------------- /server/validation/verifyUser.ts: -------------------------------------------------------------------------------- 1 | import { initManagers } from '../db/initDB'; 2 | const { userManager } = initManagers(); 3 | 4 | export default async function verifyUser(req, res, next) { 5 | const { username, password } = req.body; 6 | const valid = await userManager.verifyUser(username, password); 7 | if (valid) { 8 | next(); 9 | } else { 10 | res.sendStatus(400); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/Components/App/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow-y: scroll; 3 | overflow-x: hidden; 4 | } 5 | 6 | *:focus { 7 | outline: 0 !important; 8 | box-shadow: 9 | 0 0 0 0.2rem #fff, 10 | 0 0 0 0.35rem #069 !important; 11 | } 12 | 13 | *:focus:not(.focus-visible) { 14 | outline: 0 !important; 15 | box-shadow: none !important; 16 | } 17 | 18 | .upvotes { 19 | font-size: 1.5rem; 20 | color: grey; 21 | } 22 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /server/validation/validateRefreshJWT.ts: -------------------------------------------------------------------------------- 1 | import jwt = require('jsonwebtoken'); 2 | export default function validateRefreshJWT(req, res, next) { 3 | if (!req.cookies.refreshToken) { 4 | return res.sendStatus(401); 5 | } 6 | const refreshToken = req.cookies.refreshToken; 7 | try { 8 | jwt.verify(refreshToken, process.env.REFRESH_JWT_SECRET); 9 | } catch (e) { 10 | return res.sendStatus(401); 11 | } 12 | next(); 13 | } 14 | -------------------------------------------------------------------------------- /server/validation/validateUsernameParam.ts: -------------------------------------------------------------------------------- 1 | import { initManagers } from '../db/initDB'; 2 | const { userManager } = initManagers(); 3 | 4 | export default async function validateUsernameParam(req, res, next) { 5 | const username = req.params.username; 6 | // check if username exists in db 7 | const valid = await userManager.verifyUsername(username); 8 | if (valid) { 9 | next(); 10 | } else { 11 | res.sendStatus(400); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/validation/validateAccessJWT.ts: -------------------------------------------------------------------------------- 1 | import jwt = require('jsonwebtoken'); 2 | export default function validateAccessJWT(req, res, next) { 3 | const authHeader = req.headers.authorization; 4 | if (!authHeader) { 5 | return res.sendStatus(401); 6 | } 7 | const token = authHeader.split(' ')[1]; 8 | try { 9 | jwt.verify(token, process.env.ACCESS_JWT_SECRET); 10 | } catch (e) { 11 | return res.sendStatus(401); 12 | } 13 | next(); 14 | } 15 | -------------------------------------------------------------------------------- /server/schemas/postRequestSchema.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Segments } from 'celebrate'; 2 | 3 | export default { 4 | [Segments.QUERY]: Joi.object() 5 | .required() 6 | .keys({ 7 | page: Joi.string().optional(), 8 | number: Joi.number().integer().optional(), 9 | sort: Joi.string() 10 | .valid('default', 'recent', 'most-upvotes', 'oldest') 11 | .optional(), 12 | parent: Joi.string().optional(), 13 | returnWithComments: Joi.boolean().optional(), 14 | search: Joi.string().optional() 15 | }) 16 | }; 17 | -------------------------------------------------------------------------------- /server/validation/validateUsernameCookie.ts: -------------------------------------------------------------------------------- 1 | import { initManagers } from '../db/initDB'; 2 | const { userManager } = initManagers(); 3 | 4 | export default async function validateUsernameCookie(req, res, next) { 5 | const username = req.cookies.username; 6 | const refreshToken = req.cookies.refreshToken; 7 | const validUsername = await userManager.findRefreshToken(refreshToken); 8 | 9 | if (!username || validUsername !== username) { 10 | res.sendStatus(401); 11 | return; 12 | } 13 | 14 | next(); 15 | } 16 | -------------------------------------------------------------------------------- /server/validation/validateFile.ts: -------------------------------------------------------------------------------- 1 | import path = require('path'); 2 | /* eslint-disable node/no-callback-literal */ 3 | export default function validateFile(req, file, cb) { 4 | // Allowed ext 5 | const filetypes = /jpeg|jpg|png/; 6 | // Check ext 7 | const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); 8 | // Check mime 9 | const mimetype = filetypes.test(file.mimetype); 10 | 11 | if (mimetype && extname) { 12 | return cb(null, true); 13 | } else { 14 | cb('Error: Images Only!'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/Validation/validatePassword.js: -------------------------------------------------------------------------------- 1 | export default function validatePassword (password) { 2 | if (password.length < 8) { 3 | return 'password is too short'; 4 | } 5 | if (password.length > 20) { 6 | return 'password is too long'; 7 | } 8 | const numbers = /[0-9]/; 9 | const letters = /[a-zA-Z]/; 10 | if (!password.match(numbers)) { 11 | return 'password needs to include a number'; 12 | } 13 | if (!password.match(letters)) { 14 | return 'password needs to include a letter'; 15 | } 16 | 17 | return 'valid'; 18 | } 19 | -------------------------------------------------------------------------------- /local.env: -------------------------------------------------------------------------------- 1 | URI=mongodb://localhost:27017/?readPreference=primary&appname=MongoDB%20Compass&ssl=false 2 | PORT=3001 3 | # TOKEN_EXPIRATION_TIME: how long it takes for a JWT access token to expire in milliseconds 4 | TOKEN_EXPIRATION_TIME = 600000 5 | # MAX_COMMENT_DEPTH: the depth to which comments are fetched 6 | MAX_COMMENT_DEPTH = 3 7 | # Change this in production 8 | ACCESS_JWT_SECRET = '9hmvkQYqwPyM5AwqirC8' 9 | REFRESH_JWT_SECRET = 'UeQ31thCgXWBqMvoBocC' 10 | # Change to PRODUCTION to serve the build folder with express 11 | MODE = 'DEVELOPMENT' -------------------------------------------------------------------------------- /client/src/constants.js: -------------------------------------------------------------------------------- 1 | // POSTS_PER_PAGE: how many posts are displayed per one page 2 | const POSTS_PER_PAGE = 20; 3 | // REFRESH_TOKEN_TIME: how often a request gets sent to the server to create a new access token. 4 | // Make sure this number is less the the token expiration time in .env 5 | const REFRESH_TOKEN_TIME = 500 * 1000; 6 | // MAX_COMMENT_DEPTH: the depth to which comments are displayed. 7 | // Should be same as MAX_COMMENT_DEPTH in .env 8 | const MAX_COMMENT_DEPTH = 3; 9 | const API_URL = 'https://nodejs-react-forum.herokuapp.com'; 10 | export { POSTS_PER_PAGE, REFRESH_TOKEN_TIME, MAX_COMMENT_DEPTH, API_URL }; 11 | -------------------------------------------------------------------------------- /server/notifications/Publisher.ts: -------------------------------------------------------------------------------- 1 | interface ISubscription { 2 | [key: string]: () => void; 3 | } 4 | 5 | export default class Publisher { 6 | subscriptions: ISubscription; 7 | constructor(subscriptions: ISubscription) { 8 | this.subscriptions = subscriptions; 9 | } 10 | 11 | subscribe(id: string, fn: () => void) { 12 | this.subscriptions[id] = fn; 13 | } 14 | 15 | unsubscribe(id: string) { 16 | delete this.subscriptions[id]; 17 | } 18 | 19 | async notify(id: string[]) { 20 | for (const curId of id) { 21 | if (this.subscriptions[curId]) { 22 | this.subscriptions[curId](); 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/Components/UserDashboard/UserDashboard.module.css: -------------------------------------------------------------------------------- 1 | .imgContainer { 2 | width: 100px; 3 | height: 100px; 4 | border-radius: 50%; 5 | background-position: center center; 6 | background-size: cover; 7 | position: relative; 8 | } 9 | 10 | .editIcon { 11 | position: absolute; 12 | bottom: 90%; 13 | left: 0; 14 | color: #fff; 15 | } 16 | 17 | .background { 18 | padding: 20px; 19 | background: 20 | linear-gradient(to top, white, white) repeat-x center, 21 | linear-gradient(to top, transparent 50%, #007bff 50%), 22 | linear-gradient(to bottom, transparent 50%, #fff 50%); 23 | background-size: 24 | 1px 4px, 25 | 100% 100%, 26 | 100% 100%; 27 | } 28 | 29 | .text { 30 | text-align: center; 31 | } 32 | -------------------------------------------------------------------------------- /server/db/JobManager.ts: -------------------------------------------------------------------------------- 1 | import * as models from './models'; 2 | import mongoose = require('mongoose'); 3 | 4 | export class JobManager { 5 | model: mongoose.Model; 6 | 7 | constructor() { 8 | this.model = mongoose.model('Jobs', models.JobSchema); 9 | } 10 | 11 | async addJob( 12 | data: Partial, 13 | fn: Function 14 | ): Promise { 15 | return ( 16 | await this.model.create({ 17 | data, 18 | value: fn.toString() 19 | }) 20 | )._id; 21 | } 22 | 23 | deleteJob(id: mongoose.Types.ObjectId): void { 24 | this.model.deleteOne({ _id: id }).exec(); 25 | } 26 | 27 | getAll() { 28 | return this.model.find().exec(); 29 | } 30 | 31 | deleteAll() { 32 | this.model.remove({}).exec(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/db/initDB.ts: -------------------------------------------------------------------------------- 1 | import { PostManager } from './PostManager'; 2 | import { UserManager } from './UserManager'; 3 | import { JobManager } from './JobManager'; 4 | 5 | import mongoose = require('mongoose'); 6 | require('dotenv').config(); 7 | 8 | export async function initDB() { 9 | try { 10 | if (typeof process.env.URI !== 'string') { 11 | throw new Error('URI not set in .env'); 12 | } 13 | await mongoose.connect(process.env.URI as string); 14 | } catch (e) { 15 | console.log('Cannot connect to database'); 16 | throw e; 17 | } 18 | } 19 | 20 | export function initManagers() { 21 | const postManager = new PostManager(); 22 | const userManager = new UserManager(); 23 | const jobManager = new JobManager(); 24 | return { postManager, userManager, jobManager }; 25 | } 26 | -------------------------------------------------------------------------------- /client/src/Hooks/useSinglePostFetch.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import api from '../api.js'; 3 | export default function useSinglePostFetch(id, setPopup) { 4 | const [loading, setLoading] = useState(true); 5 | const [data, setData] = useState(); 6 | 7 | useEffect(() => { 8 | async function fetchData() { 9 | try { 10 | const res = await api.sendSinglePostRequest(id); 11 | if (res.status === 200) { 12 | setData(res.data); 13 | setLoading(false); 14 | } else { 15 | setPopup({ message: 'Something went wrong when fetching post' }); 16 | } 17 | } catch (e) { 18 | setPopup({ message: 'Something went wrong when fetching post' }); 19 | } 20 | } 21 | fetchData(); 22 | }, [id, setPopup]); 23 | 24 | return { loading, data }; 25 | } 26 | -------------------------------------------------------------------------------- /client/src/Requests/users.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | const logout = async () => axios.delete('/api/v1/logout'); 3 | const login = async (body) => axios.post('/api/v1/login', body); 4 | const signup = async (body) => axios.post('/api/v1/users', body); 5 | const sendReactionsRequest = async (username) => 6 | axios.get(`/api/v1/users/${username}/reactions`); 7 | const sendUserDataRequest = async (username) => 8 | axios.get(`/api/v1/users/${username}`); 9 | const sendChangeIconRequest = async (data, username) => 10 | axios.post(`/api/v1/users/${username}/icon`, data, { 11 | headers: { 12 | 'Content-Type': 'multipart/form-data' 13 | } 14 | }); 15 | export { 16 | logout, 17 | login, 18 | signup, 19 | sendReactionsRequest, 20 | sendUserDataRequest, 21 | sendChangeIconRequest 22 | }; 23 | -------------------------------------------------------------------------------- /client/src/Hooks/usePostsNumberFetch.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import api from '../api.js'; 3 | export default function usePostsNumberFetch(setPopup) { 4 | const [totalPosts, setTotalPosts] = useState(0); 5 | 6 | useEffect(() => { 7 | async function fetchData() { 8 | try { 9 | const res = await api.sendPostNumberRequest(); 10 | if (res.status === 200) { 11 | setTotalPosts(res.data); 12 | } else { 13 | setPopup({ 14 | message: 'Something went wrong when getting the number of posts.' 15 | }); 16 | } 17 | } catch (e) { 18 | console.log(e); 19 | setPopup({ 20 | message: 'Something went wrong when getting the number of posts.' 21 | }); 22 | } 23 | } 24 | fetchData(); 25 | }, [setPopup]); 26 | 27 | return totalPosts; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/Hooks/useUserDataFetch.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import api from '../api.js'; 3 | export default function useUserDataFetch(username, setPopup) { 4 | const [data, setData] = useState({}); 5 | useEffect(() => { 6 | async function fetchData() { 7 | if (!username) { 8 | setPopup({ message: 'Please login again' }); 9 | return; 10 | } 11 | try { 12 | const res = await api.sendUserDataRequest(username); 13 | if (res.status === 200) { 14 | setData(res.data); 15 | } else { 16 | setPopup({ message: 'Something went wrong when fetching user data' }); 17 | } 18 | } catch (e) { 19 | setPopup({ message: 'Something went wrong when fetching user data' }); 20 | } 21 | } 22 | fetchData(); 23 | }, [username, setPopup]); 24 | return data; 25 | } 26 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import App from './Components/App'; 2 | import PostCreator from './Components/PostCreator'; 3 | import PostPage from './Components/PostPage'; 4 | import LoginPage from './Components/LoginPage'; 5 | import SignupPage from './Components/SignupPage'; 6 | import ReactDOM from 'react-dom'; 7 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 8 | 9 | const AppRouter = () => { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | ReactDOM.render(, document.getElementById('root')); 24 | -------------------------------------------------------------------------------- /client/src/Hooks/useReactionsFetch.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import api from '../api.js'; 3 | export default function useReactionsFetch(username, setPopup) { 4 | const [reactions, setReactions] = useState({}); 5 | const [loading, setLoading] = useState(true); 6 | useEffect(() => { 7 | async function fetchData() { 8 | if (!username) { 9 | setLoading(false); 10 | return; 11 | } 12 | try { 13 | const res = await api.sendReactionsRequest(username); 14 | if (res.status === 200) { 15 | setReactions(res.data); 16 | } else { 17 | setPopup({ message: 'Something went wrong when fetching reactions' }); 18 | } 19 | } catch (e) { 20 | setPopup({ message: 'Something went wrong when fetching reactions' }); 21 | } 22 | setLoading(false); 23 | } 24 | fetchData(); 25 | }, [username, setPopup]); 26 | 27 | return { reactions, loading }; 28 | } 29 | -------------------------------------------------------------------------------- /client/src/Hooks/useCommentsFetch.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import api from '../api.js'; 3 | export default function useCommentsFetch( 4 | parent, 5 | setPopup, 6 | returnWithComments = true 7 | ) { 8 | const [comments, setComments] = useState([]); 9 | 10 | useEffect(() => { 11 | async function fetchData() { 12 | try { 13 | const res = await api.sendPostCommentsRequest( 14 | parent, 15 | returnWithComments 16 | ); 17 | if (res.status === 200) { 18 | setComments(res.data); 19 | } else { 20 | setPopup({ 21 | message: 'Something went wrong when fetching the comments' 22 | }); 23 | } 24 | } catch (e) { 25 | setPopup({ 26 | message: 'Something went wrong when fetching the comments' 27 | }); 28 | console.log(e); 29 | } 30 | } 31 | fetchData(); 32 | }, [parent, returnWithComments, setPopup]); 33 | 34 | return comments; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/Hooks/usePostsPaginationFetch.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import api from '../api.js'; 3 | export default function usePostsPaginationFetch( 4 | currentPage, 5 | postsPerPage, 6 | filterOptions, 7 | setPopup 8 | ) { 9 | const [posts, setPosts] = useState([]); 10 | 11 | useEffect(() => { 12 | async function fetchData() { 13 | try { 14 | const res = await api.sendPostsPageRequest( 15 | currentPage, 16 | postsPerPage, 17 | filterOptions 18 | ); 19 | if (res.status === 200) { 20 | setPosts(res.data); 21 | } else { 22 | setPopup({ 23 | message: 'Something went wrong when fetching the posts.' 24 | }); 25 | } 26 | } catch (e) { 27 | setPopup({ message: 'Something went wrong when fetching the posts.' }); 28 | } 29 | } 30 | fetchData(); 31 | // eslint-disable-next-line react-hooks/exhaustive-deps 32 | }, [currentPage, postsPerPage, filterOptions.sort, filterOptions.search]); 33 | 34 | return posts; 35 | } 36 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "standard", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 12, 18 | "sourceType": "module" 19 | }, 20 | "plugins": ["react", "node", "@typescript-eslint"], 21 | "rules": { 22 | "semi": [2, "always"], 23 | "indent": ["error", "tab", { "SwitchCase": 1 }], 24 | "no-tabs": 0, 25 | "camelcase": 2, 26 | "react/prop-types": "off", 27 | "operator-linebreak": 0, 28 | "react/react-in-jsx-scope": 0, 29 | "no-undef": "off", 30 | "node/global-require": "error", 31 | "space-before-function-paren": ["off", "never"], 32 | "@typescript-eslint/explicit-module-boundary-types": "off", 33 | "@typescript-eslint/ban-types": "off", 34 | "@typescript-eslint/no-var-requires": "off", 35 | "@typescript-eslint/keyword-spacing": ["error"], 36 | "@typescript-eslint/no-explicit-any": ["off"] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/routes/posts/index.ts: -------------------------------------------------------------------------------- 1 | import { celebrate } from 'celebrate'; 2 | import validateAccessJWT from '../../validation/validateAccessJWT'; 3 | import postSchema from '../../schemas/postSchema'; 4 | import postRequestSchema from '../../schemas/postRequestSchema'; 5 | import postController from '../../controllers/postController'; 6 | import wrap from '../../controllers/wrap'; 7 | import express = require('express'); 8 | const app = express(); 9 | 10 | // submit a post 11 | app.post('/api/v1/posts', validateAccessJWT, celebrate(postSchema), wrap(postController.postPosts)); 12 | 13 | // retrieve posts 14 | app.get('/api/v1/posts', celebrate(postRequestSchema), wrap(postController.getPosts)); 15 | 16 | // retrieve a post based on id 17 | app.get('/api/v1/posts/:id', wrap(postController.getPostsId)); 18 | 19 | app.get('/api/v1/posts-number', wrap(postController.getPostsNumber)); 20 | 21 | app.post('/api/v1/posts/:id/upvote', validateAccessJWT, wrap(postController.upvotePost)); 22 | 23 | app.post('/api/v1/posts/:id/downvote', validateAccessJWT, wrap(postController.downvotePost)); 24 | 25 | app.post('/api/v1/posts/:id/remove-reactions', validateAccessJWT, wrap(postController.removePostReactions)); 26 | 27 | module.exports = app; 28 | -------------------------------------------------------------------------------- /client/src/Components/Navbar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navbar, Nav } from 'react-bootstrap'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import Cookies from 'js-cookie'; 5 | import api from '../../api.js'; 6 | 7 | const NavbarComponent = () => { 8 | async function logoutUser() { 9 | Cookies.remove('accessToken'); 10 | Cookies.remove('username'); 11 | await api.logout(); 12 | } 13 | return ( 14 | 15 | Forum 16 | 17 | 18 | 21 | {Cookies.get('username') ? ( 22 | 23 | {Cookies.get('username')} 24 | 25 | Log Out 26 | 27 | 28 | ) : ( 29 | 30 | 31 | Sign Up 32 | 33 | Log In 34 | 35 | )} 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default NavbarComponent; 42 | -------------------------------------------------------------------------------- /server/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import User from './User'; 2 | import Publisher from './Publisher'; 3 | import { PostManager } from '../db/PostManager'; 4 | const users = {}; 5 | export const publisher = new Publisher({}); 6 | 7 | export async function registerUser( 8 | username, 9 | notifyFn: (message: string) => void, 10 | postManager: PostManager 11 | ) { 12 | if (users && !users[username]) { 13 | users[username] = new User(notifyFn); 14 | const postIds = await postManager.getUserPosts(username); 15 | // save the user on the users object and subscribe them to all the posts they made 16 | postIds.forEach((el) => subscribeUser(username, el._id)); 17 | } 18 | } 19 | 20 | export function subscribeUser(username, postId) { 21 | if (users?.[username] && publisher) { 22 | // subscribe the user with a function that will send the user the postID 23 | const fn = () => users[username].notify(`data: ${postId}\n\n`); 24 | if (publisher) { 25 | publisher.subscribe(postId, fn); 26 | } 27 | } 28 | } 29 | 30 | export async function unsubscribeUser(username, postManager: PostManager) { 31 | if (users?.[username] && publisher) { 32 | const postIds = await postManager.getUserPosts(username); 33 | // delete the user from the users object and unsubscribe them from all the posts they made 34 | postIds.forEach((el) => publisher.unsubscribe(el._id)); 35 | delete users[username]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/global-require */ 2 | import { initDB } from './db/initDB'; 3 | import { errors } from 'celebrate'; 4 | import { errorHandler } from './errorHandler'; 5 | import express = require('express'); 6 | import bodyParser = require('body-parser'); 7 | import cookieParser = require('cookie-parser'); 8 | import path = require('path'); 9 | require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); 10 | const app = express(); 11 | 12 | initDB() 13 | .then(() => { 14 | const posts = require('./routes/posts/index'); 15 | const users = require('./routes/users/index'); 16 | if (process.env.MODE === 'PRODUCTION') { 17 | // serve the built app statically 18 | app.use(express.static(path.join(__dirname, '../client/build'))); 19 | } 20 | app.use(cookieParser()); 21 | app.use(bodyParser.json()); 22 | app.use(posts); 23 | app.use(users); 24 | if (process.env.MODE === 'PRODUCTION') { 25 | // serve the index.html file for any request, if it is not an api request 26 | app.get('/*', function (req, res) { 27 | res.sendFile(path.join(__dirname, '../client/build', 'index.html')); 28 | }); 29 | } 30 | app.use(errors()); 31 | app.use(errorHandler); 32 | app.listen(process.env.PORT, () => 33 | console.log('listening on %d', process.env.PORT) 34 | ); 35 | }) 36 | .catch((error) => { 37 | console.error(error); 38 | }); 39 | 40 | export { app }; 41 | -------------------------------------------------------------------------------- /client/src/Requests/posts.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | const sendPostNumberRequest = async () => axios.get('/api/v1/posts-number'); 3 | const sendPostsPageRequest = async ( 4 | currentPage, 5 | postsPerPage, 6 | filterOptions 7 | ) => { 8 | const requestString = '/api/v1/posts?'; 9 | const searchParams = new URLSearchParams( 10 | `page=${currentPage}&number=${postsPerPage}` 11 | ); 12 | for (const [key, value] of Object.entries(filterOptions)) { 13 | searchParams.append(key, value); 14 | } 15 | return axios.get(requestString + searchParams.toString()); 16 | }; 17 | const sendPostCommentsRequest = async (parent, returnWithComments) => 18 | axios.get( 19 | `/api/v1/posts?parent=${parent}&returnWithComments=${returnWithComments}` 20 | ); 21 | const sendPostsRequest = async () => axios.get('/api/v1/posts'); 22 | const sendPostSubmitRequest = async (body) => axios.post('/api/v1/posts', body); 23 | const sendSinglePostRequest = async (id) => axios.get(`/api/v1/posts/${id}`); 24 | const sendUpvotePostRequest = async (id) => 25 | axios.post(`/api/v1/posts/${id}/upvote`); 26 | const sendDownvotePostRequest = async (id) => 27 | axios.post(`/api/v1/posts/${id}/downvote`); 28 | const sendRemovePostReactionsRequest = async (id) => 29 | axios.post(`/api/v1/posts/${id}/remove-reactions`); 30 | export { 31 | sendPostNumberRequest, 32 | sendPostsPageRequest, 33 | sendPostCommentsRequest, 34 | sendPostsRequest, 35 | sendPostSubmitRequest, 36 | sendSinglePostRequest, 37 | sendUpvotePostRequest, 38 | sendDownvotePostRequest, 39 | sendRemovePostReactionsRequest 40 | }; 41 | -------------------------------------------------------------------------------- /client/src/Components/Pagination/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Pagination } from 'react-bootstrap'; 3 | // helper function for generating a range (1,3,1) -> [1,2,3] 4 | const range = (start, end) => 5 | new Array(end - start + 1).fill().map((el, ind) => ind + start); 6 | 7 | const PaginationBar = ({ currentPage, setPage, totalPosts, perPage }) => { 8 | const totalPages = Math.ceil(totalPosts / perPage); 9 | const [shownPages, setShownPages] = useState( 10 | range(1, Math.min(totalPages, 3)) 11 | ); 12 | function correctShownPages(value) { 13 | if (value === 1) { 14 | setShownPages(range(value, Math.min(totalPages, value + 2))); 15 | } else if (value === totalPages) { 16 | setShownPages(range(Math.max(1, value - 2), value)); 17 | } else { 18 | setShownPages(range(value - 1, value + 1)); 19 | } 20 | } 21 | 22 | const proccessNewPage = (page) => () => { 23 | page = Math.max(1, page); 24 | page = Math.min(totalPages, page); 25 | correctShownPages(page); 26 | setPage(page); 27 | }; 28 | return ( 29 | 30 | 31 | 32 | {shownPages.map((value, index) => { 33 | return ( 34 | 39 | {value} 40 | 41 | ); 42 | })} 43 | 44 | 45 | 46 | ); 47 | }; 48 | export default PaginationBar; 49 | -------------------------------------------------------------------------------- /server/scheduling/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { initManagers } from '../db/initDB'; 2 | import { PostManager } from '../db/PostManager'; 3 | import { UserManager } from '../db/UserManager'; 4 | import { IPost } from '../db/models'; 5 | import mongoose = require('mongoose'); 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | const { jobManager, postManager, userManager } = initManagers(); 9 | const lt = require('long-timeout'); 10 | 11 | // run all the tasks which are in the database 12 | jobManager.getAll().then((res) => { 13 | res.forEach((elem) => { 14 | // eslint-disable-next-line no-new-func 15 | const fn = new Function('return ' + elem.value)(); 16 | scheduleJob(elem.data, fn, elem._id); 17 | }); 18 | }); 19 | 20 | function dateDiff(dateOne: Date, dateTwo: Date): number { 21 | return dateTwo.getTime() - dateOne.getTime(); 22 | } 23 | 24 | export async function scheduleJob( 25 | postData: Partial, 26 | fn: ( 27 | postManager: PostManager, 28 | userManager: UserManager, 29 | argsObj: Partial 30 | ) => Promise, 31 | id?: mongoose.Types.ObjectId 32 | ): Promise { 33 | let time = dateDiff(new Date(), postData.date); 34 | time = Math.max(time, 0); 35 | 36 | const resolveJob = async () => { 37 | try { 38 | await fn(postManager, userManager, postData); 39 | jobManager.deleteJob(id); 40 | } catch (err) { 41 | console.error(err.stack); 42 | console.log('Error resolving job'); 43 | } 44 | }; 45 | 46 | try { 47 | if (!id) { 48 | id = await jobManager.addJob(postData, fn); 49 | } 50 | 51 | lt.setTimeout(resolveJob, time); 52 | 53 | return true; 54 | } catch (err) { 55 | console.error(err.stack); 56 | console.log('Error adding schedule post'); 57 | return false; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import axios from 'axios'; 3 | import Cookies from 'js-cookie'; 4 | import { 5 | sendPostSubmitRequest, 6 | sendPostsRequest, 7 | sendPostCommentsRequest, 8 | sendPostsPageRequest, 9 | sendSinglePostRequest, 10 | sendPostNumberRequest, 11 | sendUpvotePostRequest, 12 | sendDownvotePostRequest, 13 | sendRemovePostReactionsRequest 14 | } from './Requests/posts.js'; 15 | import { 16 | logout, 17 | signup, 18 | login, 19 | sendReactionsRequest, 20 | sendUserDataRequest, 21 | sendChangeIconRequest 22 | } from './Requests/users.js'; 23 | // eslint-disable-next-line import/no-webpack-loader-syntax 24 | import Worker from 'worker-loader!./Worker.js'; 25 | 26 | const workerInstance = Worker(); 27 | 28 | // request interceptor to add the auth token header to requests 29 | axios.interceptors.request.use( 30 | (config) => { 31 | if (Cookies.get('accessToken')) { 32 | config.headers.Authorization = `Bearer ${Cookies.get('accessToken')}`; 33 | } 34 | return config; 35 | }, 36 | (error) => { 37 | Promise.reject(error); 38 | } 39 | ); 40 | 41 | // prevent axios from returning an error and instead just return the response 42 | axios.interceptors.response.use( 43 | function (response) { 44 | return response; 45 | }, 46 | function (error) { 47 | return error.response; 48 | } 49 | ); 50 | 51 | // functions to make api calls 52 | const api = { 53 | sendPostSubmitRequest, 54 | sendPostsRequest, 55 | sendPostsPageRequest, 56 | sendPostCommentsRequest, 57 | sendSinglePostRequest, 58 | sendPostNumberRequest, 59 | sendUpvotePostRequest, 60 | sendDownvotePostRequest, 61 | sendRemovePostReactionsRequest, 62 | logout, 63 | signup, 64 | login, 65 | sendReactionsRequest, 66 | sendUserDataRequest, 67 | sendChangeIconRequest 68 | }; 69 | export default api; 70 | -------------------------------------------------------------------------------- /client/src/Components/PostMenu/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styles from './PostMenu.module.css'; 3 | import { Form } from 'react-bootstrap'; 4 | 5 | const PostMenu = (props) => { 6 | const formElement = React.useRef(); 7 | const [searchText, setSearchText] = useState(''); 8 | 9 | function changeSortOption(e) { 10 | const formData = new FormData(formElement.current); 11 | const sort = formData.get('select'); 12 | props.setFilterOptions({ ...props.filterOptions, sort: sort }); 13 | } 14 | 15 | function handleSearchChange(e) { 16 | setSearchText(e.target.value); 17 | } 18 | 19 | function setFilterOptions(e) { 20 | if (searchText !== '') { 21 | props.setFilterOptions({ ...props.filterOptions, search: searchText }); 22 | } else { 23 | props.setFilterOptions((filterOptions) => { 24 | const newFilterOptions = { ...filterOptions }; 25 | delete newFilterOptions.search; 26 | return newFilterOptions; 27 | }); 28 | } 29 | } 30 | 31 | return ( 32 |
37 |

Sort by

38 | 49 | 55 | 62 |
63 | ); 64 | }; 65 | 66 | export default PostMenu; 67 | -------------------------------------------------------------------------------- /client/src/Components/LoginPage/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import 'bootstrap/dist/css/bootstrap.css'; 3 | import { Form } from 'react-bootstrap'; 4 | import { useHistory } from 'react-router'; 5 | import NavbarComponent from '../Navbar'; 6 | import api from '../../api.js'; 7 | import Popup from '../Popup'; 8 | 9 | const LoginPage = (props) => { 10 | const history = useHistory(); 11 | const [popup, setPopup] = useState({}); 12 | 13 | async function handleClick(e) { 14 | e.preventDefault(); 15 | const formElement = e.currentTarget; 16 | const formData = new FormData(formElement); 17 | const username = formData.get('username'); 18 | const password = formData.get('password'); 19 | const res = await api.login({ 20 | username, 21 | password 22 | }); 23 | if (res.status === 200) { 24 | history.push('/'); 25 | } else { 26 | setPopup({ message: 'Incorrect username or password' }); 27 | } 28 | } 29 | return ( 30 | <> 31 | 32 | {popup.message && } 33 |
42 |
43 | 44 | Username 45 | 51 | 52 | 53 | Password 54 | 61 | 62 | 65 |
66 |
67 | 68 | ); 69 | }; 70 | 71 | export default LoginPage; 72 | -------------------------------------------------------------------------------- /client/src/Components/Reactions/index.js: -------------------------------------------------------------------------------- 1 | import api from '../../api.js'; 2 | const Reactions = (props) => { 3 | async function handleUpvote() { 4 | let res; 5 | if (props.status === 1) { 6 | // already liked the post 7 | res = await api.sendRemovePostReactionsRequest(props.postID); 8 | } else { 9 | res = await api.sendUpvotePostRequest(props.postID); 10 | } 11 | if (res.status === 200) { 12 | if (props.status === -1) { 13 | // already disliked, increase upvotes by 2 14 | props.setUpvotes(props.upvotes + 2); 15 | props.setStatus(1); 16 | } else if (props.status !== 1) { 17 | props.setUpvotes(props.upvotes + 1); 18 | props.setStatus(1); 19 | } else { 20 | props.setUpvotes(props.upvotes - 1); 21 | props.setStatus(0); 22 | } 23 | } 24 | } 25 | 26 | async function handleDownvote() { 27 | let res; 28 | if (props.status === -1) { 29 | // already disliked the post 30 | res = await api.sendRemovePostReactionsRequest(props.postID); 31 | } else { 32 | res = await api.sendDownvotePostRequest(props.postID); 33 | } 34 | if (res.status === 200) { 35 | if (props.status === 1) { 36 | // already liked, decrease upvotes by 2 37 | props.setUpvotes(props.upvotes - 2); 38 | props.setStatus(-1); 39 | } else if (props.status !== -1) { 40 | props.setUpvotes(props.upvotes - 1); 41 | props.setStatus(-1); 42 | } else { 43 | props.setUpvotes(props.upvotes + 1); 44 | props.setStatus(0); 45 | } 46 | } 47 | } 48 | return ( 49 | <> 50 | 59 | 68 | 69 | ); 70 | }; 71 | 72 | export default Reactions; 73 | -------------------------------------------------------------------------------- /tests/mocks/mockInitDB.ts: -------------------------------------------------------------------------------- 1 | import sinon = require('sinon'); 2 | export default { 3 | initManagers: function () { 4 | class PostManager { 5 | getPost = sinon.stub().resolves('test'); 6 | 7 | getAllPosts = sinon 8 | .stub() 9 | .resolves(['test', 'test', 'test', 'test', 'test']); 10 | 11 | addPost = sinon.stub().resolves('testid'); 12 | 13 | getNumberOfPosts = sinon.stub().resolves(5); 14 | 15 | getPostsPage = sinon.stub().resolves(['test', 'test', 'test']); 16 | 17 | downvotePost = sinon.stub().resolves(true); 18 | 19 | upvotePost = sinon.stub().resolves(true); 20 | 21 | removeReactions = sinon.stub(); 22 | } 23 | class UserManager { 24 | addUser = sinon.stub().resolves('success'); 25 | 26 | addRefreshToken = sinon.stub(); 27 | 28 | deleteRefreshToken = sinon.stub(); 29 | 30 | findRefreshToken = sinon.stub().resolves('testUsername'); 31 | 32 | verifyUser = sinon.stub().callsFake((username, password) => { 33 | return Promise.resolve( 34 | username === 'testUsername' && password === 'testPassword' 35 | ); 36 | }); 37 | 38 | addPostUpvote = sinon.stub().resolves(true); 39 | 40 | addPostDownvote = sinon.stub().resolves(true); 41 | 42 | removePostDownvote = sinon.stub().resolves(true); 43 | 44 | removePostUpvote = sinon.stub().resolves(true); 45 | 46 | getUserReactions = sinon.stub().resolves({ downvotes: {}, upvotes: {} }); 47 | 48 | getUserData = sinon 49 | .stub() 50 | .resolves({ username: 'testUsername', upvotes: 0, downvotes: 0 }); 51 | 52 | updateIconPath = sinon.stub().resolves('testPath'); 53 | 54 | getIconPathRes = { all: ['testPath', null], cur: 0 }; 55 | 56 | getIconPath = sinon.stub().callsFake(() => { 57 | const curInd = this.getIconPathRes.cur; 58 | // Cycle to the next possible output for testing 59 | this.getIconPathRes.cur += 1; 60 | this.getIconPathRes.cur %= 2; 61 | return Promise.resolve(this.getIconPathRes.all[curInd]); 62 | }); 63 | 64 | verifyUsername = sinon.stub().callsFake((username) => { 65 | return Promise.resolve(username === 'testUsername'); 66 | }); 67 | } 68 | 69 | return { postManager: new PostManager(), userManager: new UserManager() }; 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /client/src/Components/Post/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import 'bootstrap/dist/css/bootstrap.css'; 3 | import styles from './Post.module.css'; 4 | import Reactions from '../Reactions'; 5 | import { Link } from 'react-router-dom'; 6 | 7 | const Post = (props) => { 8 | const [upvotes, setUpvotes] = useState(props.upvotes); 9 | const [status, setStatus] = useState(props.status); 10 | 11 | const dropDownText = React.useRef(); 12 | function swapDropdown() { 13 | if ( 14 | dropDownText.current.style.height === '0px' || 15 | dropDownText.current.style.height === '' 16 | ) { 17 | dropDownText.current.classList.add(styles.dropdown_hover); 18 | dropDownText.current.style.height = `${dropDownText.current.scrollHeight}px`; 19 | } else { 20 | dropDownText.current.classList.remove(styles.dropdown_hover); 21 | dropDownText.current.style.height = '0'; 22 | } 23 | } 24 | return ( 25 | <> 26 |
27 |
28 |
29 |

{props.title}

30 |
By: {props.author}
31 | 32 | Reply 33 | 34 |
35 |
36 |

{upvotes}

37 | 45 | 50 | Visit 51 | 52 | 60 |
61 |
62 |
63 |

{props.body}

64 |
65 |
66 | 67 | ); 68 | }; 69 | 70 | export default Post; 71 | -------------------------------------------------------------------------------- /server/db/models.ts: -------------------------------------------------------------------------------- 1 | import mongoose = require('mongoose'); 2 | 3 | interface IPost extends mongoose.Document { 4 | title: string; 5 | body: string; 6 | upvotes: number; 7 | author: string; 8 | date: Date; 9 | parent?: mongoose.Types.ObjectId; 10 | } 11 | 12 | const PostSchema = new mongoose.Schema( 13 | { 14 | title: { 15 | type: String 16 | }, 17 | body: { 18 | type: String 19 | }, 20 | upvotes: { 21 | type: Number 22 | }, 23 | author: { 24 | type: String 25 | }, 26 | date: { 27 | type: Date 28 | }, 29 | parent: { 30 | type: mongoose.Types.ObjectId 31 | } 32 | }, 33 | { minimize: false } 34 | ); 35 | 36 | // Index the title of the post and parent of the post for searching 37 | PostSchema.index({ title: 'text', parent: 1, author: 1 }); 38 | 39 | interface IUser extends mongoose.Document { 40 | username: string; 41 | password: string; 42 | upvotes: object; 43 | downvotes: object; 44 | refreshToken: string; 45 | reputation: number; 46 | numberOfPosts: number; 47 | iconPath: string; 48 | } 49 | 50 | const UserSchema = new mongoose.Schema( 51 | { 52 | username: { 53 | type: String, 54 | required: true, 55 | unique: true 56 | }, 57 | password: { 58 | type: String, 59 | required: true 60 | }, 61 | upvotes: { 62 | type: mongoose.Schema.Types.Mixed, 63 | required: true 64 | }, 65 | downvotes: { 66 | type: mongoose.Schema.Types.Mixed, 67 | required: true 68 | }, 69 | refreshToken: { 70 | type: String 71 | }, 72 | reputation: { 73 | type: Number 74 | }, 75 | numberOfPosts: { 76 | type: Number 77 | }, 78 | iconPath: { 79 | type: String 80 | } 81 | }, 82 | { minimize: false } 83 | ); 84 | 85 | // Index the title of the post and parent of the post for searching 86 | UserSchema.index({ username: 1 }); 87 | 88 | interface IJob extends mongoose.Document { 89 | value: String; 90 | data: Partial; 91 | } 92 | 93 | const JobSchema = new mongoose.Schema({ 94 | value: { 95 | type: String, 96 | required: true 97 | }, 98 | data: { 99 | type: Object 100 | } 101 | }); 102 | 103 | export { IPost }; 104 | export { PostSchema }; 105 | export { IUser }; 106 | export { UserSchema }; 107 | export { IJob }; 108 | export { JobSchema }; 109 | -------------------------------------------------------------------------------- /client/src/Components/Notifications/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { useEffect, useState } from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import { API_URL } from '../../constants'; 5 | const Notifications = ({ username }) => { 6 | const [notifications, setNotifications] = useState( 7 | localStorage.getItem('notifications') 8 | ? JSON.parse(localStorage.getItem('notifications')) 9 | : [] 10 | ); 11 | 12 | // for opening and EventSource with the server 13 | useEffect(() => { 14 | function addNotification(message, link) { 15 | setNotifications((oldArray) => { 16 | if (!oldArray.some((e) => e.link === link)) { 17 | return [...oldArray, { message, link }]; 18 | } else { 19 | return oldArray; 20 | } 21 | }); 22 | } 23 | let eventSource; 24 | if (username) { 25 | eventSource = new EventSource( 26 | `${API_URL}/api/v1/users/${username}/notifications`, 27 | { withCredentials: true } 28 | ); 29 | eventSource.onmessage = (e) => { 30 | addNotification('A comment was added to your post', e.data); 31 | }; 32 | } 33 | return function cleanup() { 34 | if (eventSource) { 35 | eventSource.close(); 36 | } 37 | }; 38 | }, [username]); 39 | 40 | // for updating localStorage 41 | useEffect(() => { 42 | localStorage.setItem('notifications', JSON.stringify(notifications)); 43 | }, [notifications]); 44 | 45 | function deleteNotification(link, sync) { 46 | const newNotifications = notifications.filter((e) => e.link !== link); 47 | if (sync) { 48 | // have to syncronously set local storage if we are visiting a new page 49 | // as setting state may happen too late 50 | localStorage.setItem('notifications', JSON.stringify(newNotifications)); 51 | } 52 | setNotifications(newNotifications); 53 | } 54 | 55 | return ( 56 |
57 | {notifications.map((el, i) => ( 58 |
59 |
60 |

{el.message}

61 |

deleteNotification(el.link)} 63 | style={{ cursor: 'default' }} 64 | > 65 | ✕ 66 |

67 |
68 | deleteNotification(el.link, true)} 71 | > 72 | Visit Post 73 | 74 |
75 | ))} 76 |
77 | ); 78 | }; 79 | 80 | export default Notifications; 81 | -------------------------------------------------------------------------------- /client/src/Components/UserDashboard/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-unused-vars 2 | import React, { useState } from 'react'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import styles from './UserDashboard.module.css'; 5 | import useUserDataFetch from '../../Hooks/useUserDataFetch'; 6 | import api from '../../api'; 7 | import Cookies from 'js-cookie'; 8 | 9 | const UserDashboard = ({ username, setPopup }) => { 10 | const data = useUserDataFetch(Cookies.get('username'), setPopup); 11 | const hiddenInput = React.useRef(); 12 | 13 | async function handleChange(e) { 14 | const formData = new FormData(); 15 | formData.append('image', e.target.files[0]); 16 | const res = await api.sendChangeIconRequest( 17 | formData, 18 | Cookies.get('username') 19 | ); 20 | if (res.status === 200) { 21 | window.location.reload(); 22 | } else if (res.status === 401) { 23 | setPopup({ message: 'Something went wrong. Please re-login again.' }); 24 | } else { 25 | setPopup({ message: 'That is not a valid type.' }); 26 | } 27 | } 28 | 29 | function handleClick() { 30 | // trigger the click event of the hidden "choose image" input 31 | if (hiddenInput.current) hiddenInput.current.click(); 32 | } 33 | return ( 34 |
35 |
36 |

37 | Edit Image 38 |

39 | 48 |
56 |
{username}
57 |
58 |
    59 |
  • 60 |
    Reputation
    61 |
    62 | {data.reputation} 63 |
    64 |
  • 65 |
  • 66 |
    Number of Posts
    67 |
    68 | {data.numberOfPosts} 69 |
    70 |
  • 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default UserDashboard; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Forum", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha -r ts-node/register tests/middleWare.ts", 8 | "start": "nodemon --watch '**' --ext 'ts,json' --exec 'npx ts-node server/index.ts'", 9 | "server": "node server/index.js", 10 | "client": "npm start --prefix client", 11 | "dev": "run-p server client", 12 | "prepare": "husky install" 13 | }, 14 | "husky": { 15 | "hooks": { 16 | "pre-commit": "npx lint-staged" 17 | } 18 | }, 19 | "lint-staged": { 20 | "**/*.{js,ts}": [ 21 | "eslint server/*/**.ts --fix", 22 | "eslint client/src/*/**.js --fix", 23 | "npx pretty-quick --staged" 24 | ], 25 | "**/*.css": [ 26 | "npx stylelint \"**/*.css\"" 27 | ] 28 | }, 29 | "keywords": [], 30 | "author": "", 31 | "license": "ISC", 32 | "dependencies": { 33 | "bcrypt": "^5.0.1", 34 | "body-parser": "^1.19.0", 35 | "celebrate": "^14.0.0", 36 | "cookie-parser": "^1.4.5", 37 | "cors": "^2.8.5", 38 | "dotenv": "^8.2.0", 39 | "express": "^4.17.1", 40 | "jsonwebtoken": "^8.5.1", 41 | "long-timeout": "^0.1.1", 42 | "mongodb": "^3.6.4", 43 | "mongoose": "^5.12.5", 44 | "multer": "^1.4.2", 45 | "uuid": "^8.3.2" 46 | }, 47 | "devDependencies": { 48 | "@types/bcrypt": "^3.0.0", 49 | "@types/body-parser": "^1.19.0", 50 | "@types/chai": "^4.2.16", 51 | "@types/cookie-parser": "^1.4.2", 52 | "@types/express": "^4.17.11", 53 | "@types/express-serve-static-core": "^4.17.19", 54 | "@types/jsonwebtoken": "^8.5.1", 55 | "@types/long-timeout": "^0.1.0", 56 | "@types/mocha": "^8.2.2", 57 | "@types/mongodb": "^3.6.12", 58 | "@types/mongoose": "^5.10.5", 59 | "@types/multer": "^1.4.5", 60 | "@types/node": "^14.14.37", 61 | "@types/proxyquire": "^1.3.28", 62 | "@types/sinon": "^10.0.2", 63 | "@types/uuid": "^8.3.0", 64 | "@typescript-eslint/eslint-plugin": "^4.22.0", 65 | "@typescript-eslint/parser": "^4.22.0", 66 | "chai": "^4.3.4", 67 | "eslint": "^7.21.0", 68 | "eslint-config-standard": "^16.0.2", 69 | "eslint-plugin-import": "^2.22.1", 70 | "eslint-plugin-node": "^11.1.0", 71 | "eslint-plugin-promise": "^4.3.1", 72 | "eslint-plugin-react": "^7.22.0", 73 | "husky": "^6.0.0", 74 | "lint-staged": "^11.0.0", 75 | "mocha": "^8.3.2", 76 | "nodemon": "^2.0.7", 77 | "npm-run-all": "^4.1.5", 78 | "prettier": "^2.3.1", 79 | "pretty-quick": "^3.1.1", 80 | "proxyquire": "^2.1.3", 81 | "sinon": "^10.0.0", 82 | "stylelint-config-standard": "^22.0.0", 83 | "stylint": "^2.0.0", 84 | "supertest": "^6.1.3", 85 | "ts-node": "^9.1.1", 86 | "typescript": "^4.2.4" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://localhost:3001", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "axios": "^0.21.1", 11 | "bootstrap": "^4.6.0", 12 | "js-cookie": "^2.2.1", 13 | "react": "^17.0.1", 14 | "react-bootstrap": "^1.5.2", 15 | "react-dom": "^17.0.1", 16 | "react-router-dom": "^5.2.0", 17 | "react-scripts": "4.0.3", 18 | "web-vitals": "^1.0.1", 19 | "worker-loader": "^3.0.8" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest", 31 | "standard" 32 | ], 33 | "rules": { 34 | "semi": [ 35 | 2, 36 | "always" 37 | ], 38 | "indent": [ 39 | "error", 40 | "tab" 41 | ], 42 | "space-before-function-paren": [ 43 | "off", 44 | "never" 45 | ], 46 | "multiline-ternary": [ 47 | "off", 48 | "never" 49 | ], 50 | "no-tabs": 0, 51 | "camelcase": 2, 52 | "react/prop-types": "off", 53 | "operator-linebreak": 0, 54 | "react/react-in-jsx-scope": 0, 55 | "no-undef": "off", 56 | "quote-props": [ 57 | "warn", 58 | "as-needed" 59 | ], 60 | "quotes": [ 61 | 1, 62 | "single" 63 | ], 64 | "jsx-quotes": [ 65 | 1, 66 | "prefer-single" 67 | ], 68 | "react/display-name": 0, 69 | "react/forbid-prop-types": 0, 70 | "react/jsx-boolean-value": 1, 71 | "react/jsx-closing-bracket-location": 1, 72 | "react/jsx-curly-spacing": 1, 73 | "react/jsx-handler-names": 1, 74 | "react/jsx-key": 1, 75 | "react/jsx-max-props-per-line": 0, 76 | "react/jsx-no-bind": 0, 77 | "react/jsx-no-duplicate-props": 1, 78 | "react/jsx-no-literals": 0, 79 | "react/jsx-no-undef": 1, 80 | "react/jsx-pascal-case": 1, 81 | "react/jsx-sort-prop-types": 0, 82 | "react/jsx-sort-props": 0, 83 | "react/jsx-uses-react": 1, 84 | "react/jsx-uses-vars": 1, 85 | "react/no-danger": 1, 86 | "react/no-deprecated": 1, 87 | "react/no-did-mount-set-state": 1, 88 | "react/no-did-update-set-state": 1, 89 | "react/no-direct-mutation-state": 1, 90 | "react/no-is-mounted": 1, 91 | "react/no-multi-comp": 0, 92 | "react/no-set-state": 1, 93 | "react/no-string-refs": 0, 94 | "react/no-unknown-property": 1, 95 | "react/prefer-es6-class": 1, 96 | "react/self-closing-comp": 1, 97 | "react/sort-comp": 1, 98 | "no-restricted-globals": 0, 99 | "react/jsx-props-no-multi-spaces": 1, 100 | "react/jsx-equals-spacing": [ 101 | 1, 102 | "never" 103 | ] 104 | } 105 | }, 106 | "browserslist": { 107 | "production": [ 108 | ">0.2%", 109 | "not dead", 110 | "not op_mini all" 111 | ], 112 | "development": [ 113 | "last 1 chrome version", 114 | "last 1 firefox version", 115 | "last 1 safari version" 116 | ] 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /server/routes/users/index.ts: -------------------------------------------------------------------------------- 1 | import { celebrate } from 'celebrate'; 2 | import userSchema from '../../schemas/userSchema'; 3 | import userController from '../../controllers/userController'; 4 | import validateRefreshJWT from '../../validation/validateRefreshJWT'; 5 | import verifyUser from '../../validation/verifyUser'; 6 | import wrap from '../../controllers/wrap'; 7 | import { initManagers } from '../../db/initDB'; 8 | import validateFile from '../../validation/validateFile'; 9 | import validateUsernameParam from '../../validation/validateUsernameParam'; 10 | import validateUsernameCookie from '../../validation/validateUsernameCookie'; 11 | import cors = require('cors'); 12 | import express = require('express'); 13 | import multer = require('multer'); 14 | import path = require('path'); 15 | 16 | const { userManager } = initManagers(); 17 | 18 | const store = multer.diskStorage({ 19 | destination: path.resolve(__dirname, '../../../uploads/'), 20 | filename: async function (req, file, cb) { 21 | const fileObj = { 22 | 'image/png': '.png', 23 | 'image/jpeg': '.jpeg', 24 | 'image/jpg': '.jpg' 25 | }; 26 | const refreshToken = req.cookies.refreshToken; 27 | const username = await userManager.findRefreshToken(refreshToken); 28 | cb(null, username + fileObj[file.mimetype]); 29 | } 30 | }); 31 | 32 | const upload = multer({ 33 | storage: store, 34 | fileFilter: validateFile 35 | }); 36 | 37 | const app = express(); 38 | 39 | // sign-up user 40 | app.post( 41 | '/api/v1/users', 42 | celebrate(userSchema), 43 | wrap(userController.postUsers) 44 | ); 45 | 46 | // log-in the user and return their 2 JWT tokens 47 | app.post('/api/v1/login', verifyUser, wrap(userController.login)); 48 | 49 | // return the access token providing that the refresh token is valid 50 | app.get( 51 | '/api/v1/token', 52 | validateRefreshJWT, 53 | wrap(userController.getAccessToken) 54 | ); 55 | 56 | // logout user 57 | app.delete('/api/v1/logout', validateRefreshJWT, wrap(userController.logout)); 58 | 59 | // get user reactions 60 | app.get( 61 | '/api/v1/users/:username/reactions', 62 | validateUsernameParam, 63 | wrap(userController.getUserReactions) 64 | ); 65 | 66 | // get user data such as reputation and number of posts 67 | app.get( 68 | '/api/v1/users/:username', 69 | validateUsernameParam, 70 | wrap(userController.getUserData) 71 | ); 72 | 73 | // change the user image 74 | app.post( 75 | '/api/v1/users/:username/icon', 76 | validateRefreshJWT, 77 | validateUsernameCookie, 78 | upload.single('image'), 79 | wrap(userController.changeUserIcon) 80 | ); 81 | 82 | // get the icon of a user 83 | app.get( 84 | '/api/v1/users/:username/icon', 85 | validateUsernameParam, 86 | wrap(userController.getUserIcon) 87 | ); 88 | 89 | // set up notifications 90 | // if the server runs on a different address and create-react-app is proxying SSE won't work so have to send request directly to server 91 | app.get( 92 | '/api/v1/users/:username/notifications', 93 | cors({ credentials: true, origin: process.env.APP_URL }), 94 | validateRefreshJWT, 95 | validateUsernameCookie, 96 | wrap(userController.setUpNotifications) 97 | ); 98 | 99 | module.exports = app; 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Forum 3 | 4 | A forum application built with NodeJS, Express, ReactJS and MongoDB. 5 | 6 | ### Features 7 | 8 | 1. Login System 9 | 2. Single post pages 10 | 3. Upvotes/Downvotes 11 | 4. Searching and filtering of posts 12 | 5. Nested comments system 13 | 6. JWT's 14 | 7. On-disc storage of user icons 15 | 16 | ### Demo Video 17 | 18 | https://user-images.githubusercontent.com/36348190/123466294-062e9100-d5a4-11eb-9747-3e7070a72a4d.mp4 19 | 20 | ### Questions 21 | 22 | Why JWTs? 23 | With the refresh/access token system, the app achieves a good balance between security and speed. 24 | Access tokens expire after a specific time, and you need to get refresh tokens from the database to get a new access token. If the access token is not expired, the app authenticates a request 25 | without reading from a database. 26 | 27 | Why MongoDB? 28 | Posts and comments are stored as one type of "document," and they have different attributes. As a result, you have some unstructured data, and MongoDB is better for this. 29 | There were also other instances of unstructured data that are easily implemented with MongoDB. 30 | 31 | Why On-Disk storage of user icons? 32 | Database storage is out of the question - it is too slow. The other two options were on disk storage or cloud storage. 33 | Disk just makes more sense for a smaller project like this one. 34 | 35 | ### Installation as a developer 36 | 37 | Make sure you have these installed 38 | 39 | 1. MongoDB 40 | 2. Node JS 41 | 3. NPM 42 | 4. GIT 43 | 44 | Clone the repository 45 | 46 | ``` 47 | git clone "https://github.com/OlexG/nodejs-react-forum" 48 | ``` 49 | 50 | Make sure you are in the directory of your application. 51 | After this run these commands: 52 | 53 | ``` 54 | npm install --dev 55 | cd client 56 | npm install --dev 57 | ``` 58 | 59 | The local.env file should look something like this. 60 | 61 | ``` 62 | URI=mongodb://localhost:27017/?readPreference=primary&appname=MongoDB%20Compass&ssl=false 63 | PORT=3001 64 | TOKEN_EXPIRATION_TIME = '15m' 65 | MAX_COMMENT_DEPTH = 3 66 | # Change this in production 67 | ACCESS_JWT_SECRET = '9hmvkQYqwPyM5AwqirC8' 68 | REFRESH_JWT_SECRET = 'UeQ31thCgXWBqMvoBocC' 69 | ``` 70 | 71 | You will need to copy the contents of local.env into the .env file and edit the environment variables as needed. Replace the `URI` with the URI of your own MongoDB connection. Changing the `PORT` will change the port on which the backend server runs however you will need to edit the `proxy` field of the client package.json to be on that port as well. 72 | 73 | There is also a `constants.js` file in the client directory with configuration variables for the client. 74 | 75 | To start the backend server and the client development server run `npm start` from the client and root directories. 76 | To run tests use this command from the root directory 77 | 78 | ``` 79 | npm test 80 | ``` 81 | 82 | ### Deployment 83 | 84 | Compile the server code and client code by running ```npm run build```. 85 | This will generate a dist folder with the javascript server code and a build folder inside the client code with the built 86 | client code. If you want to serve the static client files with Express, change the ```MODE``` env variable to 'PRODUCTION'. 87 | Otherwise, you can simply serve them with a different service like NGINX. 88 | 89 | 90 | -------------------------------------------------------------------------------- /client/src/Components/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Post from '../Post'; 3 | import NavbarComponent from '../Navbar'; 4 | import 'bootstrap/dist/css/bootstrap.css'; 5 | import { Link } from 'react-router-dom'; 6 | import usePostsNumberFetch from '../../Hooks/usePostsNumberFetch.js'; 7 | import usePostsPaginationFetch from '../../Hooks/usePostsPaginationFetch.js'; 8 | import PaginationBar from '../Pagination'; 9 | import PostMenu from '../PostMenu'; 10 | import { POSTS_PER_PAGE } from '../../constants.js'; 11 | import './styles.css'; 12 | import useReactionsFetch from '../../Hooks/useReactionsFetch.js'; 13 | import UserDashboard from '../UserDashboard'; 14 | import Cookies from 'js-cookie'; 15 | import Popup from '../Popup'; 16 | import Notifications from '../Notifications'; 17 | 18 | const App = () => { 19 | const [popup, setPopup] = useState({}); 20 | const username = Cookies.get('username'); 21 | const { reactions, loading } = useReactionsFetch(username, setPopup); 22 | const [currentPage, setCurrentPage] = useState(1); 23 | const [filterOptions, setFilterOptions] = useState({}); 24 | const totalPosts = usePostsNumberFetch(setPopup).result; 25 | const posts = usePostsPaginationFetch( 26 | currentPage, 27 | POSTS_PER_PAGE, 28 | filterOptions, 29 | setPopup 30 | ); 31 | return ( 32 |
33 | 34 | 35 | {popup.message && } 36 |
37 |
38 | 42 | {posts && !loading ? ( 43 | posts.map((post, idx) => { 44 | let status; 45 | if ( 46 | reactions.downvotes && 47 | Object.prototype.hasOwnProperty.call( 48 | reactions.downvotes, 49 | post._id 50 | ) 51 | ) { 52 | status = -1; 53 | } else if ( 54 | reactions.upvotes && 55 | Object.prototype.hasOwnProperty.call( 56 | reactions.upvotes, 57 | post._id 58 | ) 59 | ) { 60 | status = 1; 61 | } else { 62 | status = 0; 63 | } 64 | return ( 65 | 75 | ); 76 | }) 77 | ) : ( 78 |

loading

79 | )} 80 |
81 |
82 | 86 | Create a Post 87 | 88 | {totalPosts && ( 89 | 95 | )} 96 | {username && ( 97 | 98 | )} 99 |
100 |
101 |
102 | ); 103 | }; 104 | 105 | export default App; 106 | -------------------------------------------------------------------------------- /server/controllers/postController.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { initManagers } from '../db/initDB'; 3 | import { SortOption, FilterObject } from '../db/PostManager'; 4 | import { scheduleJob } from '../scheduling/scheduler'; 5 | const { postManager, userManager } = initManagers(); 6 | async function postPosts(req, res, next) { 7 | const refreshToken = req.cookies.refreshToken; 8 | const username = await userManager.findRefreshToken(refreshToken); 9 | const { 10 | body: { title, body: postBody, parent, date } 11 | } = req; 12 | if (date) { 13 | const jobSuccess = await scheduleJob( 14 | { date, title, body: postBody, author: username, parent }, 15 | function (postManager, userManager, argsObj) { 16 | return postManager.addPost( 17 | argsObj.title, 18 | argsObj.body, 19 | argsObj.author, 20 | argsObj.date, 21 | userManager 22 | ); 23 | } 24 | ); 25 | if (jobSuccess) { 26 | res.sendStatus(200); 27 | } else { 28 | res.sendStatus(500); 29 | } 30 | return; 31 | } 32 | const result = await postManager.addPost( 33 | title, 34 | postBody, 35 | username, 36 | new Date(), 37 | userManager, 38 | parent 39 | ); 40 | res.statusCode = 200; 41 | res.send(result); 42 | } 43 | 44 | async function getPosts(req, res, next) { 45 | let result; 46 | if ('number' in req.query && 'page' in req.query) { 47 | const { 48 | query: { number, page, sort, search } 49 | } = req; 50 | const filterObject: FilterObject = { 51 | sort: SortOption.DEFAULT, 52 | search: '' 53 | }; 54 | if (sort) filterObject.sort = sort; 55 | if (search) filterObject.search = search; 56 | 57 | result = await postManager.getPostsPage(number, page, filterObject); 58 | } else { 59 | if (req.query.parent) { 60 | result = await postManager.getAllPosts( 61 | req.query.returnWithComments, 62 | req.query.parent 63 | ); 64 | } else { 65 | result = await postManager.getAllPosts(false); 66 | } 67 | } 68 | res.send(result); 69 | } 70 | 71 | async function getPostsId(req, res, next) { 72 | const result = await postManager.getPost(req.params.id); 73 | res.send(result); 74 | } 75 | 76 | async function getPostsNumber(req, res, next) { 77 | const result = await postManager.getNumberOfPosts(); 78 | res.send({ result }); 79 | } 80 | 81 | async function upvotePost(req, res, next) { 82 | const refreshToken = req.cookies.refreshToken; 83 | const username = await userManager.findRefreshToken(refreshToken); 84 | const result = await postManager.upvotePost( 85 | req.params.id, 86 | username, 87 | userManager 88 | ); 89 | res.send(result); 90 | } 91 | 92 | async function downvotePost(req, res, next) { 93 | const refreshToken = req.cookies.refreshToken; 94 | const username = await userManager.findRefreshToken(refreshToken); 95 | const result = await postManager.downvotePost( 96 | req.params.id, 97 | username, 98 | userManager 99 | ); 100 | res.send(result); 101 | } 102 | 103 | async function removePostReactions(req, res, next) { 104 | const refreshToken = req.cookies.refreshToken; 105 | const username = await userManager.findRefreshToken(refreshToken); 106 | await postManager.removeReactions(req.params.id, username, userManager); 107 | res.sendStatus(200); 108 | } 109 | 110 | export default { 111 | postPosts, 112 | getPosts, 113 | getPostsId, 114 | getPostsNumber, 115 | upvotePost, 116 | downvotePost, 117 | removePostReactions 118 | }; 119 | -------------------------------------------------------------------------------- /client/src/Components/SignupPage/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import 'bootstrap/dist/css/bootstrap.css'; 3 | import { Form } from 'react-bootstrap'; 4 | import { useHistory } from 'react-router'; 5 | import NavbarComponent from '../Navbar'; 6 | import Popup from '../Popup'; 7 | import validatePassword from '../../Validation/validatePassword.js'; 8 | import validateUsername from '../../Validation/validateUsername.js'; 9 | import api from '../../api.js'; 10 | 11 | const SignupPage = (props) => { 12 | const history = useHistory(); 13 | const [formAttributes, setFormAttributes] = useState({ 14 | username: '', 15 | password: '', 16 | formError: 'username is too short, password is too short' 17 | }); 18 | 19 | async function handleClick(e) { 20 | e.preventDefault(); 21 | const formElement = document.querySelector('form'); 22 | const formData = new FormData(formElement); 23 | const username = formData.get('username'); 24 | const password = formData.get('password'); 25 | 26 | const res = await api.signup({ 27 | username, 28 | password 29 | }); 30 | 31 | if (res.status === 200) { 32 | // redirect to success page if result is a success 33 | history.push('/'); 34 | } else if (res.status === 400) { 35 | const result = res.data; 36 | setFormAttributes({ formError: result.validation.body.message }); 37 | } 38 | } 39 | function handleUserInput(e) { 40 | const formElement = e.currentTarget; 41 | const formData = new FormData(formElement); 42 | const username = formData.get('username'); 43 | const password = formData.get('password'); 44 | const usernameError = validateUsername(username); 45 | const passwordError = validatePassword(password); 46 | const formError = []; 47 | if (usernameError !== 'valid') { 48 | formError.push(usernameError); 49 | } 50 | if (passwordError !== 'valid') { 51 | formError.push(passwordError); 52 | } 53 | if (formError.length === 0) { 54 | setFormAttributes({ username, password }); 55 | } else { 56 | setFormAttributes({ 57 | username, 58 | password, 59 | formError: formError.join(', ') 60 | }); 61 | } 62 | } 63 | return ( 64 | <> 65 | 66 | {formAttributes.formError ? ( 67 | 68 | ) : ( 69 | 70 | )} 71 |
80 |
85 | 86 | Username 87 | 94 | 95 | 96 | Password 97 | 105 | 106 | 109 |
110 |
111 | 112 | ); 113 | }; 114 | 115 | export default SignupPage; 116 | -------------------------------------------------------------------------------- /tests/postController.ts: -------------------------------------------------------------------------------- 1 | import mockInitDB from './mocks/mockInitDB'; 2 | import chai = require('chai'); 3 | import proxyquire = require('proxyquire'); 4 | import sinon = require('sinon'); 5 | 6 | const expect = chai.expect; 7 | 8 | const postController = proxyquire('../server/controllers/postController.ts', { 9 | '../db/initDB': mockInitDB 10 | }).default; 11 | 12 | describe('Unit testing of post controllers', function () { 13 | let res: { 14 | statusCode?: any; 15 | send?: sinon.SinonSpy; 16 | sendStatus?: sinon.SinonSpy; 17 | }; 18 | let resSpy: sinon.SinonSpy; 19 | let resStatusSpy: sinon.SinonSpy; 20 | beforeEach(function () { 21 | resSpy = sinon.spy(); 22 | resStatusSpy = sinon.spy(); 23 | // eslint-disable-next-line no-unused-vars 24 | res = { 25 | send: resSpy, 26 | sendStatus: resStatusSpy 27 | }; 28 | }); 29 | 30 | it('should attempt to post', async function () { 31 | const req = { 32 | body: { 33 | title: 'testTitle', 34 | body: 'testBody' 35 | }, 36 | cookies: { 37 | refreshToken: 'testToken' 38 | } 39 | }; 40 | res.statusCode = -1; 41 | await postController.postPosts(req, res); 42 | expect(resSpy.calledOnce).to.equal(true); 43 | expect(resSpy.calledWith('testid')).to.equal(true); 44 | expect(res.statusCode).to.equal(200); 45 | }); 46 | it('should get all posts', async function () { 47 | const req = { 48 | query: {} 49 | }; 50 | await postController.getPosts(req, res); 51 | expect(resSpy.calledOnce).to.equal(true); 52 | expect(resSpy.args[0][0]).to.eql(['test', 'test', 'test', 'test', 'test']); 53 | }); 54 | it('should get post based on page', async function () { 55 | const req = { 56 | query: { 57 | number: 0, 58 | page: 0 59 | } 60 | }; 61 | await postController.getPosts(req, res); 62 | expect(resSpy.calledOnce).to.equal(true); 63 | expect(resSpy.args[0][0]).to.eql(['test', 'test', 'test']); 64 | }); 65 | it('should get get a post based on id', async function () { 66 | const req = { 67 | params: { 68 | id: 0 69 | } 70 | }; 71 | await postController.getPostsId(req, res); 72 | expect(resSpy.calledOnce).to.equal(true); 73 | expect(resSpy.calledWith('test')).to.equal(true); 74 | }); 75 | it('should get the number of posts', async function () { 76 | const req = {}; 77 | await postController.getPostsNumber(req, res); 78 | expect(resSpy.calledOnce).to.equal(true); 79 | expect(resSpy.args[0][0]).to.eql({ result: 5 }); 80 | }); 81 | 82 | it('should downvote post', async function () { 83 | const req = { 84 | cookies: { 85 | refreshToken: 'testToken' 86 | }, 87 | params: { 88 | id: 'testId' 89 | } 90 | }; 91 | await postController.downvotePost(req, res); 92 | expect(resSpy.calledOnce).to.equal(true); 93 | expect(resSpy.args[0][0]).to.equal(true); 94 | }); 95 | describe('Unit testing reactions', function () { 96 | let req: { cookies: { refreshToken: string }; params: { id: string } }; 97 | beforeEach(function () { 98 | req = { 99 | cookies: { 100 | refreshToken: 'testToken' 101 | }, 102 | params: { 103 | id: 'testId' 104 | } 105 | }; 106 | }); 107 | it('should upvote post', async function () { 108 | await postController.upvotePost(req, res); 109 | expect(resSpy.calledOnce).to.equal(true); 110 | expect(resSpy.args[0][0]).to.equal(true); 111 | }); 112 | 113 | it('should remove post reactions', async function () { 114 | await postController.removePostReactions(req, res); 115 | expect(resStatusSpy.calledOnce).to.equal(true); 116 | expect(resStatusSpy.args[0][0]).to.equal(200); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /server/controllers/userController.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { initManagers } from '../db/initDB'; 3 | import { registerUser, publisher, unsubscribeUser } from '../notifications'; 4 | import jwt = require('jsonwebtoken'); 5 | const { userManager, postManager } = initManagers(); 6 | const { resolve } = require('path'); 7 | 8 | async function postUsers(req, res, next) { 9 | const { username, password } = req.body; 10 | const result = await userManager.addUser(username, password); 11 | if (result === 'username already exists') { 12 | res.statusCode = 400; 13 | } 14 | res.send({ validation: { body: { message: result } } }); 15 | } 16 | 17 | async function login(req, res, next) { 18 | const { username } = req.body; 19 | const accessToken = jwt.sign({ username }, process.env.ACCESS_JWT_SECRET, { 20 | expiresIn: process.env.TOKEN_EXPIRATION_TIME 21 | }); 22 | const refreshToken = jwt.sign({ username }, process.env.REFRESH_JWT_SECRET); 23 | 24 | res.cookie('accessToken', accessToken, { overwrite: true }); 25 | res.cookie('refreshToken', refreshToken, { 26 | overwrite: true, 27 | httpOnly: true, 28 | sameSite: 'strict' 29 | }); 30 | res.cookie('username', username, { overwrite: true }); 31 | 32 | await userManager.addRefreshToken(username, refreshToken); 33 | 34 | res.sendStatus(200); 35 | } 36 | 37 | async function getAccessToken(req, res, next) { 38 | const refreshToken = req.cookies.refreshToken; 39 | const username = await userManager.findRefreshToken(refreshToken); 40 | const decoded = jwt.decode(refreshToken, { complete: true }); 41 | if (decoded.payload.username !== username) return res.sendStatus(401); 42 | const accessToken = jwt.sign({ username }, process.env.ACCESS_JWT_SECRET, { 43 | expiresIn: process.env.TOKEN_EXPIRATION_TIME 44 | }); 45 | res.cookie('accessToken', accessToken, { overwrite: true }); 46 | res.sendStatus(200); 47 | } 48 | 49 | async function logout(req, res, next) { 50 | const refreshToken = req.cookies.refreshToken; 51 | await userManager.deleteRefreshToken(refreshToken); 52 | // delete the http ONLY refreshToken 53 | res.clearCookie('refreshToken'); 54 | res.sendStatus(200); 55 | } 56 | 57 | async function getUserReactions(req, res, next) { 58 | const username = req.params.username; 59 | const reactions = await userManager.getUserReactions(username); 60 | if (reactions) return res.send(reactions); 61 | res.sendStatus(400); 62 | } 63 | 64 | async function getUserData(req, res, next) { 65 | const username = req.params.username; 66 | const data = await userManager.getUserData(username); 67 | res.send(data); 68 | } 69 | 70 | async function changeUserIcon(req, res, next) { 71 | const username = req.cookies.username; 72 | if (req.file) { 73 | await userManager.updateIconPath(username, req.file.path); 74 | res.sendStatus(200); 75 | } else { 76 | res.sendStatus(400); 77 | } 78 | } 79 | 80 | async function getUserIcon(req, res, next) { 81 | const path = await userManager.getIconPath(req.params.username); 82 | if (path === null) { 83 | res.sendFile(resolve(__dirname, '../../defaultFiles/default.png')); 84 | } else { 85 | res.sendFile(path); 86 | } 87 | } 88 | 89 | async function setUpNotifications(req, res, next) { 90 | res.set({ 91 | 'Content-Type': 'text/event-stream', 92 | 'Cache-Control': 'no-cache', 93 | Connection: 'keep-alive' 94 | }); 95 | await registerUser(req.cookies.username, res.write.bind(res), postManager); 96 | req.on('close', () => { 97 | unsubscribeUser(req.cookies.username, postManager); 98 | res.end(); 99 | }); 100 | } 101 | 102 | export default { 103 | postUsers, 104 | login, 105 | getAccessToken, 106 | getUserReactions, 107 | logout, 108 | getUserData, 109 | changeUserIcon, 110 | getUserIcon, 111 | setUpNotifications 112 | }; 113 | -------------------------------------------------------------------------------- /client/src/Components/PostCreator/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import validateDate from '../../Validation/validateDate'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import { Form } from 'react-bootstrap'; 5 | import { useHistory } from 'react-router'; 6 | import NavbarComponent from '../Navbar'; 7 | import Popup from '../Popup'; 8 | import api from '../../api.js'; 9 | 10 | const PostCreator = (props) => { 11 | const history = useHistory(); 12 | const [popup, setPopup] = useState({}); 13 | const params = new URLSearchParams(props.location.search); 14 | async function handleClickPost(e) { 15 | e.preventDefault(); 16 | const formElement = e.currentTarget; 17 | const formData = new FormData(formElement); 18 | const title = formData.get('title'); 19 | const body = formData.get('body'); 20 | const date = formData.get('date'); 21 | 22 | if (!validateDate(date)) { 23 | setPopup({ 24 | message: 'Please enter a valid date and time or leave field empty' 25 | }); 26 | formElement.querySelector('input[name="date"]').value = ''; 27 | return; 28 | } 29 | const reqBody = { title, body }; 30 | if (date !== '') { 31 | reqBody.date = date; 32 | } 33 | const res = await api.sendPostSubmitRequest(reqBody); 34 | if (res.status === 200) { 35 | if (res.data !== 'OK') { 36 | history.push(`/posts/${res.data}`); 37 | } else { 38 | history.push('/'); 39 | } 40 | } else if (res.status === 401) { 41 | setPopup({ message: 'Invalid credentials. Please login again.' }); 42 | } else { 43 | setPopup({ message: 'Sorry something went wrong.' }); 44 | } 45 | } 46 | 47 | async function handleClickComment(e) { 48 | e.preventDefault(); 49 | const formElement = e.currentTarget; 50 | const formData = new FormData(formElement); 51 | const body = formData.get('body'); 52 | const res = await api.sendPostSubmitRequest({ 53 | title: 'Comment', 54 | body, 55 | parent: params.get('parentId') 56 | }); 57 | if (res.status === 200) { 58 | history.push(`/posts/${params.get('originalId')}`); 59 | } else if (res.status === 401) { 60 | setPopup({ message: 'Invalid credentials. Please login again.' }); 61 | } else { 62 | setPopup({ message: 'Sorry something went wrong.' }); 63 | } 64 | } 65 | return ( 66 | <> 67 | 68 | {popup.message && } 69 |
78 |
84 | {!params.get('parentId') && ( 85 |
86 | 87 | 93 |
94 | )} 95 |
96 | 97 | 103 |
104 | {!params.get('parentId') && ( 105 |
106 | 107 | 108 | 114 |
115 | )} 116 | 119 |
120 |
121 | 122 | ); 123 | }; 124 | 125 | export default PostCreator; 126 | -------------------------------------------------------------------------------- /client/src/Components/PostPage/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import NavbarComponent from '../Navbar'; 3 | import useSinglePostFetch from '../../Hooks/useSinglePostFetch.js'; 4 | import useCommentsFetch from '../../Hooks/useCommentsFetch'; 5 | import useReactionsFetch from '../../Hooks/useReactionsFetch.js'; 6 | import Comment from '../Comment'; 7 | import Reactions from '../Reactions'; 8 | import { Link } from 'react-router-dom'; 9 | import styles from '../UserDashboard/UserDashboard.module.css'; 10 | import Popup from '../Popup'; 11 | import Cookies from 'js-cookie'; 12 | import Notifications from '../Notifications'; 13 | 14 | const PostPage = ({ match }) => { 15 | const [popup, setPopup] = useState({}); 16 | const username = Cookies.get('username'); 17 | const { reactions, loading: reactionsLoading } = useReactionsFetch( 18 | username, 19 | setPopup 20 | ); 21 | const [upvotes, setUpvotes] = useState(0); 22 | const [status, setStatus] = useState(0); 23 | const { data, loading } = useSinglePostFetch(match.params.id, setPopup); 24 | const comments = useCommentsFetch(match.params.id, setPopup); 25 | useEffect(() => { 26 | if ( 27 | reactions.downvotes && 28 | Object.prototype.hasOwnProperty.call(reactions.downvotes, match.params.id) 29 | ) { 30 | setStatus(-1); 31 | } else if ( 32 | reactions.upvotes && 33 | Object.prototype.hasOwnProperty.call(reactions.upvotes, match.params.id) 34 | ) { 35 | setStatus(1); 36 | } else { 37 | setStatus(0); 38 | } 39 | if (data) { 40 | setUpvotes(data.upvotes); 41 | } 42 | }, [match.params.id, reactions.downvotes, reactions.upvotes, data]); 43 | 44 | return ( 45 | <> 46 | 47 | 48 | {popup.message && } 49 | {!loading ? ( 50 |
51 |
52 |
58 |

By: {data.author}

59 |

{data.title}

60 |

{data.body}

61 |
62 | 65 | Reply 66 | 67 | 75 |

{upvotes}

76 |
77 |
78 |
79 | ) : ( 80 |

Loading...

81 | )} 82 | {comments && 83 | !reactionsLoading && 84 | comments.map((comment, idx) => { 85 | let status; 86 | if ( 87 | reactions.downvotes && 88 | Object.prototype.hasOwnProperty.call( 89 | reactions.downvotes, 90 | comment._id 91 | ) 92 | ) { 93 | status = -1; 94 | } else if ( 95 | reactions.upvotes && 96 | Object.prototype.hasOwnProperty.call(reactions.upvotes, comment._id) 97 | ) { 98 | status = 1; 99 | } else { 100 | status = 0; 101 | } 102 | return ( 103 | 116 | ); 117 | })} 118 | 119 | ); 120 | }; 121 | 122 | export default PostPage; 123 | -------------------------------------------------------------------------------- /client/src/Components/Comment/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import React, { useState } from 'react'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import { Link } from 'react-router-dom'; 5 | import { MAX_COMMENT_DEPTH } from '../../constants.js'; 6 | import Reactions from '../Reactions'; 7 | import api from '../../api.js'; 8 | 9 | const repliesStateOptions = { 10 | UNLOADED: 'unloaded', 11 | SHOWN: 'shown', 12 | HIDDEN: 'hidden' 13 | }; 14 | 15 | const Comment = (props) => { 16 | const [upvotes, setUpvotes] = useState(props.upvotes); 17 | const [status, setStatus] = useState(props.status); 18 | const [showChildren, setShowChildren] = useState( 19 | repliesStateOptions.UNLOADED 20 | ); 21 | const [comments, setComments] = useState([]); 22 | async function fetchComments() { 23 | if (showChildren === repliesStateOptions.UNLOADED) { 24 | const res = await api.sendPostCommentsRequest(props.id, false); 25 | setComments(res.data); 26 | } 27 | setShowChildren(repliesStateOptions.SHOWN); 28 | } 29 | return ( 30 | <> 31 |
32 |
33 |
By: {props.author}
34 |

{props.body}

35 |
36 | 39 | Reply 40 | 41 | {props.depth >= MAX_COMMENT_DEPTH && ( 42 | <> 43 |

47 | Load replies 48 |

49 |

{ 52 | setShowChildren(repliesStateOptions.HIDDEN); 53 | }} 54 | > 55 | Hide replies 56 |

57 | 58 | )} 59 | 67 |

{upvotes}

68 |
69 |
70 |
71 |
72 | {props.children && 73 | props.children.map((comment, idx) => { 74 | let status; 75 | if ( 76 | props.reactions.downvotes && 77 | Object.prototype.hasOwnProperty.call( 78 | props.reactions.downvotes, 79 | comment._id 80 | ) 81 | ) { 82 | status = -1; 83 | } else if ( 84 | props.reactions.upvotes && 85 | Object.prototype.hasOwnProperty.call( 86 | props.reactions.upvotes, 87 | comment._id 88 | ) 89 | ) { 90 | status = 1; 91 | } else { 92 | status = 0; 93 | } 94 | return ( 95 | 108 | ); 109 | })} 110 | {showChildren === 'shown' && 111 | comments && 112 | // react converts 1 elemenet object arrays to just that object in state 113 | comments.map((comment, idx) => { 114 | let status; 115 | if ( 116 | props.reactions.downvotes && 117 | Object.prototype.hasOwnProperty.call( 118 | props.reactions.downvotes, 119 | comment._id 120 | ) 121 | ) { 122 | status = -1; 123 | } else if ( 124 | props.reactions.upvotes && 125 | Object.prototype.hasOwnProperty.call( 126 | props.reactions.upvotes, 127 | comment._id 128 | ) 129 | ) { 130 | status = 1; 131 | } else { 132 | status = 0; 133 | } 134 | return ( 135 | 148 | ); 149 | })} 150 |
151 | 152 | ); 153 | }; 154 | 155 | export default Comment; 156 | -------------------------------------------------------------------------------- /tests/userController.ts: -------------------------------------------------------------------------------- 1 | import mockInitDB from './mocks/mockInitDB'; 2 | import chai = require('chai'); 3 | import proxyquire = require('proxyquire'); 4 | import sinon = require('sinon'); 5 | import jwt = require('jsonwebtoken'); 6 | const expect = chai.expect; 7 | 8 | const userController = proxyquire('../server/controllers/userController.ts', { 9 | '../db/initDB': mockInitDB 10 | }).default; 11 | 12 | describe('Unit testing of user controllers', function () { 13 | let req: { 14 | body: { username: string; password: string }; 15 | cookies: { refreshToken: string; username: string }; 16 | params: { username: string }; 17 | }; 18 | beforeEach(function () { 19 | req = { 20 | body: { 21 | username: 'testUsername', 22 | password: 'testPassword' 23 | }, 24 | cookies: { 25 | refreshToken: jwt.sign({ username: 'testUsername' }, 'testSecret'), 26 | username: 'testUsername' 27 | }, 28 | params: { 29 | username: 'testUsername' 30 | } 31 | }; 32 | }); 33 | it('should post user', async function () { 34 | const resSpy = sinon.spy(); 35 | const res = { 36 | send: resSpy 37 | }; 38 | await userController.postUsers(req, res); 39 | expect(resSpy.calledOnce).to.equal(true); 40 | expect(resSpy.args[0][0]).to.eql({ 41 | validation: { body: { message: 'success' } } 42 | }); 43 | }); 44 | 45 | it('should login', async function () { 46 | const resSpy = sinon.spy(); 47 | const cookieSpy = sinon.spy(); 48 | const res = { 49 | sendStatus: resSpy, 50 | cookie: cookieSpy 51 | }; 52 | 53 | await userController.login(req, res); 54 | expect(resSpy.calledOnce).to.equal(true); 55 | expect(resSpy.calledWith(200)).to.equal(true); 56 | expect(cookieSpy.callCount).to.equal(3); 57 | expect(cookieSpy.getCall(0).args[0]).to.equal('accessToken'); 58 | expect(cookieSpy.getCall(1).args[0]).to.equal('refreshToken'); 59 | expect(cookieSpy.getCall(2).args[0]).to.equal('username'); 60 | }); 61 | 62 | it('should get access token', async function () { 63 | const resSpy = sinon.spy(); 64 | const cookieSpy = sinon.spy(); 65 | const res = { 66 | sendStatus: resSpy, 67 | cookie: cookieSpy 68 | }; 69 | 70 | await userController.getAccessToken(req, res); 71 | expect(resSpy.calledOnce).to.equal(true); 72 | expect(resSpy.calledWith(200)).to.equal(true); 73 | expect(cookieSpy.calledOnce).to.equal(true); 74 | expect(cookieSpy.calledWith('accessToken')).to.equal(true); 75 | }); 76 | 77 | it('should logout', async function () { 78 | const resSpy = sinon.spy(); 79 | const clearCookieSpy = sinon.spy(); 80 | const res = { 81 | sendStatus: resSpy, 82 | clearCookie: clearCookieSpy 83 | }; 84 | 85 | await userController.logout(req, res); 86 | expect(resSpy.calledOnce).to.equal(true); 87 | expect(resSpy.calledWith(200)).to.equal(true); 88 | expect(clearCookieSpy.calledOnce).to.equal(true); 89 | expect(clearCookieSpy.calledWith('refreshToken')).to.equal(true); 90 | }); 91 | 92 | it('should get user reactions', async function () { 93 | const resSpy = sinon.spy(); 94 | const clearCookieSpy = sinon.spy(); 95 | const res = { 96 | send: resSpy, 97 | clearCookie: clearCookieSpy 98 | }; 99 | 100 | await userController.getUserReactions(req, res); 101 | expect(resSpy.calledOnce).to.equal(true); 102 | expect(resSpy.args[0][0]).to.eql({ downvotes: {}, upvotes: {} }); 103 | }); 104 | 105 | it('should get public user data', async function () { 106 | const resSpy = sinon.spy(); 107 | const res = { 108 | send: resSpy 109 | }; 110 | await userController.getUserData(req, res); 111 | expect(resSpy.calledOnce).to.equal(true); 112 | expect(resSpy.args[0][0]).to.eql({ 113 | username: 'testUsername', 114 | upvotes: 0, 115 | downvotes: 0 116 | }); 117 | }); 118 | describe('Unit testing icons', function () { 119 | it('should change user icon', async function () { 120 | const goodReq = { 121 | file: { 122 | path: 'testPath' 123 | }, 124 | cookies: { 125 | username: 'testUsername' 126 | } 127 | }; 128 | const badReq = { 129 | cookies: { 130 | username: 'testUsername' 131 | } 132 | }; 133 | const resSpy = sinon.spy(); 134 | const res = { 135 | sendStatus: resSpy 136 | }; 137 | await userController.changeUserIcon(goodReq, res); 138 | expect(resSpy.calledOnce).to.equal(true); 139 | expect(resSpy.args[0][0]).to.equal(200); 140 | await userController.changeUserIcon(badReq, res); 141 | expect(resSpy.args[1][0]).to.equal(400); 142 | }); 143 | it('should get user icon', async function () { 144 | const resSpy = sinon.spy(); 145 | const res = { 146 | sendFile: resSpy 147 | }; 148 | // getUserIcon will return testPath 149 | await userController.getUserIcon({ params: 'testUsername' }, res); 150 | expect(resSpy.calledOnce).to.equal(true); 151 | expect(resSpy.args[0][0]).to.equal('testPath'); 152 | // getUserIcon will now return null so the default image should be served 153 | await userController.getUserIcon({ params: 'testUsername' }, res); 154 | expect(resSpy.args[1][0]).to.contain('default.png'); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /server/db/UserManager.ts: -------------------------------------------------------------------------------- 1 | import { unlink } from 'fs'; 2 | import * as models from './models'; 3 | import bcrypt = require('bcrypt'); 4 | import mongoose = require('mongoose'); 5 | 6 | export interface PublicUserData { 7 | reputation: number; 8 | numberOfPosts: number; 9 | } 10 | 11 | export class UserManager { 12 | // class with functions relating to accessing and editing user data 13 | 14 | model: mongoose.Model; 15 | 16 | constructor() { 17 | this.model = mongoose.model('user', models.UserSchema); 18 | } 19 | 20 | increasePostCounter(username: string) { 21 | this.model.updateOne({ username }, { $inc: { numberOfPosts: 1 } }).exec(); 22 | } 23 | 24 | async addPostUpvote( 25 | postID: string, 26 | username: string, 27 | author: string 28 | ): Promise<{ hasBeenChanged: boolean }> { 29 | const user: models.IUser = await this.model.findOne({ username }).exec(); 30 | if (user && !Object.prototype.hasOwnProperty.call(user.upvotes, postID)) { 31 | // update the reputation of author 32 | this.model 33 | .updateOne({ username: author }, { $inc: { reputation: 1 } }) 34 | .exec(); 35 | user.upvotes[postID] = 1; 36 | user.markModified('upvotes'); 37 | await user.save(); 38 | return { hasBeenChanged: true }; 39 | } 40 | return { hasBeenChanged: false }; 41 | } 42 | 43 | async addPostDownvote( 44 | postID: string, 45 | username: string, 46 | author: string 47 | ): Promise<{ hasBeenChanged: boolean }> { 48 | const user = await this.model.findOne({ username }).exec(); 49 | if (user && !Object.prototype.hasOwnProperty.call(user.downvotes, postID)) { 50 | // update the reputation of author 51 | this.model 52 | .updateOne({ username: author }, { $inc: { reputation: -1 } }) 53 | .exec(); 54 | user.downvotes[postID] = 1; 55 | user.markModified('downvotes'); 56 | await user.save(); 57 | return { hasBeenChanged: true }; 58 | } 59 | return { hasBeenChanged: false }; 60 | } 61 | 62 | async removePostDownvote( 63 | postID: string, 64 | username: string, 65 | author: string 66 | ): Promise<{ hasBeenChanged: boolean }> { 67 | const user = await this.model.findOne({ username }).exec(); 68 | if (user && Object.prototype.hasOwnProperty.call(user.downvotes, postID)) { 69 | // update the reputation of author 70 | this.model 71 | .updateOne({ username: author }, { $inc: { reputation: 1 } }) 72 | .exec(); 73 | delete user.downvotes[postID]; 74 | user.markModified('downvotes'); 75 | await user.save(); 76 | return { hasBeenChanged: true }; 77 | } 78 | return { hasBeenChanged: false }; 79 | } 80 | 81 | async removePostUpvote( 82 | postID: string, 83 | username: string, 84 | author: string 85 | ): Promise<{ hasBeenChanged: boolean }> { 86 | const user = await this.model.findOne({ username }).exec(); 87 | if (user && Object.prototype.hasOwnProperty.call(user.upvotes, postID)) { 88 | // update the reputation of author 89 | this.model 90 | .updateOne({ username: author }, { $inc: { reputation: -1 } }) 91 | .exec(); 92 | delete user.upvotes[postID]; 93 | user.markModified('upvotes'); 94 | await user.save(); 95 | return { hasBeenChanged: true }; 96 | } 97 | return { hasBeenChanged: false }; 98 | } 99 | 100 | async addUser(username: string, password: string): Promise { 101 | if (await this.model.findOne({ username }).exec()) { 102 | return 'username already exists'; 103 | } 104 | const hashedPassword = await bcrypt.hash(password, 8); 105 | await this.model.create({ 106 | username, 107 | password: hashedPassword, 108 | upvotes: {}, 109 | downvotes: {}, 110 | reputation: 0, 111 | numberOfPosts: 0 112 | }); 113 | return 'success'; 114 | } 115 | 116 | async updateIconPath(username: string, path: string): Promise { 117 | const { iconPath: oldPath } = await this.model.findOne({ username }); 118 | if (oldPath !== path && oldPath) { 119 | // delete old image 120 | unlink(oldPath, (e) => { 121 | if (e) { 122 | throw e; 123 | } 124 | }); 125 | } 126 | await this.model 127 | .updateOne({ username }, { $set: { iconPath: path } }) 128 | .exec(); 129 | return path; 130 | } 131 | 132 | async getIconPath(username: string): Promise { 133 | const { iconPath } = await this.model.findOne({ username }); 134 | if (iconPath) { 135 | return iconPath; 136 | } else { 137 | return null; 138 | } 139 | } 140 | 141 | addRefreshToken(username: string, refreshToken: string) { 142 | return this.model 143 | .updateOne({ username }, { $set: { refreshToken } }) 144 | .exec(); 145 | } 146 | 147 | deleteRefreshToken(refreshToken: string) { 148 | return this.model 149 | .updateOne({ refreshToken }, { $unset: { refreshToken: '' } }) 150 | .exec(); 151 | } 152 | 153 | async findRefreshToken(refreshToken: string): Promise { 154 | const user: models.IUser = await this.model 155 | .findOne({ refreshToken }) 156 | .exec(); 157 | if (user) { 158 | return user.username; 159 | } 160 | return null; 161 | } 162 | 163 | async getUserReactions(username: string): Promise { 164 | const user: models.IUser = await this.model.findOne({ username }).exec(); 165 | if (user) { 166 | return { downvotes: user.downvotes, upvotes: user.upvotes }; 167 | } 168 | return null; 169 | } 170 | 171 | async verifyUser(username: string, password: string): Promise { 172 | const user: models.IUser = await this.model.findOne({ username }).exec(); 173 | if (user) { 174 | return await bcrypt.compare(password, user.password); 175 | } 176 | return false; 177 | } 178 | 179 | async verifyUsername(username: string): Promise { 180 | const dbUsername = await this.model 181 | .findOne({ username }, { username: 1 }) 182 | .exec(); 183 | if (dbUsername) { 184 | return true; 185 | } 186 | return false; 187 | } 188 | 189 | async getUserData(username: string): Promise { 190 | const user = await this.model.findOne({ username }).exec(); 191 | return { 192 | reputation: user.reputation, 193 | numberOfPosts: user.numberOfPosts 194 | }; 195 | } 196 | 197 | deleteAll() { 198 | this.model.remove({}).exec(); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": false /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true /* Skip type checking of declaration files. */, 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | }, 71 | "include": ["server/**/*"] 72 | } 73 | -------------------------------------------------------------------------------- /tests/middleWare.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import validateAccessJWT from '../server/validation/validateAccessJWT'; 3 | import validateFile from '../server/validation/validateFile'; 4 | import validateRefreshJWT from '../server/validation/validateRefreshJWT'; 5 | import mockInitDB from './mocks/mockInitDB'; 6 | import proxyquire = require('proxyquire'); 7 | import chai = require('chai'); 8 | import sinon = require('sinon'); 9 | import jwt = require('jsonwebtoken'); 10 | require('dotenv').config(); 11 | const expect = chai.expect; 12 | const validateUsernameCookie = proxyquire( 13 | '../server/validation/validateUsernameCookie', 14 | { '../db/initDB': mockInitDB } 15 | ).default; 16 | const validateUsernameParam = proxyquire( 17 | '../server/validation/validateUsernameParam', 18 | { '../db/initDB': mockInitDB } 19 | ).default; 20 | const verifyUser = proxyquire('../server/validation/verifyUser', { 21 | '../db/initDB': mockInitDB 22 | }).default; 23 | 24 | describe('Unit testing of express middleware', function () { 25 | let res: { sendStatus: sinon.SinonSpy }; 26 | let resSpy: sinon.SinonSpy; 27 | let nextSpy: sinon.SinonSpy; 28 | beforeEach(function () { 29 | resSpy = sinon.spy(); 30 | nextSpy = sinon.spy(); 31 | res = { 32 | sendStatus: resSpy 33 | }; 34 | }); 35 | describe('should validate access token', function () { 36 | let req: { headers: any }; 37 | beforeEach(function () { 38 | req = { 39 | headers: { 40 | authorization: undefined 41 | } 42 | }; 43 | }); 44 | it('should validate with no token', function () { 45 | validateAccessJWT(req, res, () => {}); 46 | expect(resSpy.calledOnce).to.equal(true); 47 | expect(resSpy.args[0][0]).to.equal(401); 48 | }); 49 | it('should validate with an invalid token', function () { 50 | req.headers.authorization = 51 | 'token ' + 52 | jwt.sign( 53 | { username: 'testUsername' }, 54 | process.env.ACCESS_JWT_SECRET + 'INVALID' 55 | ); 56 | validateAccessJWT(req, res, () => {}); 57 | expect(resSpy.calledOnce).to.equal(true); 58 | expect(resSpy.args[0][0]).to.equal(401); 59 | }); 60 | it('should validate with an valid token', function () { 61 | req.headers.authorization = 62 | 'token ' + 63 | jwt.sign( 64 | { username: 'testUsername' }, 65 | process.env.ACCESS_JWT_SECRET as string 66 | ); 67 | validateAccessJWT(req, res, nextSpy); 68 | expect(nextSpy.calledOnce).to.equal(true); 69 | }); 70 | }); 71 | describe('should validate file', function () { 72 | let cbSpy: sinon.SinonSpy; 73 | let file: { mimetype: any; originalname: any }; 74 | beforeEach(function () { 75 | cbSpy = sinon.spy(); 76 | file = { 77 | mimetype: 'invalid', 78 | originalname: 'invalid' 79 | }; 80 | }); 81 | it('should validate with an invalid file', function () { 82 | validateFile({}, file, cbSpy); 83 | expect(cbSpy.calledOnce).to.equal(true); 84 | expect(cbSpy.args[0][0]).to.equal('Error: Images Only!'); 85 | }); 86 | it('should validate with a valid file', function () { 87 | file.mimetype = 'image/jpg'; 88 | file.originalname = 'testImage.jpg'; 89 | validateFile({}, file, cbSpy); 90 | expect(cbSpy.args[0][0]).to.equal(null); 91 | expect(cbSpy.args[0][1]).to.equal(true); 92 | }); 93 | }); 94 | describe('should validate refresh token', function () { 95 | let req: { cookies: any }; 96 | beforeEach(function () { 97 | req = { 98 | cookies: { 99 | refreshToken: undefined 100 | } 101 | }; 102 | }); 103 | it('should validate with no token', function () { 104 | validateRefreshJWT(req, res, () => {}); 105 | expect(resSpy.calledOnce).to.equal(true); 106 | expect(resSpy.args[0][0]).to.equal(401); 107 | }); 108 | it('should validate with an invalid token', function () { 109 | req.cookies.refreshToken = jwt.sign( 110 | { username: 'testUsername' }, 111 | process.env.REFRESH_JWT_SECRET + 'INVALID' 112 | ); 113 | validateRefreshJWT(req, res, () => {}); 114 | expect(resSpy.calledOnce).to.equal(true); 115 | expect(resSpy.args[0][0]).to.equal(401); 116 | }); 117 | it('should validate with a valid token', function () { 118 | req.cookies.refreshToken = jwt.sign( 119 | { username: 'testUsername' }, 120 | process.env.REFRESH_JWT_SECRET as string 121 | ); 122 | validateRefreshJWT(req, res, nextSpy); 123 | expect(nextSpy.calledOnce).to.equal(true); 124 | }); 125 | }); 126 | describe('should validate username cookie', function () { 127 | let req: { cookies: any }; 128 | beforeEach(function () { 129 | req = { 130 | cookies: { 131 | username: 'invalidUsername', 132 | refreshToken: 'testToken' 133 | } 134 | }; 135 | }); 136 | it('should validate invalid username', async function () { 137 | await validateUsernameCookie(req, res, () => {}); 138 | expect(resSpy.calledOnce).to.equal(true); 139 | expect(resSpy.args[0][0]).to.equal(401); 140 | }); 141 | it('should validate valid username', async function () { 142 | req.cookies.username = 'testUsername'; 143 | await validateUsernameCookie(req, res, nextSpy); 144 | expect(nextSpy.calledOnce).to.equal(true); 145 | }); 146 | }); 147 | describe('should validate username parameter', function () { 148 | let req: { params: any }; 149 | beforeEach(function () { 150 | req = { 151 | params: { 152 | username: 'invalidUsername' 153 | } 154 | }; 155 | }); 156 | it('should validate invalid username', async function () { 157 | await validateUsernameParam(req, res, () => {}); 158 | expect(resSpy.calledOnce).to.equal(true); 159 | expect(resSpy.args[0][0]).to.equal(400); 160 | }); 161 | it('should validate valid username', async function () { 162 | req.params.username = 'testUsername'; 163 | await validateUsernameParam(req, res, nextSpy); 164 | expect(nextSpy.calledOnce).to.equal(true); 165 | }); 166 | }); 167 | describe('should verify user', function () { 168 | let req: { body: any }; 169 | beforeEach(function () { 170 | req = { 171 | body: { 172 | username: 'invalidUsername', 173 | password: 'testPassword' 174 | } 175 | }; 176 | }); 177 | it('should verify with an invalid user', async function () { 178 | await verifyUser(req, res, () => {}); 179 | expect(resSpy.calledOnce).to.equal(true); 180 | expect(resSpy.args[0][0]).to.equal(400); 181 | }); 182 | it('should verify with a valid user', async function () { 183 | req.body.username = 'testUsername'; 184 | await verifyUser(req, res, nextSpy); 185 | expect(nextSpy.calledOnce).to.equal(true); 186 | }); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /server/db/PostManager.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { ObjectId } from 'mongodb'; 3 | import * as models from './models'; 4 | import { UserManager } from './UserManager'; 5 | import { publisher, subscribeUser } from '../notifications'; 6 | 7 | import mongoose = require('mongoose'); 8 | mongoose.set('useCreateIndex', true); 9 | 10 | export enum SortOption { 11 | DEFAULT = 'default', 12 | RECENT = 'recent', 13 | MOST_UPVOTES = 'most-upvotes', 14 | OLDEST = 'oldest' 15 | } 16 | 17 | export interface FilterObject { 18 | sort: SortOption; 19 | search: string; 20 | } 21 | 22 | export class PostManager { 23 | // class with functions relating to accessing and editing post data 24 | 25 | model: mongoose.Model; 26 | 27 | constructor() { 28 | this.model = mongoose.model('post', models.PostSchema); 29 | } 30 | 31 | getPost(postId: string): Promise { 32 | return this.model.findById(postId).exec(); 33 | } 34 | 35 | async getAllPosts( 36 | returnWithComments: boolean, 37 | parent?: mongoose.Types.ObjectId 38 | ): Promise { 39 | if (parent && returnWithComments) { 40 | const count = await this.model.find({ parent }).countDocuments(); 41 | if (count === 0) { 42 | return []; 43 | } 44 | const parentId = new ObjectId(parent); 45 | const post = await this.model 46 | .aggregate() 47 | .graphLookup({ 48 | from: 'posts', 49 | startWith: '$_id', 50 | connectFromField: '_id', 51 | connectToField: 'parent', 52 | as: 'children', 53 | maxDepth: parseInt(process.env.MAX_COMMENT_DEPTH) - 1, 54 | depthField: 'level' 55 | }) 56 | .unwind('$children') 57 | .sort({ 'children.level': -1, 'children.upvotes': -1 }) 58 | .group({ 59 | _id: '$_id', 60 | children: { $push: '$children' } 61 | }) 62 | .addFields({ 63 | children: { 64 | $reduce: { 65 | input: '$children', 66 | initialValue: { 67 | currentLevel: -1, 68 | currentLevelPosts: [], 69 | previousLevelPosts: [] 70 | }, 71 | in: { 72 | $let: { 73 | vars: { 74 | prev: { 75 | $cond: [ 76 | { $eq: ['$$value.currentLevel', '$$this.level'] }, 77 | '$$value.previousLevelPosts', 78 | '$$value.currentLevelPosts' 79 | ] 80 | }, 81 | current: { 82 | $cond: [ 83 | { $eq: ['$$value.currentLevel', '$$this.level'] }, 84 | '$$value.currentLevelPosts', 85 | [] 86 | ] 87 | } 88 | }, 89 | in: { 90 | currentLevel: '$$this.level', 91 | previousLevelPosts: '$$prev', 92 | currentLevelPosts: { 93 | $concatArrays: [ 94 | '$$current', 95 | [ 96 | { 97 | $mergeObjects: [ 98 | '$$this', 99 | { 100 | children: { 101 | $filter: { 102 | input: '$$prev', 103 | as: 'e', 104 | cond: { $eq: ['$$e.parent', '$$this._id'] } 105 | } 106 | } 107 | } 108 | ] 109 | } 110 | ] 111 | ] 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | }) 119 | .addFields({ children: '$children.currentLevelPosts' }) 120 | .match({ 121 | _id: parentId 122 | }) 123 | .exec(); 124 | const [{ children: posts }] = post; 125 | return posts; 126 | } else if (!returnWithComments && parent) { 127 | return this.model.find({ parent }).lean().exec(); 128 | } else { 129 | return this.model.find({}).lean().exec(); 130 | } 131 | } 132 | 133 | async addPost( 134 | title: string, 135 | body: string, 136 | username: string, 137 | date: Date, 138 | userManager: UserManager, 139 | parent?: mongoose.Types.ObjectId 140 | ) { 141 | // check if the post hasn't been added before, if added just return it's id 142 | const existingPostId = await this.model 143 | .findOne({ author: username, date }, { _id: 1 }) 144 | .exec(); 145 | if (existingPostId) { 146 | return existingPostId; 147 | } 148 | const post: models.IPost = await this.model.create({ 149 | title, 150 | body, 151 | upvotes: 0, 152 | author: username, 153 | date, 154 | ...(parent && { parent }) 155 | }); 156 | 157 | // send a notification to all posts above, that are not made by this user 158 | this.notifyPostChain(post._id, username); 159 | subscribeUser(username, post._id); 160 | await userManager.increasePostCounter(username); 161 | return post._id; 162 | } 163 | 164 | async notifyPostChain(startId: mongoose.Types.ObjectId, username: string) { 165 | const matchStartId = new ObjectId(startId); 166 | const posts = await this.model 167 | .aggregate() 168 | .match({ 169 | _id: matchStartId 170 | }) 171 | .graphLookup({ 172 | from: 'posts', 173 | startWith: '$parent', 174 | connectFromField: 'parent', 175 | connectToField: '_id', 176 | as: 'chain' 177 | }) 178 | .project({ chain: 1 }) 179 | .unwind('chain') 180 | .sort({ 'chain.date': -1 }) 181 | .match({ 'chain.author': { $ne: username } }) 182 | .group({ _id: '$chain.author', link: { $first: '$chain._id' } }) 183 | .exec(); 184 | 185 | if (posts) { 186 | const ids = posts.map((post) => post.link); 187 | publisher.notify(ids); 188 | } 189 | } 190 | 191 | getNumberOfPosts(): Promise { 192 | return this.model.find({ parent: undefined }).countDocuments().exec(); 193 | } 194 | 195 | async getPostsPage( 196 | pageSize: number | string, 197 | pageNum: number | string, 198 | { sort = SortOption.DEFAULT, search = '' }: FilterObject 199 | ): Promise { 200 | if (typeof pageSize === 'string') pageSize = parseInt(pageSize); 201 | if (typeof pageNum === 'string') pageNum = parseInt(pageNum); 202 | if (pageNum < 0) { 203 | return []; 204 | } 205 | const count = await this.getNumberOfPosts(); 206 | if (pageSize * (pageNum - 1) > count) { 207 | return []; 208 | } 209 | let sorted = this.model.find({ parent: undefined }); 210 | if (search) { 211 | sorted = sorted.find({ $text: { $search: search } }); 212 | } 213 | switch (sort) { 214 | case SortOption.DEFAULT: 215 | break; 216 | case SortOption.RECENT: 217 | sorted = sorted.sort({ date: -1 }); 218 | break; 219 | case SortOption.OLDEST: 220 | sorted = sorted.sort({ date: 1 }); 221 | break; 222 | case SortOption.MOST_UPVOTES: 223 | sorted = sorted.sort({ upvotes: -1 }); 224 | break; 225 | default: 226 | break; 227 | } 228 | return sorted 229 | .skip(pageSize * (pageNum - 1)) 230 | .limit(pageSize) 231 | .exec(); 232 | } 233 | 234 | async upvotePost( 235 | postID: string, 236 | username: string, 237 | userManager: UserManager 238 | ): Promise { 239 | const { author } = await this.model 240 | .findOne({ _id: new ObjectId(postID) }) 241 | .exec(); 242 | if ( 243 | (await userManager.removePostDownvote(postID, username, author)) 244 | .hasBeenChanged 245 | ) { 246 | this.model 247 | .updateOne({ _id: new ObjectId(postID) }, { $inc: { upvotes: 1 } }) 248 | .exec(); 249 | } 250 | if ( 251 | (await userManager.addPostUpvote(postID, username, author)).hasBeenChanged 252 | ) { 253 | this.model 254 | .updateOne({ _id: new ObjectId(postID) }, { $inc: { upvotes: 1 } }) 255 | .exec(); 256 | return true; 257 | } 258 | return false; 259 | } 260 | 261 | async downvotePost( 262 | postID: string, 263 | username: string, 264 | userManager: UserManager 265 | ): Promise { 266 | const { author } = await this.model 267 | .findOne({ _id: new ObjectId(postID) }) 268 | .exec(); 269 | if ( 270 | (await userManager.removePostUpvote(postID, username, author)) 271 | .hasBeenChanged 272 | ) { 273 | this.model 274 | .updateOne({ _id: new ObjectId(postID) }, { $inc: { upvotes: -1 } }) 275 | .exec(); 276 | } 277 | if ( 278 | (await userManager.addPostDownvote(postID, username, author)) 279 | .hasBeenChanged 280 | ) { 281 | this.model 282 | .updateOne({ _id: new ObjectId(postID) }, { $inc: { upvotes: -1 } }) 283 | .exec(); 284 | return true; 285 | } 286 | return false; 287 | } 288 | 289 | async removeReactions( 290 | postID: string, 291 | username: string, 292 | userManager: UserManager 293 | ) { 294 | const { author } = await this.model 295 | .findOne({ _id: new ObjectId(postID) }) 296 | .exec(); 297 | if ( 298 | (await userManager.removePostUpvote(postID, username, author)) 299 | .hasBeenChanged 300 | ) { 301 | this.model 302 | .updateOne({ _id: new ObjectId(postID) }, { $inc: { upvotes: -1 } }) 303 | .exec(); 304 | } 305 | if ( 306 | (await userManager.removePostDownvote(postID, username, author)) 307 | .hasBeenChanged 308 | ) { 309 | this.model 310 | .updateOne({ _id: new ObjectId(postID) }, { $inc: { upvotes: 1 } }) 311 | .exec(); 312 | } 313 | } 314 | 315 | getUserPosts(username: string) { 316 | return this.model.find({ author: username }, '_id').lean().exec(); 317 | } 318 | 319 | deleteAll() { 320 | this.model.remove({}).exec(); 321 | } 322 | } 323 | --------------------------------------------------------------------------------