├── views ├── include │ ├── tweets-list.pug │ └── auth-input.pug ├── error-page.pug ├── tweet-show.pug ├── layout.pug ├── home.pug ├── login.pug ├── signup.pug └── main-layout.pug ├── .gitignore ├── .sequelizerc ├── bin └── www ├── config ├── database.js └── index.js ├── db ├── seeders │ ├── 20200810123949-DemoUser.js │ └── 20200810211123-tweets.js ├── migrations │ ├── 20200810210044-create-tweet.js │ └── 20200810123100-create-user.js └── models │ ├── tweet.js │ ├── index.js │ └── user.js ├── public ├── css │ ├── reset.css │ ├── tweet-show.css │ ├── auth.css │ ├── nav.css │ └── home.css └── js │ ├── login.js │ ├── nav.js │ ├── utils │ └── auth.js │ ├── signup.js │ ├── tweet-show.js │ └── home.js ├── routes ├── utils │ ├── auth.js │ └── index.js ├── api │ ├── index.js │ ├── tweets.js │ └── users.js └── pages.js ├── package.json ├── app.js ├── README.md └── setup-instructions.md /views/include/tweets-list.pug: -------------------------------------------------------------------------------- 1 | ul.tweets-list -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | .DS_Store -------------------------------------------------------------------------------- /views/error-page.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block content 4 | h1 Sorry, that page doesn’t exist! -------------------------------------------------------------------------------- /views/include/auth-input.pug: -------------------------------------------------------------------------------- 1 | mixin authInput(label, name) 2 | .auth-input 3 | label(for=name)= label 4 | block -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('config', 'database.js'), 5 | 'models-path': path.resolve('db', 'models'), 6 | 'seeders-path': path.resolve('db', 'seeders'), 7 | 'migrations-path': path.resolve('db', 'migrations') 8 | }; -------------------------------------------------------------------------------- /views/tweet-show.pug: -------------------------------------------------------------------------------- 1 | extends main-layout.pug 2 | 3 | append head 4 | link(rel="stylesheet" href="/public/css/tweet-show.css") 5 | script(src="/public/js/tweet-show.js" type="module" defer) 6 | 7 | block feed 8 | .tweet-show 9 | .tweet-show-header 10 | h1 Thread 11 | .tweet-thread -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype 2 | html(lang='en') 3 | head 4 | block head 5 | meta(charset='utf-8') 6 | title Twitter Lite 7 | link(rel="stylesheet" href="/public/css/reset.css") 8 | 9 | body 10 | block content -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const db = require('../db/models'); 3 | const { port } = require('../config'); 4 | 5 | db.sequelize.authenticate() 6 | .then(() => { 7 | console.log('Connected to database successfully'); 8 | app.listen(port, () => console.log('Server is listening on port', port)); 9 | }) 10 | .catch(() => { 11 | console.log('Error connecting to database'); 12 | }); -------------------------------------------------------------------------------- /config/database.js: -------------------------------------------------------------------------------- 1 | const config = require("./index"); 2 | 3 | const db = config.db; 4 | const username = db.username; 5 | const password = db.password; 6 | const database = db.database; 7 | const host = db.host; 8 | 9 | module.exports = { 10 | development: { 11 | username, 12 | password, 13 | database, 14 | host, 15 | dialect: "postgres", 16 | seederStorage: "sequelize" 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | environment: process.env.NODE_ENV || "development", 3 | port: process.env.PORT || 8080, 4 | db: { 5 | username: process.env.DB_USERNAME || 'postgres', 6 | password: process.env.DB_PASSWORD, 7 | database: process.env.DB_DATABASE, 8 | host: process.env.DB_HOST, 9 | }, 10 | jwtConfig: { 11 | secret: process.env.JWT_SECRET, 12 | expiresIn: process.env.JWT_EXPIRES_IN, 13 | } 14 | }; -------------------------------------------------------------------------------- /db/seeders/20200810123949-DemoUser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bcrypt = require('bcryptjs'); 4 | 5 | module.exports = { 6 | up: async (queryInterface, Sequelize) => { 7 | await queryInterface.bulkInsert('Users', [ 8 | { username: 'DemoUser', email: 'demo@user.io', hashedPassword: await bcrypt.hash('password', 10) } 9 | ], { fields: ['username', 'email', 'hashedPassword'] }); 10 | }, 11 | 12 | down: async (queryInterface, Sequelize) => { 13 | await queryInterface.bulkDelete('Users', null, {}); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /public/css/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 15px; 3 | font-family: sans-serif; 4 | color: white; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | h1, h2, h3, h4, h5, h6, a, ol, li, ul, div, span, 12 | body, header, nav { 13 | font-size: 1rem; 14 | color: inherit; 15 | background-color: transparent; 16 | margin: 0; 17 | padding: 0; 18 | outline: 0; 19 | font-weight: normal; 20 | } 21 | 22 | a { 23 | text-decoration: none; 24 | } 25 | 26 | ul, ol { 27 | list-style: none; 28 | } 29 | 30 | body { 31 | background-color: rgb(21, 32, 43); 32 | } -------------------------------------------------------------------------------- /routes/utils/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const { secret, expiresIn } = require('../../config').jwtConfig; 3 | 4 | const db = require('../../db/models'); 5 | 6 | const { User } = db; 7 | 8 | exports.getUserToken = (user) => { 9 | return jwt.sign( 10 | { id: user.id, username: user.username }, 11 | secret, 12 | { expiresIn: parseInt(expiresIn) } // expressed in seconds 13 | ); 14 | }; 15 | 16 | exports.getUserFromToken = async (token) => { 17 | try { 18 | const payload = jwt.verify( 19 | token, 20 | secret 21 | ); 22 | return await User.findByPk(payload.id); 23 | } catch(err) { 24 | return null; 25 | } 26 | } -------------------------------------------------------------------------------- /views/home.pug: -------------------------------------------------------------------------------- 1 | extends main-layout.pug 2 | 3 | append head 4 | link(rel="stylesheet" href="/public/css/home.css") 5 | script(src="/public/js/home.js" type="module" defer) 6 | 7 | block feed 8 | .feed 9 | .feed-header 10 | a(href="/home"): h1 Home 11 | .feed-form 12 | .user-icon-button 13 |
14 | 15 |
16 | form#tweet-form 17 | input(type="hidden" name="_csrf" value=csrf) 18 | textarea(placeholder="What's happening?" name="message") 19 | .feed-form-footer 20 | button(type="submit" disabled) Tweet 21 | include include/tweets-list.pug -------------------------------------------------------------------------------- /routes/utils/index.js: -------------------------------------------------------------------------------- 1 | const { validationResult } = require('express-validator'); 2 | 3 | exports.routeHandler = (handler) => async (req, res, next) => { 4 | try { 5 | await handler(req, res, next); 6 | } catch(err) { 7 | next(err); 8 | } 9 | }; 10 | 11 | exports.handleValidationErrors = (req, res, next) => { 12 | const validationErrors = validationResult(req); 13 | 14 | if (!validationErrors.isEmpty()) { 15 | const errors = validationErrors.array().map((error) => error.msg); 16 | 17 | const err = Error("Bad request."); 18 | err.errors = errors; 19 | err.status = 400; 20 | err.title = "Bad request."; 21 | next(err); 22 | } 23 | next(); 24 | } -------------------------------------------------------------------------------- /public/css/tweet-show.css: -------------------------------------------------------------------------------- 1 | .tweet-thread > * { 2 | border-bottom: 1.25px solid rgb(56, 68, 77); 3 | padding: 7px 15px 5px 15px; 4 | display: flex; 5 | } 6 | 7 | .tweet-thread .tweet { 8 | margin-left: 10px; 9 | } 10 | 11 | .tweet-thread .user-icon { 12 | font-size: 24px; 13 | height: 49px; 14 | width: 49px; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | border: 1px solid white; 19 | border-radius: 50%; 20 | } 21 | 22 | .tweet-show { 23 | width: 600px; 24 | border-left: 1.25px solid rgb(56, 68, 77); 25 | border-right: 1.25px solid rgb(56, 68, 77); 26 | } 27 | 28 | .tweet-show-header { 29 | padding: 0 15px; 30 | height: 53px; 31 | display: flex; 32 | align-items: center; 33 | border-bottom: 1.25px solid rgb(56, 68, 77); 34 | } 35 | 36 | .tweet-show-header h1 { 37 | font-size: 19px; 38 | font-weight: bold; 39 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-lite", 3 | "version": "1.0.0", 4 | "description": "## Set Up", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "per-env", 8 | "start:development": "nodemon -r dotenv/config ./bin/www", 9 | "start:production": "node ./bin/www" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "bcryptjs": "^2.4.3", 16 | "cookie-parser": "^1.4.5", 17 | "csurf": "^1.11.0", 18 | "express": "^4.17.1", 19 | "express-bearer-token": "^2.4.0", 20 | "express-validator": "^6.6.1", 21 | "jsonwebtoken": "^8.5.1", 22 | "morgan": "^1.10.0", 23 | "per-env": "^1.0.2", 24 | "pg": "^8.3.0", 25 | "pug": "^2.0.4", 26 | "sequelize": "^6.3.4" 27 | }, 28 | "devDependencies": { 29 | "dotenv": "^8.2.0", 30 | "dotenv-cli": "^3.2.0", 31 | "nodemon": "^2.0.4", 32 | "sequelize-cli": "^6.2.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /db/seeders/20200810211123-tweets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('../models'); 4 | const { User } = db; 5 | 6 | module.exports = { 7 | up: async (queryInterface, Sequelize) => { 8 | const demoUser = await User.findOne({ 9 | where: { username: "DemoUser" } 10 | }); 11 | await queryInterface.bulkInsert('Tweets', [ 12 | { message: "Hello World! This is my first tweet!", userId: demoUser.id }, 13 | { message: "Second tweet of the day!", userId: demoUser.id }, 14 | { message: "Third tweet of the month!", userId: demoUser.id }, 15 | { message: "Fourth tweet of the year!", userId: demoUser.id }, 16 | { message: "Fifth tweet of the decade!", userId: demoUser.id }, 17 | { message: "Sixth tweet of the century!", userId: demoUser.id }, 18 | ], { fields: ['message', 'userId'] }); 19 | }, 20 | 21 | down: async (queryInterface, Sequelize) => { 22 | await queryInterface.bulkDelete('Tweets', null, {}); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /public/js/login.js: -------------------------------------------------------------------------------- 1 | import { disableFormButton } from "./utils/auth.js"; 2 | 3 | const form = document.querySelector('#login-form'); 4 | 5 | disableFormButton('#login-form input', '#login-form button'); 6 | 7 | form.addEventListener('submit', async (e) => { 8 | e.preventDefault(); 9 | const formData = new FormData(form); 10 | const username = formData.get('username'); 11 | const password = formData.get('password'); 12 | const _csrf = formData.get('_csrf'); 13 | 14 | const body = { username, password, _csrf }; 15 | 16 | const res = await fetch('/api/users/token', { 17 | method: "POST", 18 | body: JSON.stringify(body), 19 | headers: { 20 | "Content-Type": "application/json" 21 | } 22 | }); 23 | const data = await res.json(); 24 | if (!res.ok) { 25 | const { message } = data; 26 | const errorsContainer = document.querySelector('#errors-container'); 27 | errorsContainer.innerHTML = message; 28 | return; 29 | } 30 | 31 | window.location.href = '/home'; 32 | }); -------------------------------------------------------------------------------- /db/migrations/20200810210044-create-tweet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: async (queryInterface, Sequelize) => { 4 | await queryInterface.createTable("Tweets", { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | message: { 12 | type: Sequelize.STRING(280), 13 | allowNull: false, 14 | }, 15 | userId: { 16 | type: Sequelize.INTEGER, 17 | allowNull: false, 18 | references: { 19 | model: "Users", 20 | }, 21 | }, 22 | createdAt: { 23 | allowNull: false, 24 | type: Sequelize.DATE, 25 | defaultValue: Sequelize.fn("NOW"), 26 | }, 27 | updatedAt: { 28 | allowNull: false, 29 | type: Sequelize.DATE, 30 | defaultValue: Sequelize.fn("NOW"), 31 | }, 32 | }); 33 | }, 34 | down: async (queryInterface, Sequelize) => { 35 | await queryInterface.dropTable('Tweets'); 36 | } 37 | }; -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const app = express(); // initializing an express app 3 | 4 | const apiRouter = require('./routes/api'); 5 | const pagesRouter = require('./routes/pages'); 6 | 7 | app.set("view engine", "pug"); 8 | 9 | const logger = require('morgan'); 10 | 11 | app.use(logger('dev')); 12 | app.use(require('cookie-parser')()); 13 | app.use(express.json()); 14 | app.use(express.urlencoded({ extended: false })); 15 | 16 | app.use((req, res, next) => { 17 | res.setTimeout(1000); 18 | req.setTimeout(1000); 19 | 20 | next(); 21 | }); 22 | 23 | const { getUserFromToken } = require("./routes/utils/auth"); 24 | 25 | app.use(async (req, res, next) => { 26 | const token = req.cookies.token; 27 | if (!token) return next(); 28 | 29 | const user = await getUserFromToken(token, res); 30 | if (user) req.user = user; 31 | else res.clearCookie('token'); 32 | next(); 33 | }); 34 | 35 | app.use('/public', express.static('public')); 36 | app.use('/api', apiRouter); 37 | app.use('/', pagesRouter); 38 | 39 | 40 | module.exports = app; -------------------------------------------------------------------------------- /db/models/tweet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { 3 | Model 4 | } = require('sequelize'); 5 | module.exports = (sequelize, DataTypes) => { 6 | class Tweet extends Model { 7 | /** 8 | * Helper method for defining associations. 9 | * This method is not a part of Sequelize lifecycle. 10 | * The `models/index` file will call this method automatically. 11 | */ 12 | static associate(models) { 13 | Tweet.belongsTo(models.User, { foreignKey: 'userId' }); 14 | } 15 | }; 16 | Tweet.init( 17 | { 18 | message: { 19 | type: DataTypes.STRING, 20 | allowNull: false, 21 | validate: { 22 | // save, update, create 23 | len: { 24 | args: [1, 280], 25 | msg: "Message must be between 1 and 280 characters.", 26 | }, 27 | }, 28 | }, 29 | userId: { 30 | type: DataTypes.INTEGER, 31 | allowNull: false 32 | } 33 | }, 34 | { 35 | sequelize, 36 | modelName: "Tweet", 37 | } 38 | ); 39 | return Tweet; 40 | }; -------------------------------------------------------------------------------- /routes/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { environment } = require('../../config'); 4 | 5 | const usersRouter = require('./users'); 6 | const tweetsRouter = require('./tweets'); 7 | const { ValidationError } = require("sequelize"); 8 | 9 | router.use('/users', usersRouter); 10 | router.use('/tweets', tweetsRouter); 11 | 12 | router.use((err, req, res, next) => { 13 | if (err instanceof ValidationError) { 14 | err.errors = err.errors.map(e => e.message); 15 | } 16 | next(err); 17 | }); 18 | 19 | router.use((err, req, res, next) => { 20 | res.status(err.status || 500); 21 | const isProduction = environment === "production"; 22 | if (!isProduction) console.log(err); 23 | res.json({ 24 | title: err.title || "Server Error", 25 | message: err.message, 26 | errors: err.errors, 27 | stack: isProduction ? null : err.stack, 28 | }); 29 | }); 30 | 31 | router.use('*', (req, res) => { 32 | res.status(404).json({ message: 'route does not exist' }); 33 | }) 34 | 35 | module.exports = router; -------------------------------------------------------------------------------- /routes/pages.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const csrfProtection = require("csurf")({ cookie: true }); 5 | 6 | router.get('/login', csrfProtection, (req, res) => { 7 | if (req.user) { 8 | res.redirect('/home'); 9 | return; 10 | } 11 | res.render('login', { csrf: req.csrfToken() }); 12 | }); 13 | 14 | router.get('/signup', csrfProtection, (req, res) => { 15 | if (req.user) { 16 | res.redirect("/home"); 17 | return; 18 | } 19 | res.render("signup", { csrf: req.csrfToken() }); 20 | }); 21 | 22 | router.get('/home', csrfProtection, (req, res) => { 23 | if (!req.user) { 24 | res.redirect("/login"); 25 | return; 26 | } 27 | res.render("home", { username: req.user.username, csrf: req.csrfToken() }); 28 | }); 29 | 30 | router.get('/tweets/:id', (req, res) => { 31 | if (!req.user) { 32 | res.redirect("/login"); 33 | return; 34 | } 35 | res.render("tweet-show", { username: req.user.username }) 36 | }); 37 | 38 | router.get('*', (req, res) => { 39 | res.render('error-page'); 40 | }); 41 | 42 | module.exports = router; -------------------------------------------------------------------------------- /db/migrations/20200810123100-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: async (queryInterface, Sequelize) => { 4 | await queryInterface.createTable("Users", { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | username: { 12 | type: Sequelize.STRING(70), 13 | allowNull: false, 14 | unique: true, 15 | }, 16 | email: { 17 | type: Sequelize.STRING, 18 | allowNull: false, 19 | unique: true, 20 | }, 21 | hashedPassword: { 22 | type: Sequelize.STRING.BINARY, 23 | allowNull: false, 24 | }, 25 | createdAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE, 28 | defaultValue: Sequelize.fn("NOW"), 29 | }, 30 | updatedAt: { 31 | allowNull: false, 32 | type: Sequelize.DATE, 33 | defaultValue: Sequelize.fn("NOW"), 34 | }, 35 | }); 36 | }, 37 | down: async (queryInterface, Sequelize) => { 38 | await queryInterface.dropTable('Users'); 39 | } 40 | }; -------------------------------------------------------------------------------- /db/models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const Sequelize = require('sequelize'); 6 | const basename = path.basename(__filename); 7 | const env = process.env.NODE_ENV || 'development'; 8 | const config = require(__dirname + '/../../config/database.js')[env]; 9 | const db = {}; 10 | 11 | let sequelize; 12 | if (config.use_env_variable) { 13 | sequelize = new Sequelize(process.env[config.use_env_variable], config); 14 | } else { 15 | sequelize = new Sequelize(config.database, config.username, config.password, config); 16 | } 17 | 18 | fs 19 | .readdirSync(__dirname) 20 | .filter(file => { 21 | return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); 22 | }) 23 | .forEach(file => { 24 | const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); 25 | db[model.name] = model; 26 | }); 27 | 28 | Object.keys(db).forEach(modelName => { 29 | if (db[modelName].associate) { 30 | db[modelName].associate(db); 31 | } 32 | }); 33 | 34 | db.sequelize = sequelize; 35 | db.Sequelize = Sequelize; 36 | 37 | module.exports = db; 38 | -------------------------------------------------------------------------------- /public/js/nav.js: -------------------------------------------------------------------------------- 1 | const navigationMenu = () => { 2 | const userButton = document.querySelector(".user-nav-button"); 3 | const userButtonModal = document.querySelector( 4 | ".user-nav-button .modal-background" 5 | ); 6 | const userDropdownMenu = document.querySelector( 7 | ".user-nav-button ul.dropdown-menu" 8 | ); 9 | const logoutButton = document.querySelector(".user-nav-button .logout"); 10 | 11 | userButton.addEventListener("click", (e) => { 12 | e.preventDefault(); 13 | userButtonModal.style.display = "block"; 14 | userDropdownMenu.style.display = "block"; 15 | 16 | function openModal(e) { 17 | e.stopPropagation(); 18 | userButtonModal.removeEventListener("click", openModal); 19 | userButtonModal.style.display = "none"; 20 | userDropdownMenu.style.display = "none"; 21 | } 22 | 23 | userButtonModal.addEventListener("click", openModal); 24 | }); 25 | 26 | logoutButton.addEventListener("click", async (e) => { 27 | const res = await fetch("/api/users/session", { 28 | method: "DELETE", 29 | }); 30 | 31 | if (res.ok) { 32 | window.location.href = "/login"; 33 | } 34 | }); 35 | } 36 | 37 | navigationMenu(); -------------------------------------------------------------------------------- /public/js/utils/auth.js: -------------------------------------------------------------------------------- 1 | export const disableFormButton = (inputsQuery, buttonQuery) => { 2 | const inputs = document.querySelectorAll(inputsQuery); 3 | 4 | const button = document.querySelector(buttonQuery); 5 | const isAnyInputValueEmpty = () => { 6 | for (let input of inputs) { 7 | if (input.value === "") return true; 8 | } 9 | return false; 10 | }; 11 | inputs.forEach((input) => { 12 | input.addEventListener("input", (e) => { 13 | if (!isAnyInputValueEmpty()) { 14 | button.disabled = false; 15 | } else { 16 | button.disabled = true; 17 | } 18 | }); 19 | }); 20 | }; 21 | 22 | export const getToken = () => { 23 | return document.cookie.split("; ").find((cookie) => { 24 | const [key, value] = cookie.split("="); 25 | return key === "token"; 26 | }); 27 | }; 28 | 29 | export const getUser = () => { 30 | const token = getToken(); 31 | 32 | const payloadEncoded = token.split(".")[1]; 33 | const payload = atob(payloadEncoded); 34 | 35 | const user = JSON.parse(payload); 36 | 37 | return user; 38 | }; 39 | 40 | export const redirectIfLoggedIn = async () => { 41 | const res = await fetch('/api/users/token'); 42 | if (res.ok) window.location.href = '/'; 43 | }; 44 | -------------------------------------------------------------------------------- /db/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { 3 | Model 4 | } = require('sequelize'); 5 | const bcrypt = require('bcryptjs'); 6 | 7 | module.exports = (sequelize, DataTypes) => { 8 | class User extends Model { 9 | validatePassword(password) { 10 | return bcrypt.compareSync(password, this.hashedPassword.toString()); 11 | } 12 | 13 | static associate(models) { 14 | User.hasMany(models.Tweet, { foreignKey: "userId" }); 15 | } 16 | }; 17 | User.init( 18 | { 19 | username: { 20 | type: DataTypes.STRING, 21 | allowNull: false, 22 | unique: true, 23 | validate: { 24 | // save, update, create 25 | len: { 26 | args: [5, 70], 27 | msg: "Username must be between 5 and 70 characters.", 28 | }, 29 | }, 30 | }, 31 | email: { 32 | type: DataTypes.STRING, 33 | allowNull: false, 34 | unique: true, 35 | validate: { 36 | isEmail: true, 37 | }, 38 | }, 39 | hashedPassword: { 40 | type: DataTypes.STRING.BINARY, 41 | allowNull: false, 42 | }, 43 | }, 44 | { 45 | sequelize, 46 | modelName: "User", 47 | } 48 | ); 49 | return User; 50 | }; -------------------------------------------------------------------------------- /public/js/signup.js: -------------------------------------------------------------------------------- 1 | import { disableFormButton } from './utils/auth.js'; 2 | 3 | const form = document.querySelector('#signup-form'); 4 | const errorsContainer = document.querySelector("#errors-container"); 5 | 6 | disableFormButton("#signup-form input", "#signup-form button"); 7 | 8 | form.addEventListener('submit', async (e) => { 9 | console.log('submitting'); 10 | e.preventDefault(); 11 | const formData = new FormData(form); 12 | const email = formData.get('email'); 13 | const username = formData.get('username'); 14 | const password = formData.get('password'); 15 | const password2 = formData.get('password2'); 16 | const _csrf = formData.get('_csrf'); 17 | 18 | const body = { email, username, password, password2, _csrf }; 19 | errorsContainer.innerHTML = ''; 20 | const res = await fetch('/api/users', { 21 | method: "POST", 22 | body: JSON.stringify(body), 23 | headers: { 24 | "Content-Type": "application/json" 25 | } 26 | }); 27 | const data = await res.json(); 28 | if (!res.ok) { 29 | const { message, errors } = data; 30 | for (let error of errors) { 31 | const errorLi = document.createElement('li'); 32 | errorLi.innerHTML = error; 33 | errorsContainer.appendChild(errorLi); 34 | } 35 | return; 36 | } 37 | 38 | window.location.href = '/home'; 39 | }); -------------------------------------------------------------------------------- /views/login.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | include include/auth-input.pug 4 | 5 | append head 6 | script(src="/public/js/login.js" type="module" defer) 7 | link(rel="stylesheet" href="/public/css/auth.css") 8 | 9 | block content 10 | form#login-form 11 | 12 | h1 Log in to Twitter 13 | #errors-container 14 | input(type="hidden" name="_csrf" value=csrf) 15 | +authInput("Email or username", "username") 16 | input(type="text" name="username" required) 17 | +authInput("Password", "password") 18 | input(type="password" name="password" required) 19 | button(type="submit" disabled) Log in 20 | a.link-to-other-form(href="/signup") Sign up for Twitter 21 | -------------------------------------------------------------------------------- /views/signup.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | include include/auth-input.pug 4 | 5 | append head 6 | script(src="/public/js/signup.js" type="module" defer) 7 | link(rel="stylesheet" href="/public/css/auth.css") 8 | 9 | block content 10 | form#signup-form 11 | 12 | h1 Sign up for Twitter 13 | ul#errors-container 14 | input(type="hidden" name="_csrf" value=csrf) 15 | +authInput("Email", "email") 16 | input(type="email" name="email" required) 17 | +authInput("Username", "username") 18 | input(type="text" name="username" required) 19 | +authInput("Password", "password") 20 | input(type="password" name="password" required) 21 | +authInput("Confirm Password", "password2") 22 | input(type="password" name="password2" required) 23 | button(type="submit" disabled) Sign up 24 | a.link-to-other-form(href="/login") Log in to Twitter 25 | -------------------------------------------------------------------------------- /public/css/auth.css: -------------------------------------------------------------------------------- 1 | .auth-bird { 2 | height: 39px; 3 | } 4 | 5 | .auth-bird path { 6 | fill: white; 7 | } 8 | 9 | h1 { 10 | font-size: 23px; 11 | text-align: center; 12 | margin-top: 30px; 13 | margin-bottom: 10px; 14 | } 15 | 16 | #login-form, #signup-form { 17 | width: 600px; 18 | margin: 0 auto; 19 | margin-top: 20px; 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | 24 | .auth-input { 25 | background-color: rgb(25, 39, 52); 26 | display: flex; 27 | flex-direction: column; 28 | margin: 10px 15px; 29 | } 30 | 31 | .auth-input label { 32 | padding: 5px 10px 0 10px; 33 | color: rgb(136, 153, 166); 34 | } 35 | 36 | .auth-input input { 37 | padding: 2px 10px 5px 10px; 38 | margin: 0px; 39 | background-color: transparent; 40 | border: 0; 41 | color: white; 42 | font-size: 19px; 43 | border-bottom: 2px solid rgb(136, 153, 166); 44 | } 45 | 46 | .auth-input input:focus { 47 | outline: 0; 48 | } 49 | 50 | button[type="submit"] { 51 | height: 48px; 52 | border-radius: 24px; 53 | outline: 0; 54 | padding: 0; 55 | border: 0; 56 | margin: 10px 0; 57 | color: white; 58 | background-color: rgb(29, 161, 242); 59 | cursor: pointer; 60 | } 61 | 62 | button[type="submit"]:disabled { 63 | background-color: rgb(35, 96, 142); 64 | color: rgb(137, 143, 148); 65 | cursor: default; 66 | } 67 | 68 | #errors-container { 69 | color: rgb(224, 36, 94); 70 | } 71 | 72 | a.link-to-other-form { 73 | color: rgb(27, 149, 224); 74 | margin: 0 auto; 75 | margin-top: 20px; 76 | } 77 | 78 | a.link-to-other-form:hover { 79 | text-decoration: underline; 80 | } -------------------------------------------------------------------------------- /public/js/tweet-show.js: -------------------------------------------------------------------------------- 1 | const getTweet = async (id) => { 2 | const res = await fetch("/api/tweets/" + id); 3 | const data = await res.json(); 4 | return data; 5 | }; 6 | 7 | const createTweet = (tweet) => { 8 | const createdAt = new Date(tweet.createdAt); 9 | const timeOptions = { 10 | minute: "numeric", 11 | hour: "numeric", 12 | }; 13 | 14 | const dateOptions = { 15 | year: "numeric", 16 | month: "short", 17 | day: "numeric", 18 | }; 19 | 20 | const timestamp = 21 | createdAt.toLocaleString("en-US", timeOptions) + 22 | " · " + 23 | createdAt.toLocaleString("en-US", dateOptions); 24 | 25 | return ` 26 |
27 |
28 | 29 |
30 |
31 |
32 | @${tweet.User.username} · ${timestamp} 33 | 36 | 37 | 40 |
41 |
42 | ${tweet.message} 43 |
44 |
45 | `; 46 | }; 47 | 48 | const populateTweetsThread = async () => { 49 | const tweetsList = document.querySelector(".tweet-thread"); 50 | 51 | const tweetId = window.location.pathname.split("/")[2]; 52 | 53 | const { tweet } = await getTweet(tweetId); 54 | const tweetEle = createTweet(tweet); 55 | tweetsList.innerHTML += tweetEle; 56 | }; 57 | 58 | populateTweetsThread(); -------------------------------------------------------------------------------- /views/main-layout.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | append head 4 | link(rel="stylesheet" href="/public/css/nav.css") 5 | script(src="/public/js/nav.js" type="module" defer) 6 | 7 | block content 8 | header 9 | nav 10 | ul.top-nav 11 | a(href="/home") 12 | li.logo 13 | 14 | a(href="/home") 15 | li 16 | 17 | div Home 18 | .user-nav-button 19 | .user-icon 20 | 21 | .user-info @#{username} 22 | .dropdown-arrow 23 | 24 | .modal-background(style="display: none") 25 | ul.dropdown-menu(style="display: none") 26 | li: button.logout Log out @#{username} 27 | main 28 | block feed -------------------------------------------------------------------------------- /routes/api/tweets.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const { check } = require('express-validator'); 5 | const { routeHandler, handleValidationErrors } = require("../utils"); 6 | const csrfProtection = require("csurf")({ cookie: true }); 7 | 8 | const db = require('../../db/models'); 9 | const { Tweet, User } = db; 10 | 11 | router.get('/', routeHandler(async (req, res) => { 12 | const tweets = await Tweet.findAll({ 13 | include: [{ 14 | model: User, 15 | attributes: ['username'] 16 | }], 17 | order: [['createdAt', 'DESC']] 18 | }); 19 | 20 | res.json({ tweets }); 21 | })); 22 | 23 | const validateTweet = [ 24 | check("message") 25 | .exists() 26 | .isLength({ min: 1, max: 280 }), 27 | handleValidationErrors 28 | ] 29 | 30 | router.post('/', csrfProtection, validateTweet, routeHandler(async (req, res)=> { 31 | const { message } = req.body; 32 | const userId = req.user.id; 33 | 34 | const newTweet = await Tweet.create({ message, userId }); 35 | const tweet = await Tweet.findByPk(newTweet.id, { 36 | include: [ 37 | { 38 | model: User, 39 | attributes: ["username"], 40 | }, 41 | ] 42 | }); 43 | res.json({ tweet }); 44 | })); 45 | 46 | router.get('/:id', routeHandler(async (req, res, next) => { 47 | const tweet = await Tweet.findByPk(req.params.id, { 48 | include: [ 49 | { 50 | model: User, 51 | attributes: ["username"], 52 | }, 53 | ], 54 | }); 55 | if (!tweet) { 56 | const err = new Error('Tweet not found.'); 57 | err.status = 404; 58 | next(err); 59 | return; 60 | } 61 | res.json({ tweet }); 62 | })); 63 | 64 | router.delete('/:id', routeHandler(async (req, res) => { 65 | const tweet = await Tweet.findByPk(req.params.id, { 66 | include: [ 67 | { 68 | model: User, 69 | attributes: ["username"], 70 | }, 71 | ], 72 | }); 73 | if (!tweet) { 74 | const err = new Error("Tweet not found."); 75 | err.status = 404; 76 | next(err); 77 | return; 78 | } 79 | 80 | await tweet.destroy(); 81 | res.json({ message: 'success' }); 82 | })); 83 | 84 | module.exports = router; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Lite Walkthrough 2 | 3 | [Google Drive Folder for All Videos] 4 | 5 | [Github Repo for Twitter Lite Walkthrough] 6 | 7 | ## Set Up Walkthrough 8 | 9 | [Set Up Instructions] 10 | 11 | [Set Up Walkthrough] 12 | 13 | ## Auth Walkthrough 14 | 15 | [Auth Part 1 Walkthrough] 16 | 17 | [Auth Part 2 Walkthrough] 18 | 19 | [Auth Part 3 Walkthrough] 20 | 21 | ## Auth CSS Walkthrough 22 | 23 | [Auth CSS Part 1 Walkthrough] 24 | 25 | [Auth CSS Part 2 Walkthrough] 26 | 27 | [Auth CSS Part 3 Walkthrough] 28 | 29 | [Auth CSS Part 4 Walkthrough] 30 | 31 | [Auth CSS Part 5 Walkthrough] 32 | 33 | ## Feature 1 - Tweets Walkthrough 34 | 35 | [Tweets Part 1 Walkthrough] 36 | 37 | - Beginning of video shows how to configure GitHub for pull requests 38 | 39 | [Tweets Part 2 Walkthrough] 40 | 41 | [Pull Request Part 2 Walkthrough] 42 | 43 | - How to checkout a remote repository 44 | - `git checkout --track origin/remote-branch-name` 45 | - `git pull origin remote-branch-name` 46 | 47 | [Google Drive Folder for All Videos]: https://drive.google.com/drive/folders/1mYfD1ygA9Z5hN_pTb4yzCSFPHktIcXG5?usp=sharing 48 | [Github Repo for Twitter Lite Walkthrough]: https://github.com/ssoonmi/twitter-lite-walkthrough 49 | [Set Up Instructions]: ./setup-instructions.md 50 | [Set Up Walkthrough]: https://drive.google.com/file/d/10O2W68gt1wh8ptTDJ_OO49Kd7I7vIU6y/view?usp=sharing 51 | [Auth Part 1 Walkthrough]: https://drive.google.com/file/d/171OhUYsA0cTt8jGCgYvk7F1l1qbpVNxo/view?usp=sharing 52 | [Auth Part 2 Walkthrough]: https://drive.google.com/file/d/1Yfmdsgf6jFhfwULC0WEkCFEdj4SktDbo/view?usp=sharing 53 | [Auth Part 3 Walkthrough]: https://drive.google.com/file/d/1LW-sSrhB-fKjoYd1dC_rEXLiBf_D1JDD/view?usp=sharing 54 | [Auth CSS Part 1 Walkthrough]: https://drive.google.com/file/d/1UNx6HJvD5IeOgX01MO5veXohZ3gWBndv/view?usp=sharing 55 | [Auth CSS Part 2 Walkthrough]: https://drive.google.com/file/d/1D61Tj2wXtsZX_upmpQZkYvTt0r9o3zAG/view?usp=sharing 56 | [Auth CSS Part 3 Walkthrough]: https://drive.google.com/file/d/1vv4iRqFe-3Badf-w3ytuXpbMj7TbdR1y/view?usp=sharing 57 | [Auth CSS Part 4 Walkthrough]: https://drive.google.com/file/d/1k-QwxhDjmYkCzpOywB_WTK4-36ubWPop/view?usp=sharing 58 | [Auth CSS Part 5 Walkthrough]: https://drive.google.com/file/d/1PcNGNbHtEF4O9DYMR790ZRq_EjU89jJk/view?usp=sharing 59 | [Tweets Part 1 Walkthrough]: https://drive.google.com/file/d/1GOxj3k1BdJkkgZdHh5QlCGfUGavNietZ/view?usp=sharing 60 | [Tweets Part 2 Walkthrough]: https://drive.google.com/file/d/1DC37mNATOWma6EyVCoiE1ho_o7tG53yI/view?usp=sharing 61 | [Pull Request Part 2 Walkthrough]: https://drive.google.com/file/d/1787xBDsWOVxsH3D8EYCXnKU0Ncg_C8RD/view?usp=sharing -------------------------------------------------------------------------------- /public/css/nav.css: -------------------------------------------------------------------------------- 1 | .nav-icons { 2 | height: 26px; 3 | } 4 | 5 | .nav-icons path { 6 | fill: white; 7 | } 8 | 9 | .top-nav li { 10 | display: flex; 11 | align-items: center; 12 | padding: 10px; 13 | margin: 4px 0; 14 | border-radius: 23px; 15 | width: fit-content; 16 | } 17 | 18 | li:hover { 19 | background-color: rgba(29, 161, 242, 0.1); 20 | } 21 | 22 | .top-nav li div { 23 | height: fit-content; 24 | font-size: 19px; 25 | margin-right: 15px; 26 | margin-left: 20px; 27 | font-weight: bold; 28 | } 29 | 30 | .top-nav li:hover div { 31 | color: rgb(29, 161, 242); 32 | } 33 | 34 | .top-nav li:hover:not(.logo) .nav-icons path { 35 | fill: rgb(29, 161, 242); 36 | } 37 | 38 | body { 39 | display: flex; 40 | } 41 | 42 | header { 43 | flex-basis: 275px; 44 | flex-grow: 1; 45 | display: flex; 46 | justify-content: flex-end; 47 | height: 100vh; 48 | } 49 | 50 | nav { 51 | width: 275px; 52 | height: 100%; 53 | display: flex; 54 | flex-direction: column; 55 | justify-content: space-between; 56 | padding: 0 10px; 57 | } 58 | 59 | .user-nav-button { 60 | margin-top: 20px; 61 | margin-bottom: 10px; 62 | padding: 10px; 63 | display: flex; 64 | align-items: center; 65 | border-radius: 30px; 66 | cursor: pointer; 67 | position: relative; 68 | } 69 | 70 | .user-nav-button:hover { 71 | background-color: rgba(29, 161, 242, 0.1); 72 | } 73 | 74 | .user-nav-button .user-icon { 75 | font-size: 24px; 76 | height: 39px; 77 | width: 39px; 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | border: 1px solid white; 82 | border-radius: 50%; 83 | } 84 | 85 | .user-nav-button .user-info { 86 | margin-left: 10px; 87 | margin-right: 30px; 88 | height: fit-content; 89 | } 90 | 91 | .user-nav-button .dropdown-arrow { 92 | margin-left: auto; 93 | } 94 | 95 | .user-nav-button .modal-background { 96 | background-color: transparent; 97 | position: fixed; 98 | top: 0; 99 | left: 0; 100 | bottom: 0; 101 | right: 0; 102 | cursor: default; 103 | } 104 | 105 | .user-nav-button .dropdown-menu { 106 | position: absolute; 107 | bottom: 68px; 108 | width: 300px; 109 | padding: 10px 0; 110 | left: -12px; 111 | box-shadow: rgba(136, 153, 166, 0.2) 0px 0px 15px, rgba(136, 153, 166, 0.15) 0px 0px 3px 1px; 112 | background-color: rgb(21, 32, 43); 113 | border-radius: 15px; 114 | } 115 | 116 | .user-nav-button .dropdown-menu button { 117 | background-color: transparent; 118 | padding: 15px; 119 | margin: 0; 120 | outline: 0; 121 | border: 0; 122 | cursor: pointer; 123 | width: 100%; 124 | color: white; 125 | } 126 | 127 | main { 128 | flex-basis: 900px; 129 | flex-grow: 1; 130 | } -------------------------------------------------------------------------------- /public/css/home.css: -------------------------------------------------------------------------------- 1 | .feed { 2 | width: 600px; 3 | border-left: 1.25px solid rgb(56, 68, 77); 4 | border-right: 1.25px solid rgb(56, 68, 77); 5 | } 6 | 7 | .feed-header { 8 | padding: 0 15px; 9 | height: 53px; 10 | display: flex; 11 | align-items: center; 12 | border-bottom: 1.25px solid rgb(56, 68, 77); 13 | } 14 | 15 | .feed-header h1 { 16 | font-size: 19px; 17 | font-weight: bold; 18 | } 19 | 20 | .feed-form { 21 | border-bottom: 10px solid rgb(56, 68, 77); 22 | padding: 7px 15px 5px 15px; 23 | display: flex; 24 | } 25 | 26 | .feed .user-icon { 27 | font-size: 24px; 28 | height: 49px; 29 | width: 49px; 30 | min-height: 49px; 31 | min-width: 49px; 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | border: 1px solid white; 36 | border-radius: 50%; 37 | } 38 | 39 | #tweet-form { 40 | margin-left: 10px; 41 | width: 100%; 42 | } 43 | 44 | #tweet-form textarea { 45 | padding: 2px 10px 5px 10px; 46 | font-family: sans-serif; 47 | margin: 0px; 48 | background-color: transparent; 49 | border: 0; 50 | color: white; 51 | font-size: 19px; 52 | width: 100%; 53 | margin-bottom: 10px; 54 | } 55 | 56 | #tweet-form textarea:focus { 57 | outline: 0; 58 | } 59 | 60 | .feed-form-footer { 61 | display: flex; 62 | justify-content: flex-end; 63 | padding-bottom: 5px; 64 | } 65 | 66 | #tweet-form button[type="submit"] { 67 | height: 39px; 68 | border-radius: 24px; 69 | outline: 0; 70 | padding: 0 15px; 71 | border: 0; 72 | margin: 10px 0; 73 | color: white; 74 | background-color: rgb(29, 161, 242); 75 | cursor: pointer; 76 | } 77 | 78 | #tweet-form button[type="submit"]:disabled { 79 | background-color: rgb(35, 96, 142); 80 | color: rgb(137, 143, 148); 81 | cursor: default; 82 | } 83 | 84 | .tweets-list li { 85 | border-bottom: 1.25px solid rgb(56, 68, 77); 86 | padding: 7px 15px 5px 15px; 87 | display: flex; 88 | } 89 | 90 | .tweets-list .tweet { 91 | margin-left: 10px; 92 | } 93 | 94 | .tweet { 95 | width: 100%; 96 | position: relative; 97 | } 98 | 99 | .tweet-header { 100 | display: flex; 101 | justify-content: space-between; 102 | width: 100%; 103 | } 104 | 105 | .tweet-header .dropdown-arrow { 106 | margin-left: auto; 107 | } 108 | 109 | .tweet-header .modal-background { 110 | z-index: 300; 111 | background-color: transparent; 112 | position: fixed; 113 | top: 0; 114 | left: 0; 115 | bottom: 0; 116 | right: 0; 117 | cursor: default; 118 | } 119 | 120 | .tweet-header .dropdown-menu { 121 | z-index: 400; 122 | position: absolute; 123 | top: 0; 124 | width: 300px; 125 | padding: 10px 0; 126 | right: 0; 127 | box-shadow: rgba(136, 153, 166, 0.2) 0px 0px 15px, rgba(136, 153, 166, 0.15) 0px 0px 3px 1px; 128 | background-color: rgb(21, 32, 43); 129 | border-radius: 15px; 130 | } 131 | 132 | .tweet-header .dropdown-menu button { 133 | background-color: transparent; 134 | padding: 15px; 135 | margin: 0; 136 | outline: 0; 137 | border: 0; 138 | cursor: pointer; 139 | width: 100%; 140 | color: white; 141 | } -------------------------------------------------------------------------------- /routes/api/users.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { routeHandler, handleValidationErrors } = require('../utils'); 4 | const { getUserToken } = require('../utils/auth'); 5 | 6 | const csrfProtection = require("csurf")({ cookie: true }); 7 | 8 | const bcrypt = require('bcryptjs'); 9 | const { expiresIn } = require('../../config').jwtConfig; 10 | const db = require('../../db/models'); 11 | const { Op } = require("sequelize"); 12 | const { User } = db; 13 | 14 | const { check }= require('express-validator'); 15 | 16 | const validateUsername = [ 17 | check("username") 18 | .exists() 19 | ]; 20 | 21 | const validateAuthFields = [ 22 | check("username", "Username field must be between 5 and 70 characters") 23 | .isLength({ min: 5, max: 70 }), 24 | check("email", "Email fields must be a valid email") 25 | .exists() 26 | .isEmail(), 27 | check("password", "Password field must be 6 or more characters") 28 | .exists() 29 | .isLength({ min: 6, max: 70 }), 30 | check('password2', 'Confirm password field must have the same value as the password field') 31 | .exists() 32 | .custom((value, { req }) => value === req.body.password) 33 | ] 34 | 35 | // signup route 36 | router.post( 37 | "/", 38 | csrfProtection, 39 | validateUsername, 40 | validateAuthFields, 41 | handleValidationErrors, 42 | routeHandler(async (req, res, next) => { 43 | const { username, email, password } = req.body; 44 | 45 | const user = await User.create({ 46 | username, 47 | hashedPassword: bcrypt.hashSync(password, 10), 48 | email, 49 | }); 50 | 51 | const token = await getUserToken(user); 52 | res.cookie("token", token, { maxAge: expiresIn * 1000 }); // maxAge in milliseconds 53 | 54 | res.json({ id: user.id, token }); 55 | }) 56 | ); 57 | 58 | // logging in 59 | router.post( 60 | "/token", 61 | csrfProtection, 62 | validateUsername, 63 | handleValidationErrors, 64 | routeHandler(async (req, res, next) => { 65 | const { username, password } = req.body; 66 | const user = await User.findOne({ 67 | where: { 68 | [Op.or]: [{ username }, { email: username }], 69 | }, 70 | }); 71 | if (!user || !user.validatePassword(password)) { 72 | const err = new Error( 73 | "The username and password you entered did not match our records. Please double-check and try again." 74 | ); 75 | err.status = 401; 76 | err.title = "Unauthorized"; 77 | throw err; 78 | } 79 | 80 | const token = await getUserToken(user); 81 | 82 | res.cookie("token", token, { maxAge: expiresIn * 1000 }); // maxAge in milliseconds 83 | 84 | res.json({ id: user.id, token }); 85 | }) 86 | ); 87 | 88 | router.delete('/session', routeHandler(async(req,res) => { 89 | res.clearCookie('token'); 90 | res.json({ message: 'success' }); 91 | })); 92 | 93 | router.get('/token', routeHandler(async (req, res, next) => { 94 | if (req.user) { 95 | return res.json({ 96 | id: req.user.id, 97 | username: req.user.username 98 | }); 99 | } 100 | const err = new Error('Invalid token'); 101 | err.status = 401; 102 | next(err); 103 | })); 104 | 105 | module.exports = router; 106 | -------------------------------------------------------------------------------- /public/js/home.js: -------------------------------------------------------------------------------- 1 | import { disableFormButton } from "./utils/auth.js"; 2 | 3 | const getTweets = async () => { 4 | const res = await fetch('/api/tweets'); 5 | const data = await res.json(); 6 | return data; 7 | }; 8 | 9 | const createTweetLi = (tweet) => { 10 | const createdAt = new Date(tweet.createdAt); 11 | const timeOptions = { 12 | minute: "numeric", 13 | hour: "numeric", 14 | }; 15 | 16 | const dateOptions = { 17 | year: "numeric", 18 | month: "short", 19 | day: "numeric", 20 | }; 21 | 22 | const timestamp = 23 | createdAt.toLocaleString("en-US", timeOptions) + 24 | " · " + 25 | createdAt.toLocaleString("en-US", dateOptions); 26 | 27 | return ` 28 | 29 |
  • 30 |
    31 | 32 |
    33 |
    34 |
    35 | @${tweet.User.username} · ${timestamp} 36 | 39 | 40 | 43 |
    44 |
    45 | ${tweet.message} 46 |
    47 |
    48 |
  • 49 |
    50 | `; 51 | } 52 | 53 | const populateTweetsList = async () => { 54 | const tweetsList = document.querySelector('.tweets-list'); 55 | 56 | const { tweets } = await getTweets(); 57 | for (let tweet of tweets) { 58 | const tweetLi = createTweetLi(tweet); 59 | 60 | tweetsList.innerHTML += tweetLi; 61 | } 62 | 63 | const tweetLis = document.querySelectorAll(".tweets-list > a"); 64 | const tweetDropdownArrows = document.querySelectorAll(".tweets-list .dropdown-arrow"); 65 | const tweetModals = document.querySelectorAll(".tweets-list .modal-background"); 66 | const deleteButtons = document.querySelectorAll(".tweets-list .delete"); 67 | const tweetDropdowns = document.querySelectorAll(".tweets-list .dropdown-menu"); 68 | 69 | console.log({ 70 | tweetLis, 71 | tweetDropdownArrows, 72 | tweetModals, 73 | deleteButtons, 74 | tweetDropdowns, 75 | length: tweetLis.length 76 | }) 77 | 78 | for (let idx = 0; idx < tweetLis.length; idx++) { 79 | const tweetLi = tweetLis[idx]; 80 | const tweetDropdownArrow = tweetDropdownArrows[idx]; 81 | const tweetModal = tweetModals[idx]; 82 | const deleteButton = deleteButtons[idx]; 83 | const tweetDropdown = tweetDropdowns[idx]; 84 | 85 | tweetDropdownArrow.addEventListener("click", (e) => { 86 | e.preventDefault(); 87 | e.stopPropagation(); 88 | tweetModal.style.display = "block"; 89 | tweetDropdown.style.display = "block"; 90 | 91 | function closeModal(e) { 92 | e.preventDefault(); 93 | e.stopPropagation(); 94 | tweetModal.removeEventListener("click", closeModal); 95 | deleteButton.removeEventListener("click", deleteTweet); 96 | tweetModal.style.display = "none"; 97 | tweetDropdown.style.display = "none"; 98 | } 99 | 100 | async function deleteTweet(e) { 101 | e.preventDefault(); 102 | e.stopPropagation(); 103 | const res = await fetch("/api/tweets/" + deleteButton.dataset.id, { 104 | method: "DELETE", 105 | }); 106 | 107 | if (res.ok) tweetLi.remove(); 108 | }; 109 | 110 | deleteButton.addEventListener("click", deleteTweet); 111 | 112 | tweetModal.addEventListener("click", closeModal); 113 | }); 114 | } 115 | } 116 | 117 | populateTweetsList(); 118 | 119 | const tweetForm = document.querySelector('#tweet-form'); 120 | 121 | tweetForm.addEventListener('submit', async e => { 122 | e.preventDefault(); 123 | const formData = new FormData(tweetForm); 124 | const message = formData.get('message'); 125 | const _csrf = formData.get('_csrf'); 126 | 127 | const body = { message, _csrf }; 128 | 129 | const res = await fetch('/api/tweets', { 130 | method: "POST", 131 | body: JSON.stringify(body), 132 | headers: { 133 | "Content-Type": "application/json" 134 | } 135 | }); 136 | 137 | const data = await res.json(); 138 | const { tweet } = data; 139 | 140 | const tweetsList = document.querySelector(".tweets-list"); 141 | const tweetLi = createTweetLi(tweet); 142 | 143 | tweetsList.innerHTML = tweetLi + tweetsList.innerHTML; 144 | }); 145 | 146 | disableFormButton('#tweet-form textarea', '#tweet-form button'); -------------------------------------------------------------------------------- /setup-instructions.md: -------------------------------------------------------------------------------- 1 | # Instructions 2 | 3 | ## Set Up 4 | 5 | ### 1. `.gitignore` 6 | 7 | Create a `.gitignore` and make sure to keep it up to date with files/folders 8 | that should not be pushed to GitHub!! 9 | 10 | ``` 11 | node_modules/ 12 | .env 13 | .DS_Store 14 | ``` 15 | 16 | ### 2. `.env` 17 | 18 | Create a `.env` file with environment variables to define your database, to 19 | define your port, and define your JWT: 20 | 21 | ``` 22 | DB_USERNAME= 23 | DB_PASSWORD= 24 | DB_DATABASE= 25 | DB_HOST= 26 | PORT= 27 | JWT_SECRET= 28 | JWT_EXPIRES_IN=604800 29 | ``` 30 | 31 | To get your nice `JWT_SECRET`, you can set the value to whatever the output of 32 | the following is when you run it in `node` repl: 33 | 34 | ```js 35 | require("crypto").randomBytes(32).toString("hex"); 36 | ``` 37 | 38 | ### 3. `.sequelizerc` 39 | 40 | Create a `.sequelizerc` file with the following Sequelize configurations: 41 | 42 | ```js 43 | const path = require('path'); 44 | 45 | module.exports = { 46 | 'config': path.resolve('config', 'database.js'), 47 | 'models-path': path.resolve('db', 'models'), 48 | 'seeders-path': path.resolve('db', 'seeders'), 49 | 'migrations-path': path.resolve('db', 'migrations') 50 | }; 51 | ``` 52 | 53 | ### 4. `config/index.js` 54 | 55 | Determine the default values for the environment variables if needed in 56 | `config/index.js`: 57 | 58 | ```js 59 | module.exports = { 60 | environment: process.env.NODE_ENV || "development", 61 | port: process.env.PORT || 8080, 62 | db: { 63 | username: process.env.DB_USERNAME || 'postgres', 64 | password: process.env.DB_PASSWORD, 65 | database: process.env.DB_DATABASE, 66 | host: process.env.DB_HOST, 67 | }, 68 | jwtConfig: { 69 | secret: process.env.JWT_SECRET, 70 | expiresIn: process.env.JWT_EXPIRES_IN, 71 | } 72 | }; 73 | ``` 74 | 75 | ### 5. Initialize npm and `npm install` 76 | 77 | Run `npm init -y` 78 | 79 | `npm install` the following: 80 | 81 | - express 82 | - pug@2 83 | - sequelize 84 | - pg 85 | - per-env 86 | - bcryptjs 87 | - cookie-parser 88 | - csurf 89 | - jsonwebtoken 90 | - express-bearer-token 91 | - morgan 92 | 93 | `npm install -D` the following: 94 | - sequelize-cli 95 | - dotenv 96 | - dotenv-cli 97 | - nodemon 98 | 99 | ### `npx sequelize init` 100 | 101 | Initialize Sequelize in your project by running: 102 | 103 | ``` 104 | npx sequelize init 105 | ``` 106 | 107 | Replace the `config/database.js` file contents with the following: 108 | 109 | ```js 110 | const config = require("./index"); 111 | 112 | const db = config.db; 113 | const username = db.username; 114 | const password = db.password; 115 | const database = db.database; 116 | const host = db.host; 117 | 118 | module.exports = { 119 | development: { 120 | username, 121 | password, 122 | database, 123 | host, 124 | dialect: "postgres", 125 | seederStorage: "sequelize" 126 | }, 127 | }; 128 | ``` 129 | 130 | ### `package.json` Scripts 131 | 132 | Add the following scripts in your `package.json` 133 | 134 | ```json 135 | { 136 | "start": "per-env", 137 | "start:development": "nodemon -r dotenv/config ./bin/www", 138 | "start:production": "node ./bin/www" 139 | } 140 | ``` 141 | 142 | ### `app.js` and `bin/www` 143 | 144 | Create a `app.js` file at the root of your project and initialize the 145 | express application and export it at the bottom. 146 | 147 | ```js 148 | const express = require('express'); 149 | const app = express(); 150 | 151 | module.exports = app; 152 | ``` 153 | 154 | Create a `bin/www` that will import the express app created in `app.js`, authenticate sequelize, and make the app accept requests on the port specified 155 | in your configurations. 156 | 157 | ```js 158 | const app = require('../app'); 159 | const db = require('../db/models'); 160 | const { port } = require('../config'); 161 | 162 | db.sequelize.authenticate() 163 | .then(() => { 164 | console.log('Connected to database successfully'); 165 | app.listen(port, () => console.log('Server is listening on port', port)); 166 | }) 167 | .catch(() => { 168 | console.log('Error connecting to database'); 169 | }); 170 | ``` 171 | 172 | ### Test your app with a simple route 173 | 174 | ```js 175 | app.get('/hello', (req, res) => { 176 | res.send('Hello World!'); 177 | }); 178 | ``` 179 | 180 | Delete after testing to see if it shows up. 181 | 182 | ### `app.set('view engine', 'pug')` 183 | 184 | Set the app's view engine to pug. 185 | 186 | ### Middlewares 187 | 188 | - morgan 189 | - express.json 190 | - express.urlencoded 191 | - cookie-parser 192 | - csurf 193 | 194 | #### `morgan` 195 | 196 | Set up morgan's logger middleware: 197 | 198 | ```js 199 | const morgan = require('morgan'); 200 | 201 | //... 202 | 203 | app.use(morgan('dev')); 204 | ``` 205 | 206 | #### `req.body` middlewares 207 | 208 | For requests with `Content-Type` of `application/json`: 209 | 210 | ```js 211 | app.use(express.json()); 212 | ``` 213 | 214 | For requests with `Content-Type` of `application/x-www-form-urlencoded`: 215 | 216 | ```js 217 | app.use(express.urlencoded({ extended: false })); 218 | ``` 219 | 220 | #### `cookie-parser` 221 | 222 | ```js 223 | app.use(require('cookie-parser')()); 224 | ``` 225 | 226 | OR 227 | 228 | ```js 229 | const cookieParser = require('cookie-parser'); 230 | app.use(cookieParser()); 231 | ``` 232 | 233 | #### `csurf` 234 | 235 | CSRF Protection on all routes, like the following, is not necessary: 236 | 237 | ```js 238 | const csrfProtection = require('csurf')({ cookie: true }); 239 | app.use(csrfProtection); 240 | ``` 241 | 242 | Instead, use it on individual routes, ex: 243 | 244 | ```js 245 | app.get('/tasks', csrfProtection, (req, res) => { 246 | // ... 247 | }); 248 | ``` 249 | 250 | ### `layout.pug` 251 | 252 | Create a general layout page based on how the common page layout on the website 253 | you are creating looks. 254 | 255 | ### Static Assets 256 | 257 | Serve assets in the `public` folder on `/public` route paths: 258 | 259 | ```js 260 | app.use("/public", express.static('public')); 261 | ``` 262 | 263 | ### Adding a favicon 264 | 265 | Save a `favicon.ico` in your `public` assets folder and add this into 266 | `layout.pug`: 267 | 268 | ```pug 269 | link(rel="icon" href="/public/favicon.ico" type="image/ico") 270 | ``` 271 | 272 | ### Page Not Found Error 273 | 274 | The last route defined before error middlewares: 275 | 276 | ```js 277 | // app.js 278 | 279 | app.use((req, res, next) => { 280 | res.render('error-page'); 281 | }); 282 | ``` 283 | 284 | Create a template called `error-page.pug` in `views` folder that extends 285 | `layout.pug`: 286 | 287 | `views/error-page.pug`: 288 | 289 | ```pug 290 | extends layout.pug 291 | 292 | block content 293 | h1 Page Not Found 294 | ``` --------------------------------------------------------------------------------