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