├── .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 |
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 |
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 |
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 |
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 |
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 |
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