├── .gitignore ├── 01-webpack-starter.txt ├── 02-webpack-end.txt ├── backend-api ├── Procfile ├── app.js ├── controllers │ ├── followController.js │ ├── postController.js │ └── userController.js ├── db.js ├── models │ ├── Follow.js │ ├── Post.js │ └── User.js ├── package.json └── router.js ├── generateHtml.js ├── html-templates ├── about.html ├── chat-is-visible.html ├── create-post.html ├── index-empty-feed.html ├── index-feed.html ├── index-guest.html ├── main.css ├── profile-followers.html ├── profile-posts.html ├── search-is-visible.html ├── single-post.html └── terms.html ├── previewDist.js └── vscode-react-component.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | .stylelintcache 13 | node_modules/ 14 | .tmp/ 15 | yarn.lock 16 | .DS_Store 17 | Thumbs.db 18 | .cache 19 | .project 20 | .settings 21 | .tmproj 22 | *.esproj 23 | nbproject 24 | *.sublime-project 25 | *.sublime-workspace 26 | *.komodoproject 27 | .komodotools 28 | _notes 29 | .vscode 30 | dwsync.xml -------------------------------------------------------------------------------- /01-webpack-starter.txt: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | entry: "./app/Main.js", 5 | output: { 6 | publicPath: "/", 7 | path: path.resolve(__dirname, "app"), 8 | filename: "bundled.js" 9 | }, 10 | mode: "development", 11 | devtool: "source-map", 12 | devServer: { 13 | port: 3000, 14 | static: { 15 | directory: path.join(__dirname, "app") 16 | }, 17 | hot: true, 18 | liveReload: false, 19 | historyApiFallback: { index: "index.html" } 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.js$/, 25 | exclude: /(node_modules)/, 26 | use: { 27 | loader: "babel-loader", 28 | options: { 29 | presets: ["@babel/preset-react", ["@babel/preset-env", { targets: { node: "12" } }]] 30 | } 31 | } 32 | } 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /02-webpack-end.txt: -------------------------------------------------------------------------------- 1 | const currentTask = process.env.npm_lifecycle_event 2 | const path = require("path") 3 | const Dotenv = require("dotenv-webpack") 4 | const { CleanWebpackPlugin } = require("clean-webpack-plugin") 5 | const HtmlWebpackHarddiskPlugin = require("html-webpack-harddisk-plugin") 6 | const HtmlWebpackPlugin = require("html-webpack-plugin") 7 | const fse = require("fs-extra") 8 | 9 | /* 10 | Because I didn't bother making CSS part of our 11 | webpack workflow for this project I'm just 12 | manually copying our CSS file to the DIST folder. 13 | */ 14 | class RunAfterCompile { 15 | apply(compiler) { 16 | compiler.hooks.done.tap("Copy files", function () { 17 | fse.copySync("./app/main.css", "./dist/main.css") 18 | 19 | /* 20 | If you needed to copy another file or folder 21 | such as your "images" folder, you could just 22 | call fse.copySync() as many times as you need 23 | to here to cover all of your files/folders. 24 | */ 25 | }) 26 | } 27 | } 28 | 29 | config = { 30 | entry: "./app/Main.js", 31 | output: { 32 | publicPath: "/", 33 | path: path.resolve(__dirname, "app"), 34 | filename: "bundled.js", 35 | }, 36 | plugins: [ 37 | new Dotenv(), 38 | new HtmlWebpackPlugin({ 39 | filename: "index.html", 40 | template: "app/index-template.html", 41 | alwaysWriteToDisk: true, 42 | }), 43 | new HtmlWebpackHarddiskPlugin(), 44 | ], 45 | mode: "development", 46 | module: { 47 | rules: [ 48 | { 49 | test: /\.js$/, 50 | exclude: /(node_modules)/, 51 | use: { 52 | loader: "babel-loader", 53 | options: { 54 | presets: ["@babel/preset-react", ["@babel/preset-env", { targets: { node: "12" } }]], 55 | }, 56 | }, 57 | }, 58 | ], 59 | }, 60 | } 61 | 62 | if (currentTask == "webpackDev" || currentTask == "dev") { 63 | config.devtool = "source-map" 64 | config.devServer = { 65 | port: 3000, 66 | static: { 67 | directory: path.join(__dirname, "app") 68 | }, 69 | hot: true, 70 | liveReload: false, 71 | historyApiFallback: { index: "index.html" }, 72 | } 73 | } 74 | 75 | if (currentTask == "webpackBuild") { 76 | config.plugins.push(new CleanWebpackPlugin(), new RunAfterCompile()) 77 | config.mode = "production" 78 | config.output = { 79 | publicPath: "/", 80 | path: path.resolve(__dirname, "dist"), 81 | filename: "[name].[chunkhash].js", 82 | chunkFilename: "[name].[chunkhash].js", 83 | } 84 | } 85 | 86 | module.exports = config 87 | -------------------------------------------------------------------------------- /backend-api/Procfile: -------------------------------------------------------------------------------- 1 | web: node db.js -------------------------------------------------------------------------------- /backend-api/app.js: -------------------------------------------------------------------------------- 1 | const express = require("express") 2 | const app = express() 3 | const sanitizeHTML = require("sanitize-html") 4 | const jwt = require("jsonwebtoken") 5 | 6 | app.use(express.urlencoded({ extended: false })) 7 | app.use(express.json()) 8 | 9 | app.use("/", require("./router")) 10 | 11 | const server = require("http").createServer(app) 12 | const io = require("socket.io")(server, { 13 | pingTimeout: 30000, 14 | cors: true 15 | }) 16 | 17 | io.on("connection", function(socket) { 18 | socket.on("chatFromBrowser", function(data) { 19 | try { 20 | let user = jwt.verify(data.token, process.env.JWTSECRET) 21 | socket.broadcast.emit("chatFromServer", { message: sanitizeHTML(data.message, { allowedTags: [], allowedAttributes: {} }), username: user.username, avatar: user.avatar }) 22 | } catch (e) { 23 | console.log("Not a valid token for chat.") 24 | } 25 | }) 26 | }) 27 | 28 | module.exports = server 29 | -------------------------------------------------------------------------------- /backend-api/controllers/followController.js: -------------------------------------------------------------------------------- 1 | const Follow = require("../models/Follow") 2 | 3 | exports.apiAddFollow = function(req, res) { 4 | let follow = new Follow(req.params.username, req.apiUser._id) 5 | follow 6 | .create() 7 | .then(() => { 8 | res.json(true) 9 | }) 10 | .catch(errors => { 11 | res.json(false) 12 | }) 13 | } 14 | 15 | exports.apiRemoveFollow = function(req, res) { 16 | let follow = new Follow(req.params.username, req.apiUser._id) 17 | follow 18 | .delete() 19 | .then(() => { 20 | res.json(true) 21 | }) 22 | .catch(errors => { 23 | res.json(false) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /backend-api/controllers/postController.js: -------------------------------------------------------------------------------- 1 | const Post = require("../models/Post") 2 | 3 | exports.apiCreate = function(req, res) { 4 | let post = new Post(req.body, req.apiUser._id) 5 | post 6 | .create() 7 | .then(function(newId) { 8 | res.json(newId) 9 | }) 10 | .catch(function(errors) { 11 | res.json(errors) 12 | }) 13 | } 14 | 15 | exports.apiUpdate = function(req, res) { 16 | let post = new Post(req.body, req.apiUser._id, req.params.id) 17 | post 18 | .update() 19 | .then(status => { 20 | // the post was successfully updated in the database 21 | // or user did have permission, but there were validation errors 22 | if (status == "success") { 23 | res.json("success") 24 | } else { 25 | res.json("failure") 26 | } 27 | }) 28 | .catch(e => { 29 | // a post with the requested id doesn't exist 30 | // or if the current visitor is not the owner of the requested post 31 | res.json("no permissions") 32 | }) 33 | } 34 | 35 | exports.apiDelete = function(req, res) { 36 | Post.delete(req.params.id, req.apiUser._id) 37 | .then(() => { 38 | res.json("Success") 39 | }) 40 | .catch(e => { 41 | res.json("You do not have permission to perform that action.") 42 | }) 43 | } 44 | 45 | exports.search = function(req, res) { 46 | Post.search(req.body.searchTerm) 47 | .then(posts => { 48 | res.json(posts) 49 | }) 50 | .catch(e => { 51 | res.json([]) 52 | }) 53 | } 54 | 55 | exports.reactApiViewSingle = async function(req, res) { 56 | try { 57 | let post = await Post.findSingleById(req.params.id, 0) 58 | res.json(post) 59 | } catch (e) { 60 | res.json(false) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend-api/controllers/userController.js: -------------------------------------------------------------------------------- 1 | const User = require("../models/User") 2 | const Post = require("../models/Post") 3 | const Follow = require("../models/Follow") 4 | const jwt = require("jsonwebtoken") 5 | 6 | // how long a token lasts before expiring 7 | const tokenLasts = "365d" 8 | 9 | exports.apiGetPostsByUsername = async function (req, res) { 10 | try { 11 | let authorDoc = await User.findByUsername(req.params.username) 12 | let posts = await Post.findByAuthorId(authorDoc._id) 13 | //res.header("Cache-Control", "max-age=10").json(posts) 14 | res.json(posts) 15 | } catch (e) { 16 | res.status(500).send("Sorry, invalid user requested.") 17 | } 18 | } 19 | 20 | exports.checkToken = function (req, res) { 21 | try { 22 | req.apiUser = jwt.verify(req.body.token, process.env.JWTSECRET) 23 | res.json(true) 24 | } catch (e) { 25 | res.json(false) 26 | } 27 | } 28 | 29 | exports.apiMustBeLoggedIn = function (req, res, next) { 30 | try { 31 | req.apiUser = jwt.verify(req.body.token, process.env.JWTSECRET) 32 | next() 33 | } catch (e) { 34 | res.status(500).send("Sorry, you must provide a valid token.") 35 | } 36 | } 37 | 38 | exports.doesUsernameExist = function (req, res) { 39 | User.findByUsername(req.body.username.toLowerCase()) 40 | .then(function () { 41 | res.json(true) 42 | }) 43 | .catch(function (e) { 44 | res.json(false) 45 | }) 46 | } 47 | 48 | exports.doesEmailExist = async function (req, res) { 49 | let emailBool = await User.doesEmailExist(req.body.email) 50 | res.json(emailBool) 51 | } 52 | 53 | exports.sharedProfileData = async function (req, res, next) { 54 | let viewerId 55 | try { 56 | viewer = jwt.verify(req.body.token, process.env.JWTSECRET) 57 | viewerId = viewer._id 58 | } catch (e) { 59 | viewerId = 0 60 | } 61 | req.isFollowing = await Follow.isVisitorFollowing(req.profileUser._id, viewerId) 62 | 63 | let postCountPromise = Post.countPostsByAuthor(req.profileUser._id) 64 | let followerCountPromise = Follow.countFollowersById(req.profileUser._id) 65 | let followingCountPromise = Follow.countFollowingById(req.profileUser._id) 66 | let [postCount, followerCount, followingCount] = await Promise.all([postCountPromise, followerCountPromise, followingCountPromise]) 67 | 68 | req.postCount = postCount 69 | req.followerCount = followerCount 70 | req.followingCount = followingCount 71 | 72 | next() 73 | } 74 | 75 | exports.apiLogin = function (req, res) { 76 | let user = new User(req.body) 77 | user 78 | .login() 79 | .then(function (result) { 80 | res.json({ 81 | token: jwt.sign({ _id: user.data._id, username: user.data.username, avatar: user.avatar }, process.env.JWTSECRET, { expiresIn: tokenLasts }), 82 | username: user.data.username, 83 | avatar: user.avatar 84 | }) 85 | }) 86 | .catch(function (e) { 87 | res.json(false) 88 | }) 89 | } 90 | 91 | exports.apiRegister = function (req, res) { 92 | let user = new User(req.body) 93 | user 94 | .register() 95 | .then(() => { 96 | res.json({ 97 | token: jwt.sign({ _id: user.data._id, username: user.data.username, avatar: user.avatar }, process.env.JWTSECRET, { expiresIn: tokenLasts }), 98 | username: user.data.username, 99 | avatar: user.avatar 100 | }) 101 | }) 102 | .catch(regErrors => { 103 | res.status(500).send(regErrors) 104 | }) 105 | } 106 | 107 | exports.apiGetHomeFeed = async function (req, res) { 108 | try { 109 | let posts = await Post.getFeed(req.apiUser._id) 110 | res.json(posts) 111 | } catch (e) { 112 | res.status(500).send("Error") 113 | } 114 | } 115 | 116 | exports.ifUserExists = function (req, res, next) { 117 | User.findByUsername(req.params.username) 118 | .then(function (userDocument) { 119 | req.profileUser = userDocument 120 | next() 121 | }) 122 | .catch(function (e) { 123 | res.json(false) 124 | }) 125 | } 126 | 127 | exports.profileBasicData = function (req, res) { 128 | res.json({ 129 | profileUsername: req.profileUser.username, 130 | profileAvatar: req.profileUser.avatar, 131 | isFollowing: req.isFollowing, 132 | counts: { postCount: req.postCount, followerCount: req.followerCount, followingCount: req.followingCount } 133 | }) 134 | } 135 | 136 | exports.profileFollowers = async function (req, res) { 137 | try { 138 | let followers = await Follow.getFollowersById(req.profileUser._id) 139 | //res.header("Cache-Control", "max-age=10").json(followers) 140 | res.json(followers) 141 | } catch (e) { 142 | res.status(500).send("Error") 143 | } 144 | } 145 | 146 | exports.profileFollowing = async function (req, res) { 147 | try { 148 | let following = await Follow.getFollowingById(req.profileUser._id) 149 | //res.header("Cache-Control", "max-age=10").json(following) 150 | res.json(following) 151 | } catch (e) { 152 | res.status(500).send("Error") 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /backend-api/db.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv") 2 | dotenv.config() 3 | const { MongoClient } = require("mongodb") 4 | 5 | const client = new MongoClient(process.env.CONNECTIONSTRING) 6 | 7 | async function start() { 8 | await client.connect() 9 | module.exports = client 10 | const app = require("./app") 11 | app.listen(process.env.PORT) 12 | } 13 | 14 | start() 15 | -------------------------------------------------------------------------------- /backend-api/models/Follow.js: -------------------------------------------------------------------------------- 1 | const usersCollection = require("../db").db().collection("users") 2 | const followsCollection = require("../db").db().collection("follows") 3 | const ObjectID = require("mongodb").ObjectId 4 | const User = require("./User") 5 | 6 | let Follow = function (followedUsername, authorId) { 7 | this.followedUsername = followedUsername 8 | this.authorId = authorId 9 | this.errors = [] 10 | } 11 | 12 | Follow.prototype.cleanUp = async function () { 13 | if (typeof this.followedUsername != "string") { 14 | this.followedUsername = "" 15 | } 16 | } 17 | 18 | Follow.prototype.validate = async function (action) { 19 | // followedUsername must exist in database 20 | let followedAccount = await usersCollection.findOne({ username: this.followedUsername }) 21 | if (followedAccount) { 22 | this.followedId = followedAccount._id 23 | } else { 24 | this.errors.push("You cannot follow a user that does not exist.") 25 | } 26 | 27 | let doesFollowAlreadyExist = await followsCollection.findOne({ followedId: this.followedId, authorId: new ObjectID(this.authorId) }) 28 | if (action == "create") { 29 | if (doesFollowAlreadyExist) { 30 | this.errors.push("You are already following this user.") 31 | } 32 | } 33 | if (action == "delete") { 34 | if (!doesFollowAlreadyExist) { 35 | this.errors.push("You cannot stop following someone you do not already follow.") 36 | } 37 | } 38 | 39 | // should not be able to follow yourself 40 | if (this.followedId.equals(this.authorId)) { 41 | this.errors.push("You cannot follow yourself.") 42 | } 43 | } 44 | 45 | Follow.prototype.create = function () { 46 | return new Promise(async (resolve, reject) => { 47 | this.cleanUp() 48 | await this.validate("create") 49 | if (!this.errors.length) { 50 | await followsCollection.insertOne({ followedId: this.followedId, authorId: new ObjectID(this.authorId) }) 51 | resolve() 52 | } else { 53 | reject(this.errors) 54 | } 55 | }) 56 | } 57 | 58 | Follow.prototype.delete = function () { 59 | return new Promise(async (resolve, reject) => { 60 | this.cleanUp() 61 | await this.validate("delete") 62 | if (!this.errors.length) { 63 | await followsCollection.deleteOne({ followedId: this.followedId, authorId: new ObjectID(this.authorId) }) 64 | resolve() 65 | } else { 66 | reject(this.errors) 67 | } 68 | }) 69 | } 70 | 71 | Follow.isVisitorFollowing = async function (followedId, visitorId) { 72 | let followDoc = await followsCollection.findOne({ followedId: followedId, authorId: new ObjectID(visitorId) }) 73 | if (followDoc) { 74 | return true 75 | } else { 76 | return false 77 | } 78 | } 79 | 80 | Follow.getFollowersById = function (id) { 81 | return new Promise(async (resolve, reject) => { 82 | try { 83 | let followers = await followsCollection 84 | .aggregate([ 85 | { $match: { followedId: id } }, 86 | { $lookup: { from: "users", localField: "authorId", foreignField: "_id", as: "userDoc" } }, 87 | { 88 | $project: { 89 | username: { $arrayElemAt: ["$userDoc.username", 0] }, 90 | email: { $arrayElemAt: ["$userDoc.email", 0] } 91 | } 92 | } 93 | ]) 94 | .toArray() 95 | followers = followers.map(function (follower) { 96 | let user = new User(follower, true) 97 | return { username: follower.username, avatar: user.avatar } 98 | }) 99 | resolve(followers) 100 | } catch (e) { 101 | reject() 102 | } 103 | }) 104 | } 105 | 106 | Follow.getFollowingById = function (id) { 107 | return new Promise(async (resolve, reject) => { 108 | try { 109 | let followers = await followsCollection 110 | .aggregate([ 111 | { $match: { authorId: id } }, 112 | { $lookup: { from: "users", localField: "followedId", foreignField: "_id", as: "userDoc" } }, 113 | { 114 | $project: { 115 | username: { $arrayElemAt: ["$userDoc.username", 0] }, 116 | email: { $arrayElemAt: ["$userDoc.email", 0] } 117 | } 118 | } 119 | ]) 120 | .toArray() 121 | followers = followers.map(function (follower) { 122 | let user = new User(follower, true) 123 | return { username: follower.username, avatar: user.avatar } 124 | }) 125 | resolve(followers) 126 | } catch (e) { 127 | reject() 128 | } 129 | }) 130 | } 131 | 132 | Follow.countFollowersById = function (id) { 133 | return new Promise(async (resolve, reject) => { 134 | let followerCount = await followsCollection.countDocuments({ followedId: id }) 135 | resolve(followerCount) 136 | }) 137 | } 138 | 139 | Follow.countFollowingById = function (id) { 140 | return new Promise(async (resolve, reject) => { 141 | let count = await followsCollection.countDocuments({ authorId: id }) 142 | resolve(count) 143 | }) 144 | } 145 | 146 | module.exports = Follow 147 | -------------------------------------------------------------------------------- /backend-api/models/Post.js: -------------------------------------------------------------------------------- 1 | const postsCollection = require("../db").db().collection("posts") 2 | const followsCollection = require("../db").db().collection("follows") 3 | const ObjectId = require("mongodb").ObjectId 4 | const User = require("./User") 5 | const sanitizeHTML = require("sanitize-html") 6 | 7 | postsCollection.createIndex({ title: "text", body: "text" }) 8 | 9 | let Post = function (data, userid, requestedPostId) { 10 | this.data = data 11 | this.errors = [] 12 | this.userid = userid 13 | this.requestedPostId = requestedPostId 14 | } 15 | 16 | Post.prototype.cleanUp = function () { 17 | if (typeof this.data.title != "string") { 18 | this.data.title = "" 19 | } 20 | if (typeof this.data.body != "string") { 21 | this.data.body = "" 22 | } 23 | 24 | // get rid of any bogus properties 25 | this.data = { 26 | title: sanitizeHTML(this.data.title.trim(), { allowedTags: [], allowedAttributes: {} }), 27 | body: sanitizeHTML(this.data.body.trim(), { allowedTags: [], allowedAttributes: {} }), 28 | createdDate: new Date(), 29 | author: new ObjectId(this.userid) 30 | } 31 | } 32 | 33 | Post.prototype.validate = function () { 34 | if (this.data.title == "") { 35 | this.errors.push("You must provide a title.") 36 | } 37 | if (this.data.body == "") { 38 | this.errors.push("You must provide post content.") 39 | } 40 | } 41 | 42 | Post.prototype.create = function () { 43 | return new Promise((resolve, reject) => { 44 | this.cleanUp() 45 | this.validate() 46 | if (!this.errors.length) { 47 | // save post into database 48 | postsCollection 49 | .insertOne(this.data) 50 | .then(info => { 51 | resolve(info.insertedId) 52 | }) 53 | .catch(e => { 54 | this.errors.push("Please try again later.") 55 | reject(this.errors) 56 | }) 57 | } else { 58 | reject(this.errors) 59 | } 60 | }) 61 | } 62 | 63 | Post.prototype.update = function () { 64 | return new Promise(async (resolve, reject) => { 65 | try { 66 | let post = await Post.findSingleById(this.requestedPostId, this.userid) 67 | if (post.isVisitorOwner) { 68 | // actually update the db 69 | let status = await this.actuallyUpdate() 70 | resolve(status) 71 | } else { 72 | reject() 73 | } 74 | } catch (e) { 75 | reject() 76 | } 77 | }) 78 | } 79 | 80 | Post.prototype.actuallyUpdate = function () { 81 | return new Promise(async (resolve, reject) => { 82 | this.cleanUp() 83 | this.validate() 84 | if (!this.errors.length) { 85 | await postsCollection.findOneAndUpdate({ _id: new ObjectId(this.requestedPostId) }, { $set: { title: this.data.title, body: this.data.body } }) 86 | resolve("success") 87 | } else { 88 | resolve("failure") 89 | } 90 | }) 91 | } 92 | 93 | Post.reusablePostQuery = function (uniqueOperations, visitorId, finalOperations = []) { 94 | return new Promise(async function (resolve, reject) { 95 | let aggOperations = uniqueOperations 96 | .concat([ 97 | { $lookup: { from: "users", localField: "author", foreignField: "_id", as: "authorDocument" } }, 98 | { 99 | $project: { 100 | title: 1, 101 | body: 1, 102 | createdDate: 1, 103 | authorId: "$author", 104 | author: { $arrayElemAt: ["$authorDocument", 0] } 105 | } 106 | } 107 | ]) 108 | .concat(finalOperations) 109 | 110 | let posts = await postsCollection.aggregate(aggOperations).toArray() 111 | 112 | // clean up author property in each post object 113 | posts = posts.map(function (post) { 114 | post.isVisitorOwner = post.authorId.equals(visitorId) 115 | post.authorId = undefined 116 | 117 | post.author = { 118 | username: post.author.username, 119 | avatar: new User(post.author, true).avatar 120 | } 121 | 122 | return post 123 | }) 124 | 125 | resolve(posts) 126 | }) 127 | } 128 | 129 | Post.findSingleById = function (id, visitorId) { 130 | return new Promise(async function (resolve, reject) { 131 | if (typeof id != "string" || !ObjectId.isValid(id)) { 132 | reject() 133 | return 134 | } 135 | 136 | let posts = await Post.reusablePostQuery([{ $match: { _id: new ObjectId(id) } }], visitorId) 137 | 138 | if (posts.length) { 139 | resolve(posts[0]) 140 | } else { 141 | reject() 142 | } 143 | }) 144 | } 145 | 146 | Post.findByAuthorId = function (authorId) { 147 | return Post.reusablePostQuery([{ $match: { author: authorId } }, { $sort: { createdDate: -1 } }]) 148 | } 149 | 150 | Post.delete = function (postIdToDelete, currentUserId) { 151 | return new Promise(async (resolve, reject) => { 152 | try { 153 | let post = await Post.findSingleById(postIdToDelete, currentUserId) 154 | if (post.isVisitorOwner) { 155 | await postsCollection.deleteOne({ _id: new ObjectId(postIdToDelete) }) 156 | resolve() 157 | } else { 158 | reject() 159 | } 160 | } catch (e) { 161 | reject() 162 | } 163 | }) 164 | } 165 | 166 | Post.search = function (searchTerm) { 167 | return new Promise(async (resolve, reject) => { 168 | if (typeof searchTerm == "string") { 169 | let posts = await Post.reusablePostQuery([{ $match: { $text: { $search: searchTerm } } }], undefined, [{ $sort: { score: { $meta: "textScore" } } }]) 170 | resolve(posts) 171 | } else { 172 | reject() 173 | } 174 | }) 175 | } 176 | 177 | Post.countPostsByAuthor = function (id) { 178 | return new Promise(async (resolve, reject) => { 179 | let postCount = await postsCollection.countDocuments({ author: id }) 180 | resolve(postCount) 181 | }) 182 | } 183 | 184 | Post.getFeed = async function (id) { 185 | // create an array of the user ids that the current user follows 186 | let followedUsers = await followsCollection.find({ authorId: new ObjectId(id) }).toArray() 187 | followedUsers = followedUsers.map(function (followDoc) { 188 | return followDoc.followedId 189 | }) 190 | 191 | // look for posts where the author is in the above array of followed users 192 | return Post.reusablePostQuery([{ $match: { author: { $in: followedUsers } } }, { $sort: { createdDate: -1 } }]) 193 | } 194 | 195 | module.exports = Post 196 | -------------------------------------------------------------------------------- /backend-api/models/User.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require("bcryptjs") 2 | const usersCollection = require('../db').db().collection("users") 3 | const validator = require("validator") 4 | const md5 = require('md5') 5 | 6 | let User = function(data, getAvatar) { 7 | this.data = data 8 | this.errors = [] 9 | if (getAvatar == undefined) {getAvatar = false} 10 | if (getAvatar) {this.getAvatar()} 11 | } 12 | 13 | User.prototype.cleanUp = function() { 14 | if (typeof(this.data.username) != "string") {this.data.username = ""} 15 | if (typeof(this.data.email) != "string") {this.data.email = ""} 16 | if (typeof(this.data.password) != "string") {this.data.password = ""} 17 | 18 | // get rid of any bogus properties 19 | this.data = { 20 | username: this.data.username.trim().toLowerCase(), 21 | email: this.data.email.trim().toLowerCase(), 22 | password: this.data.password 23 | } 24 | } 25 | 26 | User.prototype.validate = function() { 27 | return new Promise(async (resolve, reject) => { 28 | if (this.data.username == "") {this.errors.push("You must provide a username.")} 29 | if (this.data.username != "" && !validator.isAlphanumeric(this.data.username)) {this.errors.push("Username can only contain letters and numbers.")} 30 | if (!validator.isEmail(this.data.email)) {this.errors.push("You must provide a valid email address.")} 31 | if (this.data.password == "") {this.errors.push("You must provide a password.")} 32 | if (this.data.password.length > 0 && this.data.password.length < 12) {this.errors.push("Password must be at least 12 characters.")} 33 | if (this.data.password.length > 50) {this.errors.push("Password cannot exceed 50 characters.")} 34 | if (this.data.username.length > 0 && this.data.username.length < 3) {this.errors.push("Username must be at least 3 characters.")} 35 | if (this.data.username.length > 30) {this.errors.push("Username cannot exceed 30 characters.")} 36 | 37 | // Only if username is valid then check to see if it's already taken 38 | if (this.data.username.length > 2 && this.data.username.length < 31 && validator.isAlphanumeric(this.data.username)) { 39 | let usernameExists = await usersCollection.findOne({username: this.data.username}) 40 | if (usernameExists) {this.errors.push("That username is already taken.")} 41 | } 42 | 43 | // Only if email is valid then check to see if it's already taken 44 | if (validator.isEmail(this.data.email)) { 45 | let emailExists = await usersCollection.findOne({email: this.data.email}) 46 | if (emailExists) {this.errors.push("That email is already being used.")} 47 | } 48 | resolve() 49 | }) 50 | } 51 | 52 | User.prototype.login = function() { 53 | return new Promise((resolve, reject) => { 54 | this.cleanUp() 55 | usersCollection.findOne({username: this.data.username}).then((attemptedUser) => { 56 | if (attemptedUser && bcrypt.compareSync(this.data.password, attemptedUser.password)) { 57 | this.data = attemptedUser 58 | this.getAvatar() 59 | resolve("Congrats!") 60 | } else { 61 | reject("Invalid username / password.") 62 | } 63 | }).catch(function(e) { 64 | reject("Please try again later.") 65 | }) 66 | }) 67 | } 68 | 69 | User.prototype.register = function() { 70 | return new Promise(async (resolve, reject) => { 71 | // Step #1: Validate user data 72 | this.cleanUp() 73 | await this.validate() 74 | 75 | // Step #2: Only if there are no validation errors 76 | // then save the user data into a database 77 | if (!this.errors.length) { 78 | // hash user password 79 | let salt = bcrypt.genSaltSync(10) 80 | this.data.password = bcrypt.hashSync(this.data.password, salt) 81 | await usersCollection.insertOne(this.data) 82 | this.getAvatar() 83 | resolve() 84 | } else { 85 | reject(this.errors) 86 | } 87 | }) 88 | } 89 | 90 | User.prototype.getAvatar = function() { 91 | this.avatar = `https://gravatar.com/avatar/${md5(this.data.email)}?s=128` 92 | } 93 | 94 | User.findByUsername = function(username) { 95 | return new Promise(function(resolve, reject) { 96 | if (typeof(username) != "string") { 97 | reject() 98 | return 99 | } 100 | usersCollection.findOne({username: username}).then(function(userDoc) { 101 | if (userDoc) { 102 | userDoc = new User(userDoc, true) 103 | userDoc = { 104 | _id: userDoc.data._id, 105 | username: userDoc.data.username, 106 | avatar: userDoc.avatar 107 | } 108 | resolve(userDoc) 109 | } else { 110 | reject() 111 | } 112 | }).catch(function(e) { 113 | reject() 114 | }) 115 | }) 116 | } 117 | 118 | User.doesEmailExist = function(email) { 119 | return new Promise(async function(resolve, reject) { 120 | if (typeof(email) != "string") { 121 | resolve(false) 122 | return 123 | } 124 | 125 | let user = await usersCollection.findOne({email: email}) 126 | if (user) { 127 | resolve(true) 128 | } else { 129 | resolve(false) 130 | } 131 | }) 132 | } 133 | 134 | module.exports = User -------------------------------------------------------------------------------- /backend-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "complex-app", 3 | "version": "1.0.0", 4 | "description": "hey dude", 5 | "main": "db.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+ssh://git@github.com/mongodb/node-mongodb-native.git" 9 | }, 10 | "scripts": { 11 | "watch": "nodemon db", 12 | "start": "node db", 13 | "test": "echo \"Error: no test specified\" && exit 1" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "overrides": { 19 | "semver": "7.5.3" 20 | }, 21 | "dependencies": { 22 | "bcryptjs": "^2.4.3", 23 | "cors": "^2.8.5", 24 | "dotenv": "^16.3.1", 25 | "express": "^4.18.2", 26 | "jsonwebtoken": "^9.0.0", 27 | "md5": "^2.3.0", 28 | "mongodb": "^5.6.0", 29 | "nodemon": "^2.0.22", 30 | "sanitize-html": "^2.11.0", 31 | "socket.io": "^4.7.1", 32 | "validator": "^13.9.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend-api/router.js: -------------------------------------------------------------------------------- 1 | const apiRouter = require("express").Router() 2 | const userController = require("./controllers/userController") 3 | const postController = require("./controllers/postController") 4 | const followController = require("./controllers/followController") 5 | const cors = require("cors") 6 | 7 | apiRouter.use(cors()) 8 | 9 | apiRouter.get("/", (req, res) => res.json("Hello, if you see this message that means your backend is up and running successfully. Congrats! Now let's continue learning React!")) 10 | 11 | // check token to log out front-end if expired 12 | apiRouter.post("/checkToken", userController.checkToken) 13 | 14 | apiRouter.post("/getHomeFeed", userController.apiMustBeLoggedIn, userController.apiGetHomeFeed) 15 | apiRouter.post("/register", userController.apiRegister) 16 | apiRouter.post("/login", userController.apiLogin) 17 | apiRouter.get("/post/:id", postController.reactApiViewSingle) 18 | apiRouter.post("/post/:id/edit", userController.apiMustBeLoggedIn, postController.apiUpdate) 19 | apiRouter.delete("/post/:id", userController.apiMustBeLoggedIn, postController.apiDelete) 20 | apiRouter.post("/create-post", userController.apiMustBeLoggedIn, postController.apiCreate) 21 | apiRouter.post("/search", postController.search) 22 | 23 | apiRouter.post("/doesUsernameExist", userController.doesUsernameExist) 24 | apiRouter.post("/doesEmailExist", userController.doesEmailExist) 25 | 26 | // profile related routes 27 | apiRouter.post("/profile/:username", userController.ifUserExists, userController.sharedProfileData, userController.profileBasicData) 28 | apiRouter.get("/profile/:username/posts", userController.ifUserExists, userController.apiGetPostsByUsername) 29 | apiRouter.get("/profile/:username/followers", userController.ifUserExists, userController.profileFollowers) 30 | apiRouter.get("/profile/:username/following", userController.ifUserExists, userController.profileFollowing) 31 | 32 | // follow routes 33 | apiRouter.post("/addFollow/:username", userController.apiMustBeLoggedIn, followController.apiAddFollow) 34 | apiRouter.post("/removeFollow/:username", userController.apiMustBeLoggedIn, followController.apiRemoveFollow) 35 | 36 | module.exports = apiRouter 37 | -------------------------------------------------------------------------------- /generateHtml.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOMServer from "react-dom/server" 3 | import fs from "fs" 4 | import Footer from "./app/components/Footer" 5 | import Header from "./app/components/Header" 6 | import LoadingDotsIcon from "./app/components/LoadingDotsIcon" 7 | import { StaticRouter as Router } from "react-router" 8 | import StateContext from "./app/StateContext" 9 | 10 | function Shell() { 11 | return ( 12 | 13 | 14 |
15 |
16 | 17 |
18 |