├── backend ├── .babelrc ├── .env.example ├── .gitignore ├── controllers │ ├── auth.js │ ├── comments.js │ └── posts.js ├── data │ └── reddit-db.js ├── models │ ├── comment.js │ ├── post.js │ └── user.js ├── package-lock.json ├── package.json ├── routes │ ├── index.js │ ├── posts.js │ └── users.js ├── server.js ├── tests │ ├── auth.test.js │ ├── index.test.js │ └── posts.test.js └── util │ └── autopopulate.js └── frontend ├── .env.example ├── .gitignore ├── components ├── Comments │ ├── CommentList.js │ ├── CommentReply.js │ └── NewComment.js ├── Layout.js ├── Navigation.js ├── PanelLeft.js └── PanelRight.js ├── helpers └── cookies.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── api │ └── hello.js ├── index.js ├── login.js ├── posts │ └── new.js ├── r │ └── [sub] │ │ ├── [slug].js │ │ └── index.js └── signup.js ├── public ├── favicon.ico └── vercel.svg └── styles ├── Auth.module.css ├── Comments.module.css ├── Home.module.css ├── Layout.module.css └── globals.css /backend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT= 2 | MONGODB_URI= 3 | SECRET= 4 | -------------------------------------------------------------------------------- /backend/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | -------------------------------------------------------------------------------- /backend/controllers/auth.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const jwt = require('jsonwebtoken') 3 | const User = require('../models/user') 4 | 5 | const UserController = {} 6 | 7 | UserController.newUser = async (req, res) => { 8 | try { 9 | const user = new User(req.body) 10 | await user.save() 11 | res 12 | return res.cookie('nToken', jwt.sign({ _id: user._id }, process.env.SECRET, { expiresIn: "60 days" }), { maxAge: 900000, httpOnly: false }).json({ message: "User created successfully"}) 13 | } catch (err) { 14 | return res.status(500).json({ message: err.message }) 15 | } 16 | } 17 | 18 | UserController.login = async (req, res) => { 19 | try { 20 | const username = req.body.username; 21 | const password = req.body.password; 22 | const user = await User.findOne({ username }, "username password") 23 | if (!user) return res.status(401).send({ message: "Wrong Username or Password" }); 24 | user.comparePassword(password, (err, isMatch) => { 25 | if (!isMatch) return res.status(401).send({ message: "Wrong Username or password" }); 26 | if (err) return res.status(500).send({ message: err.message}); 27 | return res.cookie('nToken', jwt.sign({ _id: user._id, username: user.username }, process.env.SECRET, { expiresIn: "60 days" }), { maxAge: 900000, httpOnly: false }).json({ message: "User login successful"}) 28 | }); 29 | } catch (err) { 30 | return res.status(500).json({ message: err.message }) 31 | } 32 | } 33 | 34 | UserController.logout = (req, res) => { 35 | res.clearCookie("nToken") 36 | return res.status(200).json({message: "User logged out"}) 37 | } 38 | 39 | module.exports = UserController 40 | -------------------------------------------------------------------------------- /backend/controllers/comments.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const Comment = require('../models/comment') 3 | const Post = require('../models/post') 4 | const User = require('../models/user'); 5 | 6 | const CommentController = {} 7 | 8 | CommentController.newComment = async (req, res) => { 9 | if (req.user) { 10 | try { 11 | const comment = new Comment(req.body) 12 | comment.author = req.user._id 13 | const savedComment = await comment.save() 14 | const post = await Post.findById(req.params.postId) 15 | post.comments.unshift(savedComment) 16 | await post.save() 17 | const user = await User.findById(req.user._id) 18 | user.comments.unshift(savedComment) 19 | user.save() 20 | return res.json({ message: "Comment created successfully"}) 21 | } catch (err) { 22 | return res.status(500).json({ message: err.message }) 23 | } 24 | } else return res.status(401).json({message: "Unauthorized"}) 25 | } 26 | 27 | CommentController.newCommentReply = async (req, res) => { 28 | if (req.user) { 29 | try { 30 | const reply = new Comment(req.body); 31 | reply.author = req.user._id 32 | const savedReply = await reply.save() 33 | console.log(req.params) 34 | const comment = await Comment.findById(req.params.commentId) 35 | if (!comment) return res.status(500).json({ message: "Comment not found" }) 36 | comment.comments.unshift(savedReply) 37 | comment.save() 38 | return res.json({ message: "Reply created successfully"}) 39 | } catch (err) { 40 | return res.status(500).json({ message: err.message }) 41 | } 42 | } else return res.status(401).json({message: "Unauthorized"}) 43 | } 44 | module.exports = CommentController 45 | -------------------------------------------------------------------------------- /backend/controllers/posts.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const Post = require('../models/post') 3 | const User = require('../models/user'); 4 | const PostController = {} 5 | 6 | PostController.newPost = (req, res) => { 7 | if (req.user) { 8 | const post = new Post(req.body) 9 | post.author = req.user._id 10 | post.upVotes = [] 11 | post.downVotes = [] 12 | post.voteScore = 0 13 | post.save().then(post => { 14 | return User.findById(req.user._id); 15 | }).then(user => { 16 | user.posts.unshift(post); 17 | user.save(); 18 | // SHOULD REDIRECT TO THE NEW POST 19 | return res.json({ message: "Post created successfully"}) 20 | }).catch(err => { 21 | return res.status(500).json({ message: err.message }) 22 | }); 23 | } else return res.status(401).json({message: "Unauthorized"}) 24 | } 25 | 26 | PostController.allPosts = (req, res) => { 27 | Post.find({}).populate('comments').lean().then(posts => { 28 | res.status(200).json({ posts }); 29 | }).catch(err => { 30 | console.log(err.message); 31 | }) 32 | } 33 | 34 | PostController.postBySlug = (req, res) => { 35 | Post.findOne({slug: req.params.slug}).populate('comments').lean().then(post => { 36 | res.status(200).json({ post }); 37 | }).catch(err => { 38 | console.log(err.message); 39 | }) 40 | } 41 | 42 | PostController.postsBySub = (req, res) => { 43 | Post.find({subreddit: req.params.sub, slug: req.params.slug}).populate('comments').lean().then(posts => { 44 | res.status(200).json({ posts }); 45 | }).catch(err => { 46 | console.log(err.message); 47 | }) 48 | } 49 | 50 | PostController.upvote = (req, res) => { 51 | if (req.user) { 52 | Post.findById(req.params.id).exec(function(err, post) { 53 | post.upVotes.push(req.user._id); 54 | post.voteScore = post.voteScore + 1; 55 | post.save(); 56 | return res.status(200).json({ voteScore: post.voteScore }); 57 | }); 58 | } else return res.status(401).json({message: "Unauthorized"}) 59 | } 60 | 61 | PostController.downvote = (req, res) => { 62 | if (req.user) { 63 | Post.findById(req.params.id).exec(function(err, post) { 64 | post.downVotes.push(req.user._id); 65 | post.voteScore = post.voteScore - 1; 66 | post.save(); 67 | return res.status(200).json({ voteScore: post.voteScore }); 68 | }); 69 | } else return res.status(401).json({message: "Unauthorized"}) 70 | } 71 | 72 | module.exports = PostController 73 | -------------------------------------------------------------------------------- /backend/data/reddit-db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const assert = require("assert") 3 | 4 | mongoose.Promise = global.Promise 5 | mongoose.connect( 6 | process.env.MONGODB_URI, 7 | { 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true, 10 | useCreateIndex: true, 11 | useFindAndModify: false 12 | }, 13 | function(err, db) { 14 | assert.equal(null, err) 15 | console.log("Connected successfully to database") 16 | 17 | // db.close(); turn on for testing 18 | } 19 | ); 20 | mongoose.connection.on("error", console.error.bind(console, "MongoDB connection Error:")) 21 | mongoose.set("debug", true) 22 | 23 | module.exports = mongoose.connection 24 | -------------------------------------------------------------------------------- /backend/models/comment.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const Schema = mongoose.Schema 3 | const Populate = require("../util/autopopulate") 4 | 5 | const CommentSchema = new Schema({ 6 | author : { type: Schema.Types.ObjectId, ref: "User", required: true }, 7 | content: { type: String, required: true }, 8 | comments: [{type: Schema.Types.ObjectId, ref: "Comment"}] 9 | }, {timestamps: {createdAt: 'created_at'}}) 10 | 11 | CommentSchema 12 | .pre('findOne', Populate('author')) 13 | .pre('find', Populate('author')) 14 | .pre('findOne', Populate('comments')) 15 | .pre('find', Populate('comments')) 16 | 17 | module.exports = mongoose.model("Comment", CommentSchema) 18 | -------------------------------------------------------------------------------- /backend/models/post.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const slugify = require("slugify") 3 | const Schema = mongoose.Schema 4 | const Populate = require("../util/autopopulate") 5 | 6 | const PostSchema = new Schema({ 7 | author : { type: Schema.Types.ObjectId, ref: "User", required: true }, 8 | title: { type: String, required: true }, 9 | slug: { type: String, required: true, unique: true }, 10 | summary: { type: String, required: true }, 11 | subreddit: { type: String, required: true }, 12 | comments: [{ type: Schema.Types.ObjectId, ref: 'Comment' }], 13 | upVotes : [{ type: Schema.Types.ObjectId, ref: "User"}], 14 | downVotes : [{ type: Schema.Types.ObjectId, ref: "User"}], 15 | voteScore : {type: Number} 16 | }, {timestamps: {createdAt: 'created_at'}}) 17 | 18 | // Create a unique slug based on title 19 | PostSchema.pre('findOne', Populate('author')) 20 | .pre('find', Populate('author')) 21 | .pre("validate", function(next) { 22 | const post = this; 23 | if (post.title) { 24 | post.slug = slugify(post.title, { lower: true, strict: true }); 25 | } 26 | next(); 27 | }) 28 | 29 | module.exports = mongoose.model("Post", PostSchema) 30 | -------------------------------------------------------------------------------- /backend/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const bcrypt = require("bcryptjs") 3 | const Schema = mongoose.Schema 4 | 5 | const UserSchema = new Schema({ 6 | password: { type: String, select: false }, 7 | username: { type: String, required: true, unique: true }, 8 | posts: [{ type: Schema.Types.ObjectId, ref: "Post" }], 9 | comments: [{ type: Schema.Types.ObjectId, ref: "Comment" }] 10 | }, 11 | {timestamps: true} 12 | ); 13 | 14 | UserSchema.pre("save", function (next) { 15 | const user = this 16 | if (!user.isModified("password")) { 17 | return next() 18 | } 19 | bcrypt.genSalt(10, (err, salt) => { 20 | bcrypt.hash(user.password, salt, (err, hash) => { 21 | user.password = hash 22 | next(); 23 | }); 24 | }); 25 | }); 26 | 27 | UserSchema.methods.comparePassword = function (password, done) { 28 | bcrypt.compare(password, this.password, (err, isMatch) => { 29 | done(err, isMatch) 30 | }); 31 | }; 32 | 33 | module.exports = mongoose.model("User", UserSchema) 34 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reddit-clone-backend", 3 | "version": "1.0.0", 4 | "description": "Clone of Reddit website", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "nodemon --exec babel-node server.js", 8 | "test": "mocha tests/*.test.js", 9 | "test-watch": "nodemon --exec 'npm test'" 10 | }, 11 | "author": "Philippos Tsamantanis", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@babel/core": "^7.12.10", 15 | "@babel/node": "^7.12.10", 16 | "@babel/preset-env": "^7.12.11", 17 | "chai": "^4.2.0", 18 | "chai-http": "^4.3.0", 19 | "mocha": "^8.2.1", 20 | "nodemon": "^2.0.7" 21 | }, 22 | "dependencies": { 23 | "assert": "^2.0.0", 24 | "bcryptjs": "^2.4.3", 25 | "cookie-parser": "^1.4.5", 26 | "cors": "^2.8.5", 27 | "dotenv": "^8.2.0", 28 | "express": "^4.17.1", 29 | "jsonwebtoken": "^8.5.1", 30 | "mongoose": "^5.11.14", 31 | "slugify": "^1.4.6" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | 3 | router.use('/posts', require('./posts')) 4 | router.use('/users', require('./users')) 5 | router.get('/', function(req, res) { 6 | return res.send("success") 7 | }) 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /backend/routes/posts.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | const { 3 | newPost, 4 | allPosts, 5 | postBySlug, 6 | postsBySub, 7 | upvote, 8 | downvote, 9 | } = require('../controllers/posts') 10 | 11 | const { 12 | newComment, 13 | newCommentReply 14 | } = require('../controllers/comments') 15 | 16 | router.get("/", allPosts) 17 | router.get("/r/:sub", postsBySub) 18 | router.get("/r/:sub/:slug", postBySlug) 19 | router.post("/new", newPost) 20 | router.post("/:postId/comments/new", newComment) 21 | router.post("/:postId/comments/:commentId/replies/new", newCommentReply) 22 | router.put("/:id/upvote", upvote) 23 | router.put("/:id/downvote", downvote) 24 | 25 | 26 | module.exports = router 27 | -------------------------------------------------------------------------------- /backend/routes/users.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | const { 3 | newUser, 4 | login, 5 | logout 6 | } = require('../controllers/auth') 7 | 8 | router.post("/sign-up", newUser) 9 | router.post("/login", login) 10 | router.get("/logout", logout) 11 | 12 | module.exports = router 13 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config') 2 | const cors = require('cors') 3 | const express = require('express') 4 | const cookieParser = require('cookie-parser') 5 | const jwt = require('jsonwebtoken') 6 | 7 | // db 8 | require('./data/reddit-db') 9 | 10 | const app = express() 11 | 12 | const checkAuth = (req, res, next) => { 13 | if (typeof req.cookies.nToken === "undefined" || req.cookies.nToken === null) { 14 | req.user = null; 15 | } else { 16 | const token = req.cookies.nToken; 17 | const decodedToken = jwt.decode(token, { complete: true }) || {}; 18 | req.user = decodedToken.payload; 19 | } 20 | next(); 21 | } 22 | 23 | app.use(cors({ 24 | origin: ['http://localhost:3000'], 25 | methods: ['GET', 'PUT', 'POST', 'DELETE'], 26 | credentials: true, 27 | })) 28 | app.use(express.json()) 29 | app.use(express.urlencoded({ extended: true })) 30 | app.use(cookieParser()) 31 | app.use(checkAuth) 32 | // routes 33 | app.use(require('./routes')) 34 | 35 | app.listen(process.env.PORT, () => 36 | console.log(`App listening on port ${process.env.PORT}!`), 37 | ) 38 | 39 | module.exports = app 40 | -------------------------------------------------------------------------------- /backend/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | const chaiHttp = require("chai-http"); 3 | const server = require("../server"); 4 | const should = chai.should(); 5 | chai.use(chaiHttp); 6 | 7 | // Agent that will keep track of our cookies 8 | const agent = chai.request.agent(server); 9 | 10 | const User = require("../models/user"); 11 | 12 | describe("User", function() { 13 | it("should not be able to login if they have not registered", function(done) { 14 | agent.post("/users/login", { email: "wrong@wrong.com", password: "nope" }).end(function(err, res) { 15 | res.status.should.be.equal(401); 16 | done(); 17 | }); 18 | }); 19 | it("should be able to signup", function(done) { 20 | User.findOneAndRemove({ username: "testone" }, function() { 21 | agent 22 | .post("/users/sign-up") 23 | .send({ username: "testone", password: "password" }) 24 | .end(function(err, res) { 25 | res.should.have.status(200); 26 | agent.should.have.cookie("nToken"); 27 | done(); 28 | }); 29 | }); 30 | }); 31 | it("should be able to login", function(done) { 32 | agent.post("/users/login") 33 | .send({ username: "testone", password: "password" }) 34 | .end(function(err, res) { 35 | res.should.have.status(200); 36 | agent.should.have.cookie("nToken"); 37 | done(); 38 | }); 39 | }); 40 | it("should be able to logout", function(done) { 41 | agent.get("/users/logout").end(function(err, res) { 42 | console.log(res) 43 | res.should.have.status(200); 44 | agent.should.not.have.cookie("nToken"); 45 | done(); 46 | }); 47 | }); 48 | after(function () { 49 | agent.close(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /backend/tests/index.test.js: -------------------------------------------------------------------------------- 1 | const app = require("../server"); 2 | const chai = require("chai"); 3 | const chaiHttp = require("chai-http"); 4 | const should = chai.should(); 5 | 6 | chai.use(chaiHttp); 7 | 8 | describe("site", function() { 9 | // Describe what you are testing 10 | it("Should have home page", function(done) { 11 | // Describe what should happen 12 | // In this case we test that the home page loads 13 | chai 14 | .request(app) 15 | .get("/") 16 | .end(function(err, res) { 17 | if (err) { 18 | return done(err); 19 | } 20 | res.status.should.be.equal(200); 21 | return done(); // Call done if the test completed successfully. 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/tests/posts.test.js: -------------------------------------------------------------------------------- 1 | // test/posts.js 2 | const app = require("./../server"); 3 | const chai = require("chai"); 4 | const chaiHttp = require("chai-http"); 5 | const expect = chai.expect; 6 | 7 | // Import the Post model from our models folder so we 8 | // we can use it in our tests. 9 | const Post = require('../models/post'); 10 | const User = require("../models/user"); 11 | const server = require('../server'); 12 | 13 | chai.should(); 14 | chai.use(chaiHttp); 15 | 16 | describe('Posts', function() { 17 | const agent = chai.request.agent(server); 18 | // Post that we'll use for testing purposes 19 | const newPost = { 20 | title: 'post title', 21 | summary: 'post summary', 22 | subreddit: 'subreddit' 23 | }; 24 | const user = { 25 | username: 'poststest', 26 | password: 'testposts' 27 | }; 28 | before(function (done) { 29 | agent.post('/users/sign-up') 30 | .set("content-type", "application/x-www-form-urlencoded") 31 | .send(user).then(function (res) { 32 | done(); 33 | }).catch(function (err) { 34 | done(err); 35 | }); 36 | }); 37 | 38 | it("should create with valid attributes at POST /posts/new", function (done) { 39 | Post.estimatedDocumentCount().then(function (initialDocCount) { 40 | agent.post("/posts/new") 41 | .set("content-type", "application/x-www-form-urlencoded") 42 | .send(newPost).then(function (res) { 43 | Post.estimatedDocumentCount().then(function (newDocCount) { 44 | // Check that the database has one more post in it 45 | expect(res).to.have.status(200); 46 | // Check that the database has one more post in it 47 | expect(newDocCount).to.be.equal(initialDocCount + 1) 48 | done(); 49 | }).catch(function (err) { 50 | done(err); 51 | }); 52 | }).catch(function (err) { 53 | done(err); 54 | }); 55 | }).catch(function (err) { 56 | done(err); 57 | }); 58 | }); 59 | after(async function () { 60 | try { 61 | await Post.findOneAndDelete({"title": newPost.title, "summary": newPost.summary}); 62 | await User.findOneAndDelete({ username: user.username }); 63 | console.log("Post & test user deleted successfully"); 64 | } catch (error) { 65 | res.status(500).send(error); 66 | } 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /backend/util/autopopulate.js: -------------------------------------------------------------------------------- 1 | module.exports = field => { 2 | return function(next) { 3 | this.populate(field); 4 | next(); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_APP_URI= 2 | -------------------------------------------------------------------------------- /frontend/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /frontend/components/Comments/CommentList.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import CommentReply from './CommentReply' 3 | import styles from '../../styles/Comments.module.css' 4 | const BetterDate = require('@tsamantanis/date-lib') 5 | 6 | function CommentList({ postId, comments, author, loadPost }) { 7 | const [reply, setReply] = useState(null) 8 | 9 | return comments && comments.length > 0 && comments.map((comment, index) => { 10 | return ( 11 |
{comment.content}
15 | { reply && reply === comment._id ? 16 |{ post.summary.length > 100 ? post.summary.substring(0, 97) + '...' : post.summary }
25 |{ post.summary }
63 |{ post.summary.length > 100 ? post.summary.substring(0, 97) + '...' : post.summary }
29 |