diff --git a/code/00-starting-project/app.js b/code/00-starting-project/app.js new file mode 100644 index 0000000..ded8679 --- /dev/null +++ b/code/00-starting-project/app.js @@ -0,0 +1,5 @@ +const express = require('express'); + +const app = express(); + +app.listen(3000); \ No newline at end of file diff --git a/code/00-starting-project/package.json b/code/00-starting-project/package.json new file mode 100644 index 0000000..e4e54d3 --- /dev/null +++ b/code/00-starting-project/package.json @@ -0,0 +1,18 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "express": "^4.17.1" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/01-adding-ejs-first-views/app.js b/code/01-adding-ejs-first-views/app.js new file mode 100644 index 0000000..a4abb68 --- /dev/null +++ b/code/01-adding-ejs-first-views/app.js @@ -0,0 +1,14 @@ +const path = require('path'); + +const express = require('express'); + +const authRoutes = require('./routes/auth.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(authRoutes); + +app.listen(3000); \ No newline at end of file diff --git a/code/01-adding-ejs-first-views/controllers/auth.controller.js b/code/01-adding-ejs-first-views/controllers/auth.controller.js new file mode 100644 index 0000000..d2b8158 --- /dev/null +++ b/code/01-adding-ejs-first-views/controllers/auth.controller.js @@ -0,0 +1,12 @@ +function getSignup(req, res) { + // ... +} + +function getLogin(req, res) { + // ... +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin +}; diff --git a/code/01-adding-ejs-first-views/package.json b/code/01-adding-ejs-first-views/package.json new file mode 100644 index 0000000..f4f3955 --- /dev/null +++ b/code/01-adding-ejs-first-views/package.json @@ -0,0 +1,19 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "ejs": "^3.1.6", + "express": "^4.17.1" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/01-adding-ejs-first-views/routes/auth.routes.js b/code/01-adding-ejs-first-views/routes/auth.routes.js new file mode 100644 index 0000000..e0c366b --- /dev/null +++ b/code/01-adding-ejs-first-views/routes/auth.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/01-adding-ejs-first-views/views/customer/auth/login.ejs b/code/01-adding-ejs-first-views/views/customer/auth/login.ejs new file mode 100644 index 0000000..e69de29 diff --git a/code/01-adding-ejs-first-views/views/customer/auth/signup.ejs b/code/01-adding-ejs-first-views/views/customer/auth/signup.ejs new file mode 100644 index 0000000..d9b2f6f --- /dev/null +++ b/code/01-adding-ejs-first-views/views/customer/auth/signup.ejs @@ -0,0 +1,11 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + <%- include('../includes/header') %> +
+

Create New Account

+
+ +
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/01-adding-ejs-first-views/views/customer/includes/footer.ejs b/code/01-adding-ejs-first-views/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/01-adding-ejs-first-views/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/01-adding-ejs-first-views/views/customer/includes/head.ejs b/code/01-adding-ejs-first-views/views/customer/includes/head.ejs new file mode 100644 index 0000000..e71a6d3 --- /dev/null +++ b/code/01-adding-ejs-first-views/views/customer/includes/head.ejs @@ -0,0 +1,7 @@ + + + + + + + <%= pageTitle %> diff --git a/code/01-adding-ejs-first-views/views/customer/includes/header.ejs b/code/01-adding-ejs-first-views/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/01-adding-ejs-first-views/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/02-adding-base-css/app.js b/code/02-adding-base-css/app.js new file mode 100644 index 0000000..5d02945 --- /dev/null +++ b/code/02-adding-base-css/app.js @@ -0,0 +1,16 @@ +const path = require('path'); + +const express = require('express'); + +const authRoutes = require('./routes/auth.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); + +app.use(authRoutes); + +app.listen(3000); \ No newline at end of file diff --git a/code/02-adding-base-css/controllers/auth.controller.js b/code/02-adding-base-css/controllers/auth.controller.js new file mode 100644 index 0000000..9cb32c8 --- /dev/null +++ b/code/02-adding-base-css/controllers/auth.controller.js @@ -0,0 +1,12 @@ +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +function getLogin(req, res) { + // ... +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin +}; diff --git a/code/02-adding-base-css/package.json b/code/02-adding-base-css/package.json new file mode 100644 index 0000000..f4f3955 --- /dev/null +++ b/code/02-adding-base-css/package.json @@ -0,0 +1,19 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "ejs": "^3.1.6", + "express": "^4.17.1" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/02-adding-base-css/public/styles/auth.css b/code/02-adding-base-css/public/styles/auth.css new file mode 100644 index 0000000..e69de29 diff --git a/code/02-adding-base-css/public/styles/base.css b/code/02-adding-base-css/public/styles/base.css new file mode 100644 index 0000000..3dae868 --- /dev/null +++ b/code/02-adding-base-css/public/styles/base.css @@ -0,0 +1,7 @@ +* { + box-sizing: border-box; +} + +html { + font-family: 'Montserrat', 'sans-serif'; +} \ No newline at end of file diff --git a/code/02-adding-base-css/public/styles/forms.css b/code/02-adding-base-css/public/styles/forms.css new file mode 100644 index 0000000..e69de29 diff --git a/code/02-adding-base-css/routes/auth.routes.js b/code/02-adding-base-css/routes/auth.routes.js new file mode 100644 index 0000000..e0c366b --- /dev/null +++ b/code/02-adding-base-css/routes/auth.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/02-adding-base-css/views/customer/auth/login.ejs b/code/02-adding-base-css/views/customer/auth/login.ejs new file mode 100644 index 0000000..e69de29 diff --git a/code/02-adding-base-css/views/customer/auth/signup.ejs b/code/02-adding-base-css/views/customer/auth/signup.ejs new file mode 100644 index 0000000..ffdc119 --- /dev/null +++ b/code/02-adding-base-css/views/customer/auth/signup.ejs @@ -0,0 +1,49 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Create New Account

+
+

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/02-adding-base-css/views/customer/includes/footer.ejs b/code/02-adding-base-css/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/02-adding-base-css/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/02-adding-base-css/views/customer/includes/head.ejs b/code/02-adding-base-css/views/customer/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/02-adding-base-css/views/customer/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/02-adding-base-css/views/customer/includes/header.ejs b/code/02-adding-base-css/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/02-adding-base-css/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/03-adding-css-variables/app.js b/code/03-adding-css-variables/app.js new file mode 100644 index 0000000..5d02945 --- /dev/null +++ b/code/03-adding-css-variables/app.js @@ -0,0 +1,16 @@ +const path = require('path'); + +const express = require('express'); + +const authRoutes = require('./routes/auth.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); + +app.use(authRoutes); + +app.listen(3000); \ No newline at end of file diff --git a/code/03-adding-css-variables/controllers/auth.controller.js b/code/03-adding-css-variables/controllers/auth.controller.js new file mode 100644 index 0000000..9cb32c8 --- /dev/null +++ b/code/03-adding-css-variables/controllers/auth.controller.js @@ -0,0 +1,12 @@ +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +function getLogin(req, res) { + // ... +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin +}; diff --git a/code/03-adding-css-variables/package.json b/code/03-adding-css-variables/package.json new file mode 100644 index 0000000..f4f3955 --- /dev/null +++ b/code/03-adding-css-variables/package.json @@ -0,0 +1,19 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "ejs": "^3.1.6", + "express": "^4.17.1" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/03-adding-css-variables/public/styles/auth.css b/code/03-adding-css-variables/public/styles/auth.css new file mode 100644 index 0000000..e69de29 diff --git a/code/03-adding-css-variables/public/styles/base.css b/code/03-adding-css-variables/public/styles/base.css new file mode 100644 index 0000000..7cb3699 --- /dev/null +++ b/code/03-adding-css-variables/public/styles/base.css @@ -0,0 +1,40 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} diff --git a/code/03-adding-css-variables/public/styles/forms.css b/code/03-adding-css-variables/public/styles/forms.css new file mode 100644 index 0000000..e69de29 diff --git a/code/03-adding-css-variables/routes/auth.routes.js b/code/03-adding-css-variables/routes/auth.routes.js new file mode 100644 index 0000000..e0c366b --- /dev/null +++ b/code/03-adding-css-variables/routes/auth.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/03-adding-css-variables/views/customer/auth/login.ejs b/code/03-adding-css-variables/views/customer/auth/login.ejs new file mode 100644 index 0000000..e69de29 diff --git a/code/03-adding-css-variables/views/customer/auth/signup.ejs b/code/03-adding-css-variables/views/customer/auth/signup.ejs new file mode 100644 index 0000000..ffdc119 --- /dev/null +++ b/code/03-adding-css-variables/views/customer/auth/signup.ejs @@ -0,0 +1,49 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Create New Account

+
+

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/03-adding-css-variables/views/customer/includes/footer.ejs b/code/03-adding-css-variables/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/03-adding-css-variables/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/03-adding-css-variables/views/customer/includes/head.ejs b/code/03-adding-css-variables/views/customer/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/03-adding-css-variables/views/customer/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/03-adding-css-variables/views/customer/includes/header.ejs b/code/03-adding-css-variables/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/03-adding-css-variables/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/04-styling-form-elements/app.js b/code/04-styling-form-elements/app.js new file mode 100644 index 0000000..5d02945 --- /dev/null +++ b/code/04-styling-form-elements/app.js @@ -0,0 +1,16 @@ +const path = require('path'); + +const express = require('express'); + +const authRoutes = require('./routes/auth.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); + +app.use(authRoutes); + +app.listen(3000); \ No newline at end of file diff --git a/code/04-styling-form-elements/controllers/auth.controller.js b/code/04-styling-form-elements/controllers/auth.controller.js new file mode 100644 index 0000000..9cb32c8 --- /dev/null +++ b/code/04-styling-form-elements/controllers/auth.controller.js @@ -0,0 +1,12 @@ +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +function getLogin(req, res) { + // ... +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin +}; diff --git a/code/04-styling-form-elements/package.json b/code/04-styling-form-elements/package.json new file mode 100644 index 0000000..f4f3955 --- /dev/null +++ b/code/04-styling-form-elements/package.json @@ -0,0 +1,19 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "ejs": "^3.1.6", + "express": "^4.17.1" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/04-styling-form-elements/public/styles/auth.css b/code/04-styling-form-elements/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/04-styling-form-elements/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/04-styling-form-elements/public/styles/base.css b/code/04-styling-form-elements/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/04-styling-form-elements/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/04-styling-form-elements/public/styles/forms.css b/code/04-styling-form-elements/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/04-styling-form-elements/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/04-styling-form-elements/routes/auth.routes.js b/code/04-styling-form-elements/routes/auth.routes.js new file mode 100644 index 0000000..e0c366b --- /dev/null +++ b/code/04-styling-form-elements/routes/auth.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/04-styling-form-elements/views/customer/auth/login.ejs b/code/04-styling-form-elements/views/customer/auth/login.ejs new file mode 100644 index 0000000..e69de29 diff --git a/code/04-styling-form-elements/views/customer/auth/signup.ejs b/code/04-styling-form-elements/views/customer/auth/signup.ejs new file mode 100644 index 0000000..ffdc119 --- /dev/null +++ b/code/04-styling-form-elements/views/customer/auth/signup.ejs @@ -0,0 +1,49 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Create New Account

+
+

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/04-styling-form-elements/views/customer/includes/footer.ejs b/code/04-styling-form-elements/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/04-styling-form-elements/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/04-styling-form-elements/views/customer/includes/head.ejs b/code/04-styling-form-elements/views/customer/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/04-styling-form-elements/views/customer/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/04-styling-form-elements/views/customer/includes/header.ejs b/code/04-styling-form-elements/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/04-styling-form-elements/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/05-adding-monodb/app.js b/code/05-adding-monodb/app.js new file mode 100644 index 0000000..c758139 --- /dev/null +++ b/code/05-adding-monodb/app.js @@ -0,0 +1,24 @@ +const path = require("path"); + +const express = require("express"); + +const db = require("./data/database"); +const authRoutes = require("./routes/auth.routes"); + +const app = express(); + +app.set("view engine", "ejs"); +app.set("views", path.join(__dirname, "views")); + +app.use(express.static("public")); + +app.use(authRoutes); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log("Failed to connect to the database!"); + console.log(error); + }); diff --git a/code/05-adding-monodb/controllers/auth.controller.js b/code/05-adding-monodb/controllers/auth.controller.js new file mode 100644 index 0000000..382fd29 --- /dev/null +++ b/code/05-adding-monodb/controllers/auth.controller.js @@ -0,0 +1,17 @@ +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +function signup(req, res) { + +} + +function getLogin(req, res) { + // ... +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup +}; diff --git a/code/05-adding-monodb/data/database.js b/code/05-adding-monodb/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/05-adding-monodb/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/05-adding-monodb/package.json b/code/05-adding-monodb/package.json new file mode 100644 index 0000000..e414843 --- /dev/null +++ b/code/05-adding-monodb/package.json @@ -0,0 +1,20 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "ejs": "^3.1.6", + "express": "^4.17.1", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/05-adding-monodb/public/styles/auth.css b/code/05-adding-monodb/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/05-adding-monodb/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/05-adding-monodb/public/styles/base.css b/code/05-adding-monodb/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/05-adding-monodb/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/05-adding-monodb/public/styles/forms.css b/code/05-adding-monodb/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/05-adding-monodb/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/05-adding-monodb/routes/auth.routes.js b/code/05-adding-monodb/routes/auth.routes.js new file mode 100644 index 0000000..4ccfcba --- /dev/null +++ b/code/05-adding-monodb/routes/auth.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/05-adding-monodb/views/customer/auth/login.ejs b/code/05-adding-monodb/views/customer/auth/login.ejs new file mode 100644 index 0000000..e69de29 diff --git a/code/05-adding-monodb/views/customer/auth/signup.ejs b/code/05-adding-monodb/views/customer/auth/signup.ejs new file mode 100644 index 0000000..ef19311 --- /dev/null +++ b/code/05-adding-monodb/views/customer/auth/signup.ejs @@ -0,0 +1,49 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Create New Account

+
+

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/05-adding-monodb/views/customer/includes/footer.ejs b/code/05-adding-monodb/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/05-adding-monodb/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/05-adding-monodb/views/customer/includes/head.ejs b/code/05-adding-monodb/views/customer/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/05-adding-monodb/views/customer/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/05-adding-monodb/views/customer/includes/header.ejs b/code/05-adding-monodb/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/05-adding-monodb/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/06-adding-user-signup/app.js b/code/06-adding-user-signup/app.js new file mode 100644 index 0000000..2f13b70 --- /dev/null +++ b/code/06-adding-user-signup/app.js @@ -0,0 +1,25 @@ +const path = require("path"); + +const express = require("express"); + +const db = require("./data/database"); +const authRoutes = require("./routes/auth.routes"); + +const app = express(); + +app.set("view engine", "ejs"); +app.set("views", path.join(__dirname, "views")); + +app.use(express.static("public")); +app.use(express.urlencoded({ extended: false })); + +app.use(authRoutes); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log("Failed to connect to the database!"); + console.log(error); + }); diff --git a/code/06-adding-user-signup/controllers/auth.controller.js b/code/06-adding-user-signup/controllers/auth.controller.js new file mode 100644 index 0000000..b2c9c19 --- /dev/null +++ b/code/06-adding-user-signup/controllers/auth.controller.js @@ -0,0 +1,30 @@ +const User = require('../models/user.model'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res) { + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + await user.signup(); + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, +}; diff --git a/code/06-adding-user-signup/data/database.js b/code/06-adding-user-signup/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/06-adding-user-signup/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/06-adding-user-signup/models/user.model.js b/code/06-adding-user-signup/models/user.model.js new file mode 100644 index 0000000..4fae6fe --- /dev/null +++ b/code/06-adding-user-signup/models/user.model.js @@ -0,0 +1,29 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city + }; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address + }); + } +} + +module.exports = User; \ No newline at end of file diff --git a/code/06-adding-user-signup/package.json b/code/06-adding-user-signup/package.json new file mode 100644 index 0000000..35d1514 --- /dev/null +++ b/code/06-adding-user-signup/package.json @@ -0,0 +1,21 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "ejs": "^3.1.6", + "express": "^4.17.1", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/06-adding-user-signup/public/styles/auth.css b/code/06-adding-user-signup/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/06-adding-user-signup/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/06-adding-user-signup/public/styles/base.css b/code/06-adding-user-signup/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/06-adding-user-signup/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/06-adding-user-signup/public/styles/forms.css b/code/06-adding-user-signup/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/06-adding-user-signup/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/06-adding-user-signup/routes/auth.routes.js b/code/06-adding-user-signup/routes/auth.routes.js new file mode 100644 index 0000000..4ccfcba --- /dev/null +++ b/code/06-adding-user-signup/routes/auth.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/06-adding-user-signup/views/customer/auth/login.ejs b/code/06-adding-user-signup/views/customer/auth/login.ejs new file mode 100644 index 0000000..a8bcd1e --- /dev/null +++ b/code/06-adding-user-signup/views/customer/auth/login.ejs @@ -0,0 +1,22 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Login

+
+

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/06-adding-user-signup/views/customer/auth/signup.ejs b/code/06-adding-user-signup/views/customer/auth/signup.ejs new file mode 100644 index 0000000..ef19311 --- /dev/null +++ b/code/06-adding-user-signup/views/customer/auth/signup.ejs @@ -0,0 +1,49 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Create New Account

+
+

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/06-adding-user-signup/views/customer/includes/footer.ejs b/code/06-adding-user-signup/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/06-adding-user-signup/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/06-adding-user-signup/views/customer/includes/head.ejs b/code/06-adding-user-signup/views/customer/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/06-adding-user-signup/views/customer/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/06-adding-user-signup/views/customer/includes/header.ejs b/code/06-adding-user-signup/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/06-adding-user-signup/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/07-security-time-csrf/app.js b/code/07-security-time-csrf/app.js new file mode 100644 index 0000000..a508e3a --- /dev/null +++ b/code/07-security-time-csrf/app.js @@ -0,0 +1,31 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); + +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const authRoutes = require('./routes/auth.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); + +app.use(authRoutes); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/07-security-time-csrf/controllers/auth.controller.js b/code/07-security-time-csrf/controllers/auth.controller.js new file mode 100644 index 0000000..b2c9c19 --- /dev/null +++ b/code/07-security-time-csrf/controllers/auth.controller.js @@ -0,0 +1,30 @@ +const User = require('../models/user.model'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res) { + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + await user.signup(); + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, +}; diff --git a/code/07-security-time-csrf/data/database.js b/code/07-security-time-csrf/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/07-security-time-csrf/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/07-security-time-csrf/middlewares/csrf-token.js b/code/07-security-time-csrf/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/07-security-time-csrf/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/07-security-time-csrf/models/user.model.js b/code/07-security-time-csrf/models/user.model.js new file mode 100644 index 0000000..4fae6fe --- /dev/null +++ b/code/07-security-time-csrf/models/user.model.js @@ -0,0 +1,29 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city + }; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address + }); + } +} + +module.exports = User; \ No newline at end of file diff --git a/code/07-security-time-csrf/package.json b/code/07-security-time-csrf/package.json new file mode 100644 index 0000000..90ebe49 --- /dev/null +++ b/code/07-security-time-csrf/package.json @@ -0,0 +1,22 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/07-security-time-csrf/public/styles/auth.css b/code/07-security-time-csrf/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/07-security-time-csrf/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/07-security-time-csrf/public/styles/base.css b/code/07-security-time-csrf/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/07-security-time-csrf/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/07-security-time-csrf/public/styles/forms.css b/code/07-security-time-csrf/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/07-security-time-csrf/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/07-security-time-csrf/routes/auth.routes.js b/code/07-security-time-csrf/routes/auth.routes.js new file mode 100644 index 0000000..4ccfcba --- /dev/null +++ b/code/07-security-time-csrf/routes/auth.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/07-security-time-csrf/views/customer/auth/login.ejs b/code/07-security-time-csrf/views/customer/auth/login.ejs new file mode 100644 index 0000000..d873b05 --- /dev/null +++ b/code/07-security-time-csrf/views/customer/auth/login.ejs @@ -0,0 +1,23 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Login

+
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/07-security-time-csrf/views/customer/auth/signup.ejs b/code/07-security-time-csrf/views/customer/auth/signup.ejs new file mode 100644 index 0000000..8003687 --- /dev/null +++ b/code/07-security-time-csrf/views/customer/auth/signup.ejs @@ -0,0 +1,50 @@ +<%- include('../includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../includes/header') %> +
+

Create New Account

+
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../includes/footer') %> \ No newline at end of file diff --git a/code/07-security-time-csrf/views/customer/includes/footer.ejs b/code/07-security-time-csrf/views/customer/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/07-security-time-csrf/views/customer/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/07-security-time-csrf/views/customer/includes/head.ejs b/code/07-security-time-csrf/views/customer/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/07-security-time-csrf/views/customer/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/07-security-time-csrf/views/customer/includes/header.ejs b/code/07-security-time-csrf/views/customer/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/07-security-time-csrf/views/customer/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/app.js b/code/08-implementing-proper-error-handling/app.js new file mode 100644 index 0000000..2f17a70 --- /dev/null +++ b/code/08-implementing-proper-error-handling/app.js @@ -0,0 +1,34 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); + +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const authRoutes = require('./routes/auth.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); + +app.use(authRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/08-implementing-proper-error-handling/controllers/auth.controller.js b/code/08-implementing-proper-error-handling/controllers/auth.controller.js new file mode 100644 index 0000000..b2c9c19 --- /dev/null +++ b/code/08-implementing-proper-error-handling/controllers/auth.controller.js @@ -0,0 +1,30 @@ +const User = require('../models/user.model'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res) { + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + await user.signup(); + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, +}; diff --git a/code/08-implementing-proper-error-handling/data/database.js b/code/08-implementing-proper-error-handling/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/08-implementing-proper-error-handling/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/middlewares/csrf-token.js b/code/08-implementing-proper-error-handling/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/08-implementing-proper-error-handling/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/middlewares/error-handler.js b/code/08-implementing-proper-error-handling/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/08-implementing-proper-error-handling/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/models/user.model.js b/code/08-implementing-proper-error-handling/models/user.model.js new file mode 100644 index 0000000..4fae6fe --- /dev/null +++ b/code/08-implementing-proper-error-handling/models/user.model.js @@ -0,0 +1,29 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city + }; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address + }); + } +} + +module.exports = User; \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/package.json b/code/08-implementing-proper-error-handling/package.json new file mode 100644 index 0000000..90ebe49 --- /dev/null +++ b/code/08-implementing-proper-error-handling/package.json @@ -0,0 +1,22 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/08-implementing-proper-error-handling/public/styles/auth.css b/code/08-implementing-proper-error-handling/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/08-implementing-proper-error-handling/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/public/styles/base.css b/code/08-implementing-proper-error-handling/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/08-implementing-proper-error-handling/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/08-implementing-proper-error-handling/public/styles/forms.css b/code/08-implementing-proper-error-handling/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/08-implementing-proper-error-handling/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/routes/auth.routes.js b/code/08-implementing-proper-error-handling/routes/auth.routes.js new file mode 100644 index 0000000..4ccfcba --- /dev/null +++ b/code/08-implementing-proper-error-handling/routes/auth.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +module.exports = router; \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/views/customer/auth/login.ejs b/code/08-implementing-proper-error-handling/views/customer/auth/login.ejs new file mode 100644 index 0000000..fbc7b27 --- /dev/null +++ b/code/08-implementing-proper-error-handling/views/customer/auth/login.ejs @@ -0,0 +1,23 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/views/customer/auth/signup.ejs b/code/08-implementing-proper-error-handling/views/customer/auth/signup.ejs new file mode 100644 index 0000000..927c88a --- /dev/null +++ b/code/08-implementing-proper-error-handling/views/customer/auth/signup.ejs @@ -0,0 +1,50 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/views/shared/500.ejs b/code/08-implementing-proper-error-handling/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/08-implementing-proper-error-handling/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/views/shared/includes/footer.ejs b/code/08-implementing-proper-error-handling/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/08-implementing-proper-error-handling/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/08-implementing-proper-error-handling/views/shared/includes/head.ejs b/code/08-implementing-proper-error-handling/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/08-implementing-proper-error-handling/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/08-implementing-proper-error-handling/views/shared/includes/header.ejs b/code/08-implementing-proper-error-handling/views/shared/includes/header.ejs new file mode 100644 index 0000000..cc3760a --- /dev/null +++ b/code/08-implementing-proper-error-handling/views/shared/includes/header.ejs @@ -0,0 +1,4 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/09-implementing-authentication/app.js b/code/09-implementing-authentication/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/09-implementing-authentication/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/09-implementing-authentication/config/session.js b/code/09-implementing-authentication/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/09-implementing-authentication/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/09-implementing-authentication/controllers/auth.controller.js b/code/09-implementing-authentication/controllers/auth.controller.js new file mode 100644 index 0000000..9925e74 --- /dev/null +++ b/code/09-implementing-authentication/controllers/auth.controller.js @@ -0,0 +1,55 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res) { + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + await user.signup(); + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +async function login(req, res) { + const user = new User(req.body.email, req.body.password); + const existingUser = await user.getUserWithSameEmail(); + + if (!existingUser) { + res.redirect('/login'); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + res.redirect('/login'); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login +}; diff --git a/code/09-implementing-authentication/data/database.js b/code/09-implementing-authentication/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/09-implementing-authentication/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/09-implementing-authentication/middlewares/check-auth.js b/code/09-implementing-authentication/middlewares/check-auth.js new file mode 100644 index 0000000..899be60 --- /dev/null +++ b/code/09-implementing-authentication/middlewares/check-auth.js @@ -0,0 +1,13 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/09-implementing-authentication/middlewares/csrf-token.js b/code/09-implementing-authentication/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/09-implementing-authentication/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/09-implementing-authentication/middlewares/error-handler.js b/code/09-implementing-authentication/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/09-implementing-authentication/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/09-implementing-authentication/models/user.model.js b/code/09-implementing-authentication/models/user.model.js new file mode 100644 index 0000000..5fea71f --- /dev/null +++ b/code/09-implementing-authentication/models/user.model.js @@ -0,0 +1,37 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/09-implementing-authentication/package.json b/code/09-implementing-authentication/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/09-implementing-authentication/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/09-implementing-authentication/public/styles/auth.css b/code/09-implementing-authentication/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/09-implementing-authentication/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/09-implementing-authentication/public/styles/base.css b/code/09-implementing-authentication/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/09-implementing-authentication/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/09-implementing-authentication/public/styles/forms.css b/code/09-implementing-authentication/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/09-implementing-authentication/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/09-implementing-authentication/routes/auth.routes.js b/code/09-implementing-authentication/routes/auth.routes.js new file mode 100644 index 0000000..cf39cc9 --- /dev/null +++ b/code/09-implementing-authentication/routes/auth.routes.js @@ -0,0 +1,15 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +module.exports = router; \ No newline at end of file diff --git a/code/09-implementing-authentication/routes/base.routes.js b/code/09-implementing-authentication/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/09-implementing-authentication/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/09-implementing-authentication/routes/products.routes.js b/code/09-implementing-authentication/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/09-implementing-authentication/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/09-implementing-authentication/util/authentication.js b/code/09-implementing-authentication/util/authentication.js new file mode 100644 index 0000000..cc29be9 --- /dev/null +++ b/code/09-implementing-authentication/util/authentication.js @@ -0,0 +1,8 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.save(action); +} + +module.exports = { + createUserSession: createUserSession +}; \ No newline at end of file diff --git a/code/09-implementing-authentication/views/customer/auth/login.ejs b/code/09-implementing-authentication/views/customer/auth/login.ejs new file mode 100644 index 0000000..8b2ea0b --- /dev/null +++ b/code/09-implementing-authentication/views/customer/auth/login.ejs @@ -0,0 +1,23 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/09-implementing-authentication/views/customer/auth/signup.ejs b/code/09-implementing-authentication/views/customer/auth/signup.ejs new file mode 100644 index 0000000..927c88a --- /dev/null +++ b/code/09-implementing-authentication/views/customer/auth/signup.ejs @@ -0,0 +1,50 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/09-implementing-authentication/views/customer/products/all-products.ejs b/code/09-implementing-authentication/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/09-implementing-authentication/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/09-implementing-authentication/views/shared/500.ejs b/code/09-implementing-authentication/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/09-implementing-authentication/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/09-implementing-authentication/views/shared/includes/footer.ejs b/code/09-implementing-authentication/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/09-implementing-authentication/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/09-implementing-authentication/views/shared/includes/head.ejs b/code/09-implementing-authentication/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/09-implementing-authentication/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/09-implementing-authentication/views/shared/includes/header.ejs b/code/09-implementing-authentication/views/shared/includes/header.ejs new file mode 100644 index 0000000..b582b90 --- /dev/null +++ b/code/09-implementing-authentication/views/shared/includes/header.ejs @@ -0,0 +1,12 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/10-adding-logout/app.js b/code/10-adding-logout/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/10-adding-logout/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/10-adding-logout/config/session.js b/code/10-adding-logout/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/10-adding-logout/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/10-adding-logout/controllers/auth.controller.js b/code/10-adding-logout/controllers/auth.controller.js new file mode 100644 index 0000000..7e47bd4 --- /dev/null +++ b/code/10-adding-logout/controllers/auth.controller.js @@ -0,0 +1,61 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res) { + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + await user.signup(); + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +async function login(req, res) { + const user = new User(req.body.email, req.body.password); + const existingUser = await user.getUserWithSameEmail(); + + if (!existingUser) { + res.redirect('/login'); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + res.redirect('/login'); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout +}; diff --git a/code/10-adding-logout/data/database.js b/code/10-adding-logout/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/10-adding-logout/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/10-adding-logout/middlewares/check-auth.js b/code/10-adding-logout/middlewares/check-auth.js new file mode 100644 index 0000000..899be60 --- /dev/null +++ b/code/10-adding-logout/middlewares/check-auth.js @@ -0,0 +1,13 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/10-adding-logout/middlewares/csrf-token.js b/code/10-adding-logout/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/10-adding-logout/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/10-adding-logout/middlewares/error-handler.js b/code/10-adding-logout/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/10-adding-logout/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/10-adding-logout/models/user.model.js b/code/10-adding-logout/models/user.model.js new file mode 100644 index 0000000..5fea71f --- /dev/null +++ b/code/10-adding-logout/models/user.model.js @@ -0,0 +1,37 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/10-adding-logout/package.json b/code/10-adding-logout/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/10-adding-logout/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/10-adding-logout/public/styles/auth.css b/code/10-adding-logout/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/10-adding-logout/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/10-adding-logout/public/styles/base.css b/code/10-adding-logout/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/10-adding-logout/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/10-adding-logout/public/styles/forms.css b/code/10-adding-logout/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/10-adding-logout/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/10-adding-logout/routes/auth.routes.js b/code/10-adding-logout/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/10-adding-logout/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/10-adding-logout/routes/base.routes.js b/code/10-adding-logout/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/10-adding-logout/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/10-adding-logout/routes/products.routes.js b/code/10-adding-logout/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/10-adding-logout/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/10-adding-logout/util/authentication.js b/code/10-adding-logout/util/authentication.js new file mode 100644 index 0000000..c0d4581 --- /dev/null +++ b/code/10-adding-logout/util/authentication.js @@ -0,0 +1,13 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/10-adding-logout/views/customer/auth/login.ejs b/code/10-adding-logout/views/customer/auth/login.ejs new file mode 100644 index 0000000..8b2ea0b --- /dev/null +++ b/code/10-adding-logout/views/customer/auth/login.ejs @@ -0,0 +1,23 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/10-adding-logout/views/customer/auth/signup.ejs b/code/10-adding-logout/views/customer/auth/signup.ejs new file mode 100644 index 0000000..927c88a --- /dev/null +++ b/code/10-adding-logout/views/customer/auth/signup.ejs @@ -0,0 +1,50 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/10-adding-logout/views/customer/products/all-products.ejs b/code/10-adding-logout/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/10-adding-logout/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/10-adding-logout/views/shared/500.ejs b/code/10-adding-logout/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/10-adding-logout/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/10-adding-logout/views/shared/includes/footer.ejs b/code/10-adding-logout/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/10-adding-logout/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/10-adding-logout/views/shared/includes/head.ejs b/code/10-adding-logout/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/10-adding-logout/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/10-adding-logout/views/shared/includes/header.ejs b/code/10-adding-logout/views/shared/includes/header.ejs new file mode 100644 index 0000000..afec019 --- /dev/null +++ b/code/10-adding-logout/views/shared/includes/header.ejs @@ -0,0 +1,15 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/11-handling-errors-async-code/app.js b/code/11-handling-errors-async-code/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/11-handling-errors-async-code/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/11-handling-errors-async-code/config/session.js b/code/11-handling-errors-async-code/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/11-handling-errors-async-code/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/controllers/auth.controller.js b/code/11-handling-errors-async-code/controllers/auth.controller.js new file mode 100644 index 0000000..8b7ec6c --- /dev/null +++ b/code/11-handling-errors-async-code/controllers/auth.controller.js @@ -0,0 +1,72 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res, next) { + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + if (!existingUser) { + res.redirect('/login'); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + res.redirect('/login'); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout +}; diff --git a/code/11-handling-errors-async-code/data/database.js b/code/11-handling-errors-async-code/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/11-handling-errors-async-code/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/middlewares/check-auth.js b/code/11-handling-errors-async-code/middlewares/check-auth.js new file mode 100644 index 0000000..899be60 --- /dev/null +++ b/code/11-handling-errors-async-code/middlewares/check-auth.js @@ -0,0 +1,13 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/middlewares/csrf-token.js b/code/11-handling-errors-async-code/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/11-handling-errors-async-code/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/middlewares/error-handler.js b/code/11-handling-errors-async-code/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/11-handling-errors-async-code/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/models/user.model.js b/code/11-handling-errors-async-code/models/user.model.js new file mode 100644 index 0000000..5fea71f --- /dev/null +++ b/code/11-handling-errors-async-code/models/user.model.js @@ -0,0 +1,37 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/11-handling-errors-async-code/package.json b/code/11-handling-errors-async-code/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/11-handling-errors-async-code/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/11-handling-errors-async-code/public/styles/auth.css b/code/11-handling-errors-async-code/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/11-handling-errors-async-code/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/11-handling-errors-async-code/public/styles/base.css b/code/11-handling-errors-async-code/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/11-handling-errors-async-code/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/11-handling-errors-async-code/public/styles/forms.css b/code/11-handling-errors-async-code/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/11-handling-errors-async-code/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/11-handling-errors-async-code/routes/auth.routes.js b/code/11-handling-errors-async-code/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/11-handling-errors-async-code/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/routes/base.routes.js b/code/11-handling-errors-async-code/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/11-handling-errors-async-code/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/routes/products.routes.js b/code/11-handling-errors-async-code/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/11-handling-errors-async-code/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/util/authentication.js b/code/11-handling-errors-async-code/util/authentication.js new file mode 100644 index 0000000..c0d4581 --- /dev/null +++ b/code/11-handling-errors-async-code/util/authentication.js @@ -0,0 +1,13 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/11-handling-errors-async-code/views/customer/auth/login.ejs b/code/11-handling-errors-async-code/views/customer/auth/login.ejs new file mode 100644 index 0000000..8b2ea0b --- /dev/null +++ b/code/11-handling-errors-async-code/views/customer/auth/login.ejs @@ -0,0 +1,23 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/11-handling-errors-async-code/views/customer/auth/signup.ejs b/code/11-handling-errors-async-code/views/customer/auth/signup.ejs new file mode 100644 index 0000000..927c88a --- /dev/null +++ b/code/11-handling-errors-async-code/views/customer/auth/signup.ejs @@ -0,0 +1,50 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/11-handling-errors-async-code/views/customer/products/all-products.ejs b/code/11-handling-errors-async-code/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/11-handling-errors-async-code/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/11-handling-errors-async-code/views/shared/500.ejs b/code/11-handling-errors-async-code/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/11-handling-errors-async-code/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/11-handling-errors-async-code/views/shared/includes/footer.ejs b/code/11-handling-errors-async-code/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/11-handling-errors-async-code/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/11-handling-errors-async-code/views/shared/includes/head.ejs b/code/11-handling-errors-async-code/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/11-handling-errors-async-code/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/11-handling-errors-async-code/views/shared/includes/header.ejs b/code/11-handling-errors-async-code/views/shared/includes/header.ejs new file mode 100644 index 0000000..afec019 --- /dev/null +++ b/code/11-handling-errors-async-code/views/shared/includes/header.ejs @@ -0,0 +1,15 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/12-adding-user-input-validation/app.js b/code/12-adding-user-input-validation/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/12-adding-user-input-validation/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/12-adding-user-input-validation/config/session.js b/code/12-adding-user-input-validation/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/12-adding-user-input-validation/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/controllers/auth.controller.js b/code/12-adding-user-input-validation/controllers/auth.controller.js new file mode 100644 index 0000000..08ffcf4 --- /dev/null +++ b/code/12-adding-user-input-validation/controllers/auth.controller.js @@ -0,0 +1,94 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); + +function getSignup(req, res) { + res.render('customer/auth/signup'); +} + +async function signup(req, res, next) { + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + res.redirect('/signup'); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + res.redirect('/signup'); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + res.render('customer/auth/login'); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + if (!existingUser) { + res.redirect('/login'); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + res.redirect('/login'); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/12-adding-user-input-validation/data/database.js b/code/12-adding-user-input-validation/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/12-adding-user-input-validation/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/middlewares/check-auth.js b/code/12-adding-user-input-validation/middlewares/check-auth.js new file mode 100644 index 0000000..899be60 --- /dev/null +++ b/code/12-adding-user-input-validation/middlewares/check-auth.js @@ -0,0 +1,13 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/middlewares/csrf-token.js b/code/12-adding-user-input-validation/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/12-adding-user-input-validation/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/middlewares/error-handler.js b/code/12-adding-user-input-validation/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/12-adding-user-input-validation/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/models/user.model.js b/code/12-adding-user-input-validation/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/12-adding-user-input-validation/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/12-adding-user-input-validation/package.json b/code/12-adding-user-input-validation/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/12-adding-user-input-validation/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/12-adding-user-input-validation/public/styles/auth.css b/code/12-adding-user-input-validation/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/12-adding-user-input-validation/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/12-adding-user-input-validation/public/styles/base.css b/code/12-adding-user-input-validation/public/styles/base.css new file mode 100644 index 0000000..bf0af1b --- /dev/null +++ b/code/12-adding-user-input-validation/public/styles/base.css @@ -0,0 +1,80 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} diff --git a/code/12-adding-user-input-validation/public/styles/forms.css b/code/12-adding-user-input-validation/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/12-adding-user-input-validation/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/12-adding-user-input-validation/routes/auth.routes.js b/code/12-adding-user-input-validation/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/12-adding-user-input-validation/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/routes/base.routes.js b/code/12-adding-user-input-validation/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/12-adding-user-input-validation/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/routes/products.routes.js b/code/12-adding-user-input-validation/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/12-adding-user-input-validation/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/util/authentication.js b/code/12-adding-user-input-validation/util/authentication.js new file mode 100644 index 0000000..c0d4581 --- /dev/null +++ b/code/12-adding-user-input-validation/util/authentication.js @@ -0,0 +1,13 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/12-adding-user-input-validation/util/validation.js b/code/12-adding-user-input-validation/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/12-adding-user-input-validation/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/12-adding-user-input-validation/views/customer/auth/login.ejs b/code/12-adding-user-input-validation/views/customer/auth/login.ejs new file mode 100644 index 0000000..8b2ea0b --- /dev/null +++ b/code/12-adding-user-input-validation/views/customer/auth/login.ejs @@ -0,0 +1,23 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/12-adding-user-input-validation/views/customer/auth/signup.ejs b/code/12-adding-user-input-validation/views/customer/auth/signup.ejs new file mode 100644 index 0000000..927c88a --- /dev/null +++ b/code/12-adding-user-input-validation/views/customer/auth/signup.ejs @@ -0,0 +1,50 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/12-adding-user-input-validation/views/customer/products/all-products.ejs b/code/12-adding-user-input-validation/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/12-adding-user-input-validation/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/12-adding-user-input-validation/views/shared/500.ejs b/code/12-adding-user-input-validation/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/12-adding-user-input-validation/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/12-adding-user-input-validation/views/shared/includes/footer.ejs b/code/12-adding-user-input-validation/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/12-adding-user-input-validation/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/12-adding-user-input-validation/views/shared/includes/head.ejs b/code/12-adding-user-input-validation/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/12-adding-user-input-validation/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/12-adding-user-input-validation/views/shared/includes/header.ejs b/code/12-adding-user-input-validation/views/shared/includes/header.ejs new file mode 100644 index 0000000..afec019 --- /dev/null +++ b/code/12-adding-user-input-validation/views/shared/includes/header.ejs @@ -0,0 +1,15 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/13-displaying-error-messages/app.js b/code/13-displaying-error-messages/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/13-displaying-error-messages/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/13-displaying-error-messages/config/session.js b/code/13-displaying-error-messages/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/13-displaying-error-messages/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/13-displaying-error-messages/controllers/auth.controller.js b/code/13-displaying-error-messages/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/13-displaying-error-messages/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/13-displaying-error-messages/data/database.js b/code/13-displaying-error-messages/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/13-displaying-error-messages/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/13-displaying-error-messages/middlewares/check-auth.js b/code/13-displaying-error-messages/middlewares/check-auth.js new file mode 100644 index 0000000..899be60 --- /dev/null +++ b/code/13-displaying-error-messages/middlewares/check-auth.js @@ -0,0 +1,13 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/13-displaying-error-messages/middlewares/csrf-token.js b/code/13-displaying-error-messages/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/13-displaying-error-messages/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/13-displaying-error-messages/middlewares/error-handler.js b/code/13-displaying-error-messages/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/13-displaying-error-messages/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/13-displaying-error-messages/models/user.model.js b/code/13-displaying-error-messages/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/13-displaying-error-messages/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/13-displaying-error-messages/package.json b/code/13-displaying-error-messages/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/13-displaying-error-messages/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/13-displaying-error-messages/public/styles/auth.css b/code/13-displaying-error-messages/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/13-displaying-error-messages/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/13-displaying-error-messages/public/styles/base.css b/code/13-displaying-error-messages/public/styles/base.css new file mode 100644 index 0000000..b83dc9e --- /dev/null +++ b/code/13-displaying-error-messages/public/styles/base.css @@ -0,0 +1,97 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/13-displaying-error-messages/public/styles/forms.css b/code/13-displaying-error-messages/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/13-displaying-error-messages/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/13-displaying-error-messages/routes/auth.routes.js b/code/13-displaying-error-messages/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/13-displaying-error-messages/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/13-displaying-error-messages/routes/base.routes.js b/code/13-displaying-error-messages/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/13-displaying-error-messages/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/13-displaying-error-messages/routes/products.routes.js b/code/13-displaying-error-messages/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/13-displaying-error-messages/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/13-displaying-error-messages/util/authentication.js b/code/13-displaying-error-messages/util/authentication.js new file mode 100644 index 0000000..c0d4581 --- /dev/null +++ b/code/13-displaying-error-messages/util/authentication.js @@ -0,0 +1,13 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/13-displaying-error-messages/util/session-flash.js b/code/13-displaying-error-messages/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/13-displaying-error-messages/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/13-displaying-error-messages/util/validation.js b/code/13-displaying-error-messages/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/13-displaying-error-messages/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/13-displaying-error-messages/views/customer/auth/login.ejs b/code/13-displaying-error-messages/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/13-displaying-error-messages/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/13-displaying-error-messages/views/customer/auth/signup.ejs b/code/13-displaying-error-messages/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/13-displaying-error-messages/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/13-displaying-error-messages/views/customer/products/all-products.ejs b/code/13-displaying-error-messages/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/13-displaying-error-messages/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/13-displaying-error-messages/views/shared/500.ejs b/code/13-displaying-error-messages/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/13-displaying-error-messages/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/13-displaying-error-messages/views/shared/includes/footer.ejs b/code/13-displaying-error-messages/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/13-displaying-error-messages/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/13-displaying-error-messages/views/shared/includes/head.ejs b/code/13-displaying-error-messages/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/13-displaying-error-messages/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/13-displaying-error-messages/views/shared/includes/header.ejs b/code/13-displaying-error-messages/views/shared/includes/header.ejs new file mode 100644 index 0000000..afec019 --- /dev/null +++ b/code/13-displaying-error-messages/views/shared/includes/header.ejs @@ -0,0 +1,15 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/14-admin-authorization/app.js b/code/14-admin-authorization/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/14-admin-authorization/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/14-admin-authorization/config/session.js b/code/14-admin-authorization/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/14-admin-authorization/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/14-admin-authorization/controllers/auth.controller.js b/code/14-admin-authorization/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/14-admin-authorization/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/14-admin-authorization/data/database.js b/code/14-admin-authorization/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/14-admin-authorization/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/14-admin-authorization/middlewares/check-auth.js b/code/14-admin-authorization/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/14-admin-authorization/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/14-admin-authorization/middlewares/csrf-token.js b/code/14-admin-authorization/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/14-admin-authorization/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/14-admin-authorization/middlewares/error-handler.js b/code/14-admin-authorization/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/14-admin-authorization/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/14-admin-authorization/models/user.model.js b/code/14-admin-authorization/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/14-admin-authorization/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/14-admin-authorization/package.json b/code/14-admin-authorization/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/14-admin-authorization/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/14-admin-authorization/public/styles/auth.css b/code/14-admin-authorization/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/14-admin-authorization/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/14-admin-authorization/public/styles/base.css b/code/14-admin-authorization/public/styles/base.css new file mode 100644 index 0000000..b83dc9e --- /dev/null +++ b/code/14-admin-authorization/public/styles/base.css @@ -0,0 +1,97 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/14-admin-authorization/public/styles/forms.css b/code/14-admin-authorization/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/14-admin-authorization/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/14-admin-authorization/routes/auth.routes.js b/code/14-admin-authorization/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/14-admin-authorization/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/14-admin-authorization/routes/base.routes.js b/code/14-admin-authorization/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/14-admin-authorization/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/14-admin-authorization/routes/products.routes.js b/code/14-admin-authorization/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/14-admin-authorization/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/14-admin-authorization/util/authentication.js b/code/14-admin-authorization/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/14-admin-authorization/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/14-admin-authorization/util/session-flash.js b/code/14-admin-authorization/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/14-admin-authorization/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/14-admin-authorization/util/validation.js b/code/14-admin-authorization/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/14-admin-authorization/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/14-admin-authorization/views/customer/auth/login.ejs b/code/14-admin-authorization/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/14-admin-authorization/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/14-admin-authorization/views/customer/auth/signup.ejs b/code/14-admin-authorization/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/14-admin-authorization/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/14-admin-authorization/views/customer/products/all-products.ejs b/code/14-admin-authorization/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/14-admin-authorization/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/14-admin-authorization/views/shared/500.ejs b/code/14-admin-authorization/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/14-admin-authorization/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/14-admin-authorization/views/shared/includes/footer.ejs b/code/14-admin-authorization/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/14-admin-authorization/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/14-admin-authorization/views/shared/includes/head.ejs b/code/14-admin-authorization/views/shared/includes/head.ejs new file mode 100644 index 0000000..57a91b8 --- /dev/null +++ b/code/14-admin-authorization/views/shared/includes/head.ejs @@ -0,0 +1,15 @@ + + + + + + + <%= pageTitle %> + + + + + diff --git a/code/14-admin-authorization/views/shared/includes/header.ejs b/code/14-admin-authorization/views/shared/includes/header.ejs new file mode 100644 index 0000000..71ced5e --- /dev/null +++ b/code/14-admin-authorization/views/shared/includes/header.ejs @@ -0,0 +1,34 @@ +
+
WDE
+ +
\ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/app.js b/code/15-setting-up-base-nav-styles/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/15-setting-up-base-nav-styles/config/session.js b/code/15-setting-up-base-nav-styles/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/15-setting-up-base-nav-styles/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/controllers/auth.controller.js b/code/15-setting-up-base-nav-styles/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/15-setting-up-base-nav-styles/data/database.js b/code/15-setting-up-base-nav-styles/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/middlewares/check-auth.js b/code/15-setting-up-base-nav-styles/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/middlewares/csrf-token.js b/code/15-setting-up-base-nav-styles/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/middlewares/error-handler.js b/code/15-setting-up-base-nav-styles/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/15-setting-up-base-nav-styles/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/models/user.model.js b/code/15-setting-up-base-nav-styles/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/15-setting-up-base-nav-styles/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/15-setting-up-base-nav-styles/package.json b/code/15-setting-up-base-nav-styles/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/15-setting-up-base-nav-styles/public/styles/auth.css b/code/15-setting-up-base-nav-styles/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/15-setting-up-base-nav-styles/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/public/styles/base.css b/code/15-setting-up-base-nav-styles/public/styles/base.css new file mode 100644 index 0000000..b83dc9e --- /dev/null +++ b/code/15-setting-up-base-nav-styles/public/styles/base.css @@ -0,0 +1,97 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/public/styles/forms.css b/code/15-setting-up-base-nav-styles/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/public/styles/navigation.css b/code/15-setting-up-base-nav-styles/public/styles/navigation.css new file mode 100644 index 0000000..e9aa955 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/public/styles/navigation.css @@ -0,0 +1,132 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: flex; + flex-direction: column; + align-items: center; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/routes/auth.routes.js b/code/15-setting-up-base-nav-styles/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/15-setting-up-base-nav-styles/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/routes/base.routes.js b/code/15-setting-up-base-nav-styles/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/15-setting-up-base-nav-styles/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/routes/products.routes.js b/code/15-setting-up-base-nav-styles/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/15-setting-up-base-nav-styles/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/util/authentication.js b/code/15-setting-up-base-nav-styles/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/util/session-flash.js b/code/15-setting-up-base-nav-styles/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/util/validation.js b/code/15-setting-up-base-nav-styles/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/15-setting-up-base-nav-styles/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/15-setting-up-base-nav-styles/views/customer/auth/login.ejs b/code/15-setting-up-base-nav-styles/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/views/customer/auth/signup.ejs b/code/15-setting-up-base-nav-styles/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/views/customer/products/all-products.ejs b/code/15-setting-up-base-nav-styles/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/views/shared/500.ejs b/code/15-setting-up-base-nav-styles/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/views/shared/includes/footer.ejs b/code/15-setting-up-base-nav-styles/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/views/shared/includes/head.ejs b/code/15-setting-up-base-nav-styles/views/shared/includes/head.ejs new file mode 100644 index 0000000..1f3039a --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/shared/includes/head.ejs @@ -0,0 +1,16 @@ + + + + + + + <%= pageTitle %> + + + + + + diff --git a/code/15-setting-up-base-nav-styles/views/shared/includes/header.ejs b/code/15-setting-up-base-nav-styles/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/15-setting-up-base-nav-styles/views/shared/includes/nav-items.ejs b/code/15-setting-up-base-nav-styles/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/15-setting-up-base-nav-styles/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/app.js b/code/16-frontend-js-for-toggling/app.js new file mode 100644 index 0000000..bbe6e64 --- /dev/null +++ b/code/16-frontend-js-for-toggling/app.js @@ -0,0 +1,45 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/16-frontend-js-for-toggling/config/session.js b/code/16-frontend-js-for-toggling/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/16-frontend-js-for-toggling/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/controllers/auth.controller.js b/code/16-frontend-js-for-toggling/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/16-frontend-js-for-toggling/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/16-frontend-js-for-toggling/data/database.js b/code/16-frontend-js-for-toggling/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/16-frontend-js-for-toggling/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/middlewares/check-auth.js b/code/16-frontend-js-for-toggling/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/16-frontend-js-for-toggling/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/middlewares/csrf-token.js b/code/16-frontend-js-for-toggling/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/16-frontend-js-for-toggling/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/middlewares/error-handler.js b/code/16-frontend-js-for-toggling/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/16-frontend-js-for-toggling/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/models/user.model.js b/code/16-frontend-js-for-toggling/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/16-frontend-js-for-toggling/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/16-frontend-js-for-toggling/package.json b/code/16-frontend-js-for-toggling/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/16-frontend-js-for-toggling/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/16-frontend-js-for-toggling/public/scripts/mobile.js b/code/16-frontend-js-for-toggling/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/16-frontend-js-for-toggling/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/public/styles/auth.css b/code/16-frontend-js-for-toggling/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/16-frontend-js-for-toggling/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/public/styles/base.css b/code/16-frontend-js-for-toggling/public/styles/base.css new file mode 100644 index 0000000..b83dc9e --- /dev/null +++ b/code/16-frontend-js-for-toggling/public/styles/base.css @@ -0,0 +1,97 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/public/styles/forms.css b/code/16-frontend-js-for-toggling/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/16-frontend-js-for-toggling/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/public/styles/navigation.css b/code/16-frontend-js-for-toggling/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/16-frontend-js-for-toggling/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/routes/auth.routes.js b/code/16-frontend-js-for-toggling/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/16-frontend-js-for-toggling/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/routes/base.routes.js b/code/16-frontend-js-for-toggling/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/16-frontend-js-for-toggling/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/routes/products.routes.js b/code/16-frontend-js-for-toggling/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/16-frontend-js-for-toggling/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/util/authentication.js b/code/16-frontend-js-for-toggling/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/16-frontend-js-for-toggling/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/util/session-flash.js b/code/16-frontend-js-for-toggling/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/16-frontend-js-for-toggling/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/util/validation.js b/code/16-frontend-js-for-toggling/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/16-frontend-js-for-toggling/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/16-frontend-js-for-toggling/views/customer/auth/login.ejs b/code/16-frontend-js-for-toggling/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/views/customer/auth/signup.ejs b/code/16-frontend-js-for-toggling/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/views/customer/products/all-products.ejs b/code/16-frontend-js-for-toggling/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/views/shared/500.ejs b/code/16-frontend-js-for-toggling/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/views/shared/includes/footer.ejs b/code/16-frontend-js-for-toggling/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/views/shared/includes/head.ejs b/code/16-frontend-js-for-toggling/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/16-frontend-js-for-toggling/views/shared/includes/header.ejs b/code/16-frontend-js-for-toggling/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/16-frontend-js-for-toggling/views/shared/includes/nav-items.ejs b/code/16-frontend-js-for-toggling/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/16-frontend-js-for-toggling/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/app.js b/code/17-adding-product-admin-pages/app.js new file mode 100644 index 0000000..7ebe3a7 --- /dev/null +++ b/code/17-adding-product-admin-pages/app.js @@ -0,0 +1,47 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/17-adding-product-admin-pages/config/session.js b/code/17-adding-product-admin-pages/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/17-adding-product-admin-pages/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/controllers/admin.controller.js b/code/17-adding-product-admin-pages/controllers/admin.controller.js new file mode 100644 index 0000000..50f522c --- /dev/null +++ b/code/17-adding-product-admin-pages/controllers/admin.controller.js @@ -0,0 +1,15 @@ +function getProducts(req, res) { + res.render('admin/products/all-products'); +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +function createNewProduct() {} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct +}; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/controllers/auth.controller.js b/code/17-adding-product-admin-pages/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/17-adding-product-admin-pages/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/17-adding-product-admin-pages/data/database.js b/code/17-adding-product-admin-pages/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/17-adding-product-admin-pages/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/middlewares/check-auth.js b/code/17-adding-product-admin-pages/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/17-adding-product-admin-pages/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/middlewares/csrf-token.js b/code/17-adding-product-admin-pages/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/17-adding-product-admin-pages/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/middlewares/error-handler.js b/code/17-adding-product-admin-pages/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/17-adding-product-admin-pages/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/models/user.model.js b/code/17-adding-product-admin-pages/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/17-adding-product-admin-pages/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/17-adding-product-admin-pages/package.json b/code/17-adding-product-admin-pages/package.json new file mode 100644 index 0000000..30f5766 --- /dev/null +++ b/code/17-adding-product-admin-pages/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/17-adding-product-admin-pages/public/scripts/mobile.js b/code/17-adding-product-admin-pages/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/17-adding-product-admin-pages/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/public/styles/auth.css b/code/17-adding-product-admin-pages/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/17-adding-product-admin-pages/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/public/styles/base.css b/code/17-adding-product-admin-pages/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/17-adding-product-admin-pages/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/public/styles/forms.css b/code/17-adding-product-admin-pages/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/17-adding-product-admin-pages/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/public/styles/navigation.css b/code/17-adding-product-admin-pages/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/17-adding-product-admin-pages/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/routes/admin.routes.js b/code/17-adding-product-admin-pages/routes/admin.routes.js new file mode 100644 index 0000000..03611e2 --- /dev/null +++ b/code/17-adding-product-admin-pages/routes/admin.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/routes/auth.routes.js b/code/17-adding-product-admin-pages/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/17-adding-product-admin-pages/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/routes/base.routes.js b/code/17-adding-product-admin-pages/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/17-adding-product-admin-pages/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/routes/products.routes.js b/code/17-adding-product-admin-pages/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/17-adding-product-admin-pages/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/util/authentication.js b/code/17-adding-product-admin-pages/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/17-adding-product-admin-pages/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/util/session-flash.js b/code/17-adding-product-admin-pages/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/17-adding-product-admin-pages/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/util/validation.js b/code/17-adding-product-admin-pages/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/17-adding-product-admin-pages/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/17-adding-product-admin-pages/views/admin/products/all-products.ejs b/code/17-adding-product-admin-pages/views/admin/products/all-products.ejs new file mode 100644 index 0000000..1b00bba --- /dev/null +++ b/code/17-adding-product-admin-pages/views/admin/products/all-products.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+

A list of products...

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/admin/products/new-product.ejs b/code/17-adding-product-admin-pages/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e40365f --- /dev/null +++ b/code/17-adding-product-admin-pages/views/admin/products/new-product.ejs @@ -0,0 +1,39 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+
+

+ + +

+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/customer/auth/login.ejs b/code/17-adding-product-admin-pages/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/customer/auth/signup.ejs b/code/17-adding-product-admin-pages/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/customer/products/all-products.ejs b/code/17-adding-product-admin-pages/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/shared/500.ejs b/code/17-adding-product-admin-pages/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/shared/includes/footer.ejs b/code/17-adding-product-admin-pages/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/17-adding-product-admin-pages/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/shared/includes/head.ejs b/code/17-adding-product-admin-pages/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/17-adding-product-admin-pages/views/shared/includes/header.ejs b/code/17-adding-product-admin-pages/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/17-adding-product-admin-pages/views/shared/includes/nav-items.ejs b/code/17-adding-product-admin-pages/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/17-adding-product-admin-pages/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/18-adding-image-upload/app.js b/code/18-adding-image-upload/app.js new file mode 100644 index 0000000..7ebe3a7 --- /dev/null +++ b/code/18-adding-image-upload/app.js @@ -0,0 +1,47 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/18-adding-image-upload/config/session.js b/code/18-adding-image-upload/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/18-adding-image-upload/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/18-adding-image-upload/controllers/admin.controller.js b/code/18-adding-image-upload/controllers/admin.controller.js new file mode 100644 index 0000000..fd27aae --- /dev/null +++ b/code/18-adding-image-upload/controllers/admin.controller.js @@ -0,0 +1,19 @@ +function getProducts(req, res) { + res.render('admin/products/all-products'); +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +function createNewProduct(req, res) { + + + res.redirect('/admin/products'); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct +}; \ No newline at end of file diff --git a/code/18-adding-image-upload/controllers/auth.controller.js b/code/18-adding-image-upload/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/18-adding-image-upload/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/18-adding-image-upload/data/database.js b/code/18-adding-image-upload/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/18-adding-image-upload/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/18-adding-image-upload/middlewares/check-auth.js b/code/18-adding-image-upload/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/18-adding-image-upload/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/18-adding-image-upload/middlewares/csrf-token.js b/code/18-adding-image-upload/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/18-adding-image-upload/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/18-adding-image-upload/middlewares/error-handler.js b/code/18-adding-image-upload/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/18-adding-image-upload/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/18-adding-image-upload/middlewares/image-upload.js b/code/18-adding-image-upload/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/18-adding-image-upload/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/18-adding-image-upload/models/user.model.js b/code/18-adding-image-upload/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/18-adding-image-upload/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/18-adding-image-upload/package.json b/code/18-adding-image-upload/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/18-adding-image-upload/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/18-adding-image-upload/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/18-adding-image-upload/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/18-adding-image-upload/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/18-adding-image-upload/public/scripts/mobile.js b/code/18-adding-image-upload/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/18-adding-image-upload/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/18-adding-image-upload/public/styles/auth.css b/code/18-adding-image-upload/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/18-adding-image-upload/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/18-adding-image-upload/public/styles/base.css b/code/18-adding-image-upload/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/18-adding-image-upload/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/18-adding-image-upload/public/styles/forms.css b/code/18-adding-image-upload/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/18-adding-image-upload/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/18-adding-image-upload/public/styles/navigation.css b/code/18-adding-image-upload/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/18-adding-image-upload/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/18-adding-image-upload/routes/admin.routes.js b/code/18-adding-image-upload/routes/admin.routes.js new file mode 100644 index 0000000..cb5dd55 --- /dev/null +++ b/code/18-adding-image-upload/routes/admin.routes.js @@ -0,0 +1,14 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/18-adding-image-upload/routes/auth.routes.js b/code/18-adding-image-upload/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/18-adding-image-upload/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/18-adding-image-upload/routes/base.routes.js b/code/18-adding-image-upload/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/18-adding-image-upload/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/18-adding-image-upload/routes/products.routes.js b/code/18-adding-image-upload/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/18-adding-image-upload/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/18-adding-image-upload/util/authentication.js b/code/18-adding-image-upload/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/18-adding-image-upload/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/18-adding-image-upload/util/session-flash.js b/code/18-adding-image-upload/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/18-adding-image-upload/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/18-adding-image-upload/util/validation.js b/code/18-adding-image-upload/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/18-adding-image-upload/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/18-adding-image-upload/views/admin/products/all-products.ejs b/code/18-adding-image-upload/views/admin/products/all-products.ejs new file mode 100644 index 0000000..1b00bba --- /dev/null +++ b/code/18-adding-image-upload/views/admin/products/all-products.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+

A list of products...

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/18-adding-image-upload/views/admin/products/new-product.ejs b/code/18-adding-image-upload/views/admin/products/new-product.ejs new file mode 100644 index 0000000..7a87219 --- /dev/null +++ b/code/18-adding-image-upload/views/admin/products/new-product.ejs @@ -0,0 +1,39 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+
+

+ + +

+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/18-adding-image-upload/views/customer/auth/login.ejs b/code/18-adding-image-upload/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/18-adding-image-upload/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/18-adding-image-upload/views/customer/auth/signup.ejs b/code/18-adding-image-upload/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/18-adding-image-upload/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/18-adding-image-upload/views/customer/products/all-products.ejs b/code/18-adding-image-upload/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/18-adding-image-upload/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/18-adding-image-upload/views/shared/500.ejs b/code/18-adding-image-upload/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/18-adding-image-upload/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/18-adding-image-upload/views/shared/includes/footer.ejs b/code/18-adding-image-upload/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/18-adding-image-upload/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/18-adding-image-upload/views/shared/includes/head.ejs b/code/18-adding-image-upload/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/18-adding-image-upload/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/18-adding-image-upload/views/shared/includes/header.ejs b/code/18-adding-image-upload/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/18-adding-image-upload/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/18-adding-image-upload/views/shared/includes/nav-items.ejs b/code/18-adding-image-upload/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/18-adding-image-upload/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/19-adding-a-product-model/app.js b/code/19-adding-a-product-model/app.js new file mode 100644 index 0000000..7ebe3a7 --- /dev/null +++ b/code/19-adding-a-product-model/app.js @@ -0,0 +1,47 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/19-adding-a-product-model/config/session.js b/code/19-adding-a-product-model/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/19-adding-a-product-model/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/19-adding-a-product-model/controllers/admin.controller.js b/code/19-adding-a-product-model/controllers/admin.controller.js new file mode 100644 index 0000000..0f01642 --- /dev/null +++ b/code/19-adding-a-product-model/controllers/admin.controller.js @@ -0,0 +1,31 @@ +const Product = require('../models/product.model'); + +function getProducts(req, res) { + res.render('admin/products/all-products'); +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, +}; diff --git a/code/19-adding-a-product-model/controllers/auth.controller.js b/code/19-adding-a-product-model/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/19-adding-a-product-model/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/19-adding-a-product-model/data/database.js b/code/19-adding-a-product-model/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/19-adding-a-product-model/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/19-adding-a-product-model/middlewares/check-auth.js b/code/19-adding-a-product-model/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/19-adding-a-product-model/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/19-adding-a-product-model/middlewares/csrf-token.js b/code/19-adding-a-product-model/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/19-adding-a-product-model/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/19-adding-a-product-model/middlewares/error-handler.js b/code/19-adding-a-product-model/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/19-adding-a-product-model/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/19-adding-a-product-model/middlewares/image-upload.js b/code/19-adding-a-product-model/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/19-adding-a-product-model/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/19-adding-a-product-model/models/product.model.js b/code/19-adding-a-product-model/models/product.model.js new file mode 100644 index 0000000..0943a22 --- /dev/null +++ b/code/19-adding-a-product-model/models/product.model.js @@ -0,0 +1,27 @@ +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.imagePath = `product-data/images/${productData.image}`; + this.imageUrl = `/products/assets/images/${productData.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image + }; + + await db.getDb().collection('products').insertOne(productData); + } +} + +module.exports = Product; \ No newline at end of file diff --git a/code/19-adding-a-product-model/models/user.model.js b/code/19-adding-a-product-model/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/19-adding-a-product-model/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/19-adding-a-product-model/package.json b/code/19-adding-a-product-model/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/19-adding-a-product-model/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/19-adding-a-product-model/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/19-adding-a-product-model/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/19-adding-a-product-model/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/19-adding-a-product-model/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/19-adding-a-product-model/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/19-adding-a-product-model/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/19-adding-a-product-model/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/19-adding-a-product-model/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/19-adding-a-product-model/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/19-adding-a-product-model/public/scripts/mobile.js b/code/19-adding-a-product-model/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/19-adding-a-product-model/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/19-adding-a-product-model/public/styles/auth.css b/code/19-adding-a-product-model/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/19-adding-a-product-model/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/19-adding-a-product-model/public/styles/base.css b/code/19-adding-a-product-model/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/19-adding-a-product-model/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/19-adding-a-product-model/public/styles/forms.css b/code/19-adding-a-product-model/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/19-adding-a-product-model/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/19-adding-a-product-model/public/styles/navigation.css b/code/19-adding-a-product-model/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/19-adding-a-product-model/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/19-adding-a-product-model/routes/admin.routes.js b/code/19-adding-a-product-model/routes/admin.routes.js new file mode 100644 index 0000000..cb5dd55 --- /dev/null +++ b/code/19-adding-a-product-model/routes/admin.routes.js @@ -0,0 +1,14 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/19-adding-a-product-model/routes/auth.routes.js b/code/19-adding-a-product-model/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/19-adding-a-product-model/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/19-adding-a-product-model/routes/base.routes.js b/code/19-adding-a-product-model/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/19-adding-a-product-model/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/19-adding-a-product-model/routes/products.routes.js b/code/19-adding-a-product-model/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/19-adding-a-product-model/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/19-adding-a-product-model/util/authentication.js b/code/19-adding-a-product-model/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/19-adding-a-product-model/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/19-adding-a-product-model/util/session-flash.js b/code/19-adding-a-product-model/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/19-adding-a-product-model/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/19-adding-a-product-model/util/validation.js b/code/19-adding-a-product-model/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/19-adding-a-product-model/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/19-adding-a-product-model/views/admin/products/all-products.ejs b/code/19-adding-a-product-model/views/admin/products/all-products.ejs new file mode 100644 index 0000000..1b00bba --- /dev/null +++ b/code/19-adding-a-product-model/views/admin/products/all-products.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+

A list of products...

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/admin/products/new-product.ejs b/code/19-adding-a-product-model/views/admin/products/new-product.ejs new file mode 100644 index 0000000..7a87219 --- /dev/null +++ b/code/19-adding-a-product-model/views/admin/products/new-product.ejs @@ -0,0 +1,39 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+
+

+ + +

+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/customer/auth/login.ejs b/code/19-adding-a-product-model/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/19-adding-a-product-model/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/customer/auth/signup.ejs b/code/19-adding-a-product-model/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/19-adding-a-product-model/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/customer/products/all-products.ejs b/code/19-adding-a-product-model/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/19-adding-a-product-model/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/shared/500.ejs b/code/19-adding-a-product-model/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/19-adding-a-product-model/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/shared/includes/footer.ejs b/code/19-adding-a-product-model/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/19-adding-a-product-model/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/shared/includes/head.ejs b/code/19-adding-a-product-model/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/19-adding-a-product-model/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/19-adding-a-product-model/views/shared/includes/header.ejs b/code/19-adding-a-product-model/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/19-adding-a-product-model/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/19-adding-a-product-model/views/shared/includes/nav-items.ejs b/code/19-adding-a-product-model/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/19-adding-a-product-model/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/app.js b/code/20-fetching-outputting-product-items/app.js new file mode 100644 index 0000000..1e1ad27 --- /dev/null +++ b/code/20-fetching-outputting-product-items/app.js @@ -0,0 +1,48 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/20-fetching-outputting-product-items/config/session.js b/code/20-fetching-outputting-product-items/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/20-fetching-outputting-product-items/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/controllers/admin.controller.js b/code/20-fetching-outputting-product-items/controllers/admin.controller.js new file mode 100644 index 0000000..cb4a155 --- /dev/null +++ b/code/20-fetching-outputting-product-items/controllers/admin.controller.js @@ -0,0 +1,37 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, +}; diff --git a/code/20-fetching-outputting-product-items/controllers/auth.controller.js b/code/20-fetching-outputting-product-items/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/20-fetching-outputting-product-items/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/20-fetching-outputting-product-items/data/database.js b/code/20-fetching-outputting-product-items/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/20-fetching-outputting-product-items/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/middlewares/check-auth.js b/code/20-fetching-outputting-product-items/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/20-fetching-outputting-product-items/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/middlewares/csrf-token.js b/code/20-fetching-outputting-product-items/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/20-fetching-outputting-product-items/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/middlewares/error-handler.js b/code/20-fetching-outputting-product-items/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/20-fetching-outputting-product-items/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/middlewares/image-upload.js b/code/20-fetching-outputting-product-items/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/20-fetching-outputting-product-items/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/models/product.model.js b/code/20-fetching-outputting-product-items/models/product.model.js new file mode 100644 index 0000000..efbcd4a --- /dev/null +++ b/code/20-fetching-outputting-product-items/models/product.model.js @@ -0,0 +1,38 @@ +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.imagePath = `product-data/images/${productData.image}`; + this.imageUrl = `/products/assets/images/${productData.image}`; + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function(productDocument) { + return new Product(productDocument); + }); + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image + }; + + await db.getDb().collection('products').insertOne(productData); + } +} + +module.exports = Product; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/models/user.model.js b/code/20-fetching-outputting-product-items/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/20-fetching-outputting-product-items/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/20-fetching-outputting-product-items/package.json b/code/20-fetching-outputting-product-items/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/20-fetching-outputting-product-items/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/20-fetching-outputting-product-items/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/20-fetching-outputting-product-items/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/20-fetching-outputting-product-items/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/20-fetching-outputting-product-items/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/20-fetching-outputting-product-items/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/20-fetching-outputting-product-items/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/20-fetching-outputting-product-items/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/20-fetching-outputting-product-items/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/20-fetching-outputting-product-items/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/20-fetching-outputting-product-items/public/scripts/mobile.js b/code/20-fetching-outputting-product-items/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/20-fetching-outputting-product-items/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/public/styles/auth.css b/code/20-fetching-outputting-product-items/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/20-fetching-outputting-product-items/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/public/styles/base.css b/code/20-fetching-outputting-product-items/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/20-fetching-outputting-product-items/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/public/styles/forms.css b/code/20-fetching-outputting-product-items/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/20-fetching-outputting-product-items/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/public/styles/navigation.css b/code/20-fetching-outputting-product-items/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/20-fetching-outputting-product-items/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/public/styles/products.css b/code/20-fetching-outputting-product-items/public/styles/products.css new file mode 100644 index 0000000..ef2dfda --- /dev/null +++ b/code/20-fetching-outputting-product-items/public/styles/products.css @@ -0,0 +1,34 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); +} \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/routes/admin.routes.js b/code/20-fetching-outputting-product-items/routes/admin.routes.js new file mode 100644 index 0000000..cb5dd55 --- /dev/null +++ b/code/20-fetching-outputting-product-items/routes/admin.routes.js @@ -0,0 +1,14 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/routes/auth.routes.js b/code/20-fetching-outputting-product-items/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/20-fetching-outputting-product-items/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/routes/base.routes.js b/code/20-fetching-outputting-product-items/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/20-fetching-outputting-product-items/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/routes/products.routes.js b/code/20-fetching-outputting-product-items/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/20-fetching-outputting-product-items/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/util/authentication.js b/code/20-fetching-outputting-product-items/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/20-fetching-outputting-product-items/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/util/session-flash.js b/code/20-fetching-outputting-product-items/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/20-fetching-outputting-product-items/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/util/validation.js b/code/20-fetching-outputting-product-items/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/20-fetching-outputting-product-items/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/20-fetching-outputting-product-items/views/admin/products/all-products.ejs b/code/20-fetching-outputting-product-items/views/admin/products/all-products.ejs new file mode 100644 index 0000000..2ff8ce2 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/admin/products/all-products.ejs @@ -0,0 +1,24 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/admin/products/includes/product-item.ejs b/code/20-fetching-outputting-product-items/views/admin/products/includes/product-item.ejs new file mode 100644 index 0000000..8789c96 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/admin/products/includes/product-item.ejs @@ -0,0 +1,10 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ View & Edit + +
+
+
diff --git a/code/20-fetching-outputting-product-items/views/admin/products/new-product.ejs b/code/20-fetching-outputting-product-items/views/admin/products/new-product.ejs new file mode 100644 index 0000000..7a87219 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/admin/products/new-product.ejs @@ -0,0 +1,39 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+
+

+ + +

+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/customer/auth/login.ejs b/code/20-fetching-outputting-product-items/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/customer/auth/signup.ejs b/code/20-fetching-outputting-product-items/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/customer/products/all-products.ejs b/code/20-fetching-outputting-product-items/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/shared/500.ejs b/code/20-fetching-outputting-product-items/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/shared/includes/footer.ejs b/code/20-fetching-outputting-product-items/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/shared/includes/head.ejs b/code/20-fetching-outputting-product-items/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/20-fetching-outputting-product-items/views/shared/includes/header.ejs b/code/20-fetching-outputting-product-items/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/20-fetching-outputting-product-items/views/shared/includes/nav-items.ejs b/code/20-fetching-outputting-product-items/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/20-fetching-outputting-product-items/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/21-updating-products/app.js b/code/21-updating-products/app.js new file mode 100644 index 0000000..1e1ad27 --- /dev/null +++ b/code/21-updating-products/app.js @@ -0,0 +1,48 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/21-updating-products/config/session.js b/code/21-updating-products/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/21-updating-products/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/21-updating-products/controllers/admin.controller.js b/code/21-updating-products/controllers/admin.controller.js new file mode 100644 index 0000000..0edf162 --- /dev/null +++ b/code/21-updating-products/controllers/admin.controller.js @@ -0,0 +1,68 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, +}; diff --git a/code/21-updating-products/controllers/auth.controller.js b/code/21-updating-products/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/21-updating-products/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/21-updating-products/data/database.js b/code/21-updating-products/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/21-updating-products/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/21-updating-products/middlewares/check-auth.js b/code/21-updating-products/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/21-updating-products/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/21-updating-products/middlewares/csrf-token.js b/code/21-updating-products/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/21-updating-products/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/21-updating-products/middlewares/error-handler.js b/code/21-updating-products/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/21-updating-products/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/21-updating-products/middlewares/image-upload.js b/code/21-updating-products/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/21-updating-products/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/21-updating-products/models/product.model.js b/code/21-updating-products/models/product.model.js new file mode 100644 index 0000000..417fd5c --- /dev/null +++ b/code/21-updating-products/models/product.model.js @@ -0,0 +1,86 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } +} + +module.exports = Product; diff --git a/code/21-updating-products/models/user.model.js b/code/21-updating-products/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/21-updating-products/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/21-updating-products/package.json b/code/21-updating-products/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/21-updating-products/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/21-updating-products/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/21-updating-products/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/21-updating-products/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/21-updating-products/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/21-updating-products/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/21-updating-products/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/21-updating-products/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/21-updating-products/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/21-updating-products/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/21-updating-products/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/21-updating-products/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/21-updating-products/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/21-updating-products/public/scripts/mobile.js b/code/21-updating-products/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/21-updating-products/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/21-updating-products/public/styles/auth.css b/code/21-updating-products/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/21-updating-products/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/21-updating-products/public/styles/base.css b/code/21-updating-products/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/21-updating-products/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/21-updating-products/public/styles/forms.css b/code/21-updating-products/public/styles/forms.css new file mode 100644 index 0000000..b008fa2 --- /dev/null +++ b/code/21-updating-products/public/styles/forms.css @@ -0,0 +1,18 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} \ No newline at end of file diff --git a/code/21-updating-products/public/styles/navigation.css b/code/21-updating-products/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/21-updating-products/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/21-updating-products/public/styles/products.css b/code/21-updating-products/public/styles/products.css new file mode 100644 index 0000000..ef2dfda --- /dev/null +++ b/code/21-updating-products/public/styles/products.css @@ -0,0 +1,34 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); +} \ No newline at end of file diff --git a/code/21-updating-products/routes/admin.routes.js b/code/21-updating-products/routes/admin.routes.js new file mode 100644 index 0000000..fbece69 --- /dev/null +++ b/code/21-updating-products/routes/admin.routes.js @@ -0,0 +1,18 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/21-updating-products/routes/auth.routes.js b/code/21-updating-products/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/21-updating-products/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/21-updating-products/routes/base.routes.js b/code/21-updating-products/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/21-updating-products/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/21-updating-products/routes/products.routes.js b/code/21-updating-products/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/21-updating-products/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/21-updating-products/util/authentication.js b/code/21-updating-products/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/21-updating-products/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/21-updating-products/util/session-flash.js b/code/21-updating-products/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/21-updating-products/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/21-updating-products/util/validation.js b/code/21-updating-products/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/21-updating-products/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/21-updating-products/views/admin/products/all-products.ejs b/code/21-updating-products/views/admin/products/all-products.ejs new file mode 100644 index 0000000..2ff8ce2 --- /dev/null +++ b/code/21-updating-products/views/admin/products/all-products.ejs @@ -0,0 +1,24 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/admin/products/includes/product-form.ejs b/code/21-updating-products/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..c559eaf --- /dev/null +++ b/code/21-updating-products/views/admin/products/includes/product-form.ejs @@ -0,0 +1,30 @@ +
+

+ + +

+

+ + required<% } %>> +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/21-updating-products/views/admin/products/includes/product-item.ejs b/code/21-updating-products/views/admin/products/includes/product-item.ejs new file mode 100644 index 0000000..8789c96 --- /dev/null +++ b/code/21-updating-products/views/admin/products/includes/product-item.ejs @@ -0,0 +1,10 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ View & Edit + +
+
+
diff --git a/code/21-updating-products/views/admin/products/new-product.ejs b/code/21-updating-products/views/admin/products/new-product.ejs new file mode 100644 index 0000000..0d3022a --- /dev/null +++ b/code/21-updating-products/views/admin/products/new-product.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/admin/products/update-product.ejs b/code/21-updating-products/views/admin/products/update-product.ejs new file mode 100644 index 0000000..c450404 --- /dev/null +++ b/code/21-updating-products/views/admin/products/update-product.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/customer/auth/login.ejs b/code/21-updating-products/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/21-updating-products/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/customer/auth/signup.ejs b/code/21-updating-products/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/21-updating-products/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/customer/products/all-products.ejs b/code/21-updating-products/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/21-updating-products/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/shared/500.ejs b/code/21-updating-products/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/21-updating-products/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/21-updating-products/views/shared/includes/footer.ejs b/code/21-updating-products/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/21-updating-products/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/21-updating-products/views/shared/includes/head.ejs b/code/21-updating-products/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/21-updating-products/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/21-updating-products/views/shared/includes/header.ejs b/code/21-updating-products/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/21-updating-products/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/21-updating-products/views/shared/includes/nav-items.ejs b/code/21-updating-products/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/21-updating-products/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/app.js b/code/22-adding-file-upload-preview/app.js new file mode 100644 index 0000000..1e1ad27 --- /dev/null +++ b/code/22-adding-file-upload-preview/app.js @@ -0,0 +1,48 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/22-adding-file-upload-preview/config/session.js b/code/22-adding-file-upload-preview/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/22-adding-file-upload-preview/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/controllers/admin.controller.js b/code/22-adding-file-upload-preview/controllers/admin.controller.js new file mode 100644 index 0000000..0edf162 --- /dev/null +++ b/code/22-adding-file-upload-preview/controllers/admin.controller.js @@ -0,0 +1,68 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, +}; diff --git a/code/22-adding-file-upload-preview/controllers/auth.controller.js b/code/22-adding-file-upload-preview/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/22-adding-file-upload-preview/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/22-adding-file-upload-preview/data/database.js b/code/22-adding-file-upload-preview/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/22-adding-file-upload-preview/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/middlewares/check-auth.js b/code/22-adding-file-upload-preview/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/22-adding-file-upload-preview/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/middlewares/csrf-token.js b/code/22-adding-file-upload-preview/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/22-adding-file-upload-preview/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/middlewares/error-handler.js b/code/22-adding-file-upload-preview/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/22-adding-file-upload-preview/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/middlewares/image-upload.js b/code/22-adding-file-upload-preview/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/22-adding-file-upload-preview/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/models/product.model.js b/code/22-adding-file-upload-preview/models/product.model.js new file mode 100644 index 0000000..417fd5c --- /dev/null +++ b/code/22-adding-file-upload-preview/models/product.model.js @@ -0,0 +1,86 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } +} + +module.exports = Product; diff --git a/code/22-adding-file-upload-preview/models/user.model.js b/code/22-adding-file-upload-preview/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/22-adding-file-upload-preview/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/22-adding-file-upload-preview/package.json b/code/22-adding-file-upload-preview/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/22-adding-file-upload-preview/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/22-adding-file-upload-preview/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/22-adding-file-upload-preview/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/22-adding-file-upload-preview/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/22-adding-file-upload-preview/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/22-adding-file-upload-preview/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/22-adding-file-upload-preview/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/22-adding-file-upload-preview/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/22-adding-file-upload-preview/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/22-adding-file-upload-preview/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/22-adding-file-upload-preview/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/22-adding-file-upload-preview/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/22-adding-file-upload-preview/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/22-adding-file-upload-preview/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/22-adding-file-upload-preview/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/22-adding-file-upload-preview/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/22-adding-file-upload-preview/public/scripts/image-preview.js b/code/22-adding-file-upload-preview/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/22-adding-file-upload-preview/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/public/scripts/mobile.js b/code/22-adding-file-upload-preview/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/22-adding-file-upload-preview/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/public/styles/auth.css b/code/22-adding-file-upload-preview/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/22-adding-file-upload-preview/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/public/styles/base.css b/code/22-adding-file-upload-preview/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/22-adding-file-upload-preview/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/public/styles/forms.css b/code/22-adding-file-upload-preview/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/22-adding-file-upload-preview/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/22-adding-file-upload-preview/public/styles/navigation.css b/code/22-adding-file-upload-preview/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/22-adding-file-upload-preview/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/public/styles/products.css b/code/22-adding-file-upload-preview/public/styles/products.css new file mode 100644 index 0000000..ef2dfda --- /dev/null +++ b/code/22-adding-file-upload-preview/public/styles/products.css @@ -0,0 +1,34 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); +} \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/routes/admin.routes.js b/code/22-adding-file-upload-preview/routes/admin.routes.js new file mode 100644 index 0000000..fbece69 --- /dev/null +++ b/code/22-adding-file-upload-preview/routes/admin.routes.js @@ -0,0 +1,18 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/routes/auth.routes.js b/code/22-adding-file-upload-preview/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/22-adding-file-upload-preview/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/routes/base.routes.js b/code/22-adding-file-upload-preview/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/22-adding-file-upload-preview/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/routes/products.routes.js b/code/22-adding-file-upload-preview/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/22-adding-file-upload-preview/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/util/authentication.js b/code/22-adding-file-upload-preview/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/22-adding-file-upload-preview/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/util/session-flash.js b/code/22-adding-file-upload-preview/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/22-adding-file-upload-preview/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/util/validation.js b/code/22-adding-file-upload-preview/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/22-adding-file-upload-preview/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/22-adding-file-upload-preview/views/admin/products/all-products.ejs b/code/22-adding-file-upload-preview/views/admin/products/all-products.ejs new file mode 100644 index 0000000..2ff8ce2 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/admin/products/all-products.ejs @@ -0,0 +1,24 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/admin/products/includes/product-form.ejs b/code/22-adding-file-upload-preview/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/22-adding-file-upload-preview/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/admin/products/includes/product-item.ejs b/code/22-adding-file-upload-preview/views/admin/products/includes/product-item.ejs new file mode 100644 index 0000000..8789c96 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/admin/products/includes/product-item.ejs @@ -0,0 +1,10 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ View & Edit + +
+
+
diff --git a/code/22-adding-file-upload-preview/views/admin/products/new-product.ejs b/code/22-adding-file-upload-preview/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/22-adding-file-upload-preview/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/admin/products/update-product.ejs b/code/22-adding-file-upload-preview/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/22-adding-file-upload-preview/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/customer/auth/login.ejs b/code/22-adding-file-upload-preview/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/customer/auth/signup.ejs b/code/22-adding-file-upload-preview/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/customer/products/all-products.ejs b/code/22-adding-file-upload-preview/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/shared/500.ejs b/code/22-adding-file-upload-preview/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/shared/includes/footer.ejs b/code/22-adding-file-upload-preview/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/22-adding-file-upload-preview/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/shared/includes/head.ejs b/code/22-adding-file-upload-preview/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/22-adding-file-upload-preview/views/shared/includes/header.ejs b/code/22-adding-file-upload-preview/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/22-adding-file-upload-preview/views/shared/includes/nav-items.ejs b/code/22-adding-file-upload-preview/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/22-adding-file-upload-preview/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/23-making-products-deletable/app.js b/code/23-making-products-deletable/app.js new file mode 100644 index 0000000..1e1ad27 --- /dev/null +++ b/code/23-making-products-deletable/app.js @@ -0,0 +1,48 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/23-making-products-deletable/config/session.js b/code/23-making-products-deletable/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/23-making-products-deletable/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/23-making-products-deletable/controllers/admin.controller.js b/code/23-making-products-deletable/controllers/admin.controller.js new file mode 100644 index 0000000..8831fa9 --- /dev/null +++ b/code/23-making-products-deletable/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.redirect('/admin/products'); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct +}; diff --git a/code/23-making-products-deletable/controllers/auth.controller.js b/code/23-making-products-deletable/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/23-making-products-deletable/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/23-making-products-deletable/data/database.js b/code/23-making-products-deletable/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/23-making-products-deletable/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/23-making-products-deletable/middlewares/check-auth.js b/code/23-making-products-deletable/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/23-making-products-deletable/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/23-making-products-deletable/middlewares/csrf-token.js b/code/23-making-products-deletable/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/23-making-products-deletable/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/23-making-products-deletable/middlewares/error-handler.js b/code/23-making-products-deletable/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/23-making-products-deletable/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/23-making-products-deletable/middlewares/image-upload.js b/code/23-making-products-deletable/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/23-making-products-deletable/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/23-making-products-deletable/models/product.model.js b/code/23-making-products-deletable/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/23-making-products-deletable/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/23-making-products-deletable/models/user.model.js b/code/23-making-products-deletable/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/23-making-products-deletable/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/23-making-products-deletable/package.json b/code/23-making-products-deletable/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/23-making-products-deletable/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/23-making-products-deletable/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/23-making-products-deletable/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/23-making-products-deletable/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/23-making-products-deletable/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/23-making-products-deletable/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/23-making-products-deletable/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/23-making-products-deletable/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/23-making-products-deletable/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/23-making-products-deletable/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/23-making-products-deletable/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/23-making-products-deletable/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/23-making-products-deletable/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/23-making-products-deletable/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/23-making-products-deletable/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/23-making-products-deletable/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/23-making-products-deletable/public/scripts/image-preview.js b/code/23-making-products-deletable/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/23-making-products-deletable/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/23-making-products-deletable/public/scripts/mobile.js b/code/23-making-products-deletable/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/23-making-products-deletable/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/23-making-products-deletable/public/styles/auth.css b/code/23-making-products-deletable/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/23-making-products-deletable/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/23-making-products-deletable/public/styles/base.css b/code/23-making-products-deletable/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/23-making-products-deletable/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/23-making-products-deletable/public/styles/forms.css b/code/23-making-products-deletable/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/23-making-products-deletable/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/23-making-products-deletable/public/styles/navigation.css b/code/23-making-products-deletable/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/23-making-products-deletable/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/23-making-products-deletable/public/styles/products.css b/code/23-making-products-deletable/public/styles/products.css new file mode 100644 index 0000000..ef2dfda --- /dev/null +++ b/code/23-making-products-deletable/public/styles/products.css @@ -0,0 +1,34 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); +} \ No newline at end of file diff --git a/code/23-making-products-deletable/routes/admin.routes.js b/code/23-making-products-deletable/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/23-making-products-deletable/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/23-making-products-deletable/routes/auth.routes.js b/code/23-making-products-deletable/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/23-making-products-deletable/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/23-making-products-deletable/routes/base.routes.js b/code/23-making-products-deletable/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/23-making-products-deletable/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/23-making-products-deletable/routes/products.routes.js b/code/23-making-products-deletable/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/23-making-products-deletable/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/23-making-products-deletable/util/authentication.js b/code/23-making-products-deletable/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/23-making-products-deletable/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/23-making-products-deletable/util/session-flash.js b/code/23-making-products-deletable/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/23-making-products-deletable/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/23-making-products-deletable/util/validation.js b/code/23-making-products-deletable/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/23-making-products-deletable/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/23-making-products-deletable/views/admin/products/all-products.ejs b/code/23-making-products-deletable/views/admin/products/all-products.ejs new file mode 100644 index 0000000..2ff8ce2 --- /dev/null +++ b/code/23-making-products-deletable/views/admin/products/all-products.ejs @@ -0,0 +1,24 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/admin/products/includes/product-form.ejs b/code/23-making-products-deletable/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/23-making-products-deletable/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/23-making-products-deletable/views/admin/products/includes/product-item.ejs b/code/23-making-products-deletable/views/admin/products/includes/product-item.ejs new file mode 100644 index 0000000..8789c96 --- /dev/null +++ b/code/23-making-products-deletable/views/admin/products/includes/product-item.ejs @@ -0,0 +1,10 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ View & Edit + +
+
+
diff --git a/code/23-making-products-deletable/views/admin/products/new-product.ejs b/code/23-making-products-deletable/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/23-making-products-deletable/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/admin/products/update-product.ejs b/code/23-making-products-deletable/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/23-making-products-deletable/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/customer/auth/login.ejs b/code/23-making-products-deletable/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/23-making-products-deletable/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/customer/auth/signup.ejs b/code/23-making-products-deletable/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/23-making-products-deletable/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/customer/products/all-products.ejs b/code/23-making-products-deletable/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/23-making-products-deletable/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/shared/500.ejs b/code/23-making-products-deletable/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/23-making-products-deletable/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/23-making-products-deletable/views/shared/includes/footer.ejs b/code/23-making-products-deletable/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/23-making-products-deletable/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/23-making-products-deletable/views/shared/includes/head.ejs b/code/23-making-products-deletable/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/23-making-products-deletable/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/23-making-products-deletable/views/shared/includes/header.ejs b/code/23-making-products-deletable/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/23-making-products-deletable/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/23-making-products-deletable/views/shared/includes/nav-items.ejs b/code/23-making-products-deletable/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/23-making-products-deletable/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/24-using-ajax/app.js b/code/24-using-ajax/app.js new file mode 100644 index 0000000..1e1ad27 --- /dev/null +++ b/code/24-using-ajax/app.js @@ -0,0 +1,48 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/24-using-ajax/config/session.js b/code/24-using-ajax/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/24-using-ajax/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/24-using-ajax/controllers/admin.controller.js b/code/24-using-ajax/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/24-using-ajax/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/24-using-ajax/controllers/auth.controller.js b/code/24-using-ajax/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/24-using-ajax/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/24-using-ajax/data/database.js b/code/24-using-ajax/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/24-using-ajax/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/24-using-ajax/middlewares/check-auth.js b/code/24-using-ajax/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/24-using-ajax/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/24-using-ajax/middlewares/csrf-token.js b/code/24-using-ajax/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/24-using-ajax/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/24-using-ajax/middlewares/error-handler.js b/code/24-using-ajax/middlewares/error-handler.js new file mode 100644 index 0000000..c150d6a --- /dev/null +++ b/code/24-using-ajax/middlewares/error-handler.js @@ -0,0 +1,6 @@ +function handleErrors(error, req, res, next) { + console.log(error); + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/24-using-ajax/middlewares/image-upload.js b/code/24-using-ajax/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/24-using-ajax/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/24-using-ajax/models/product.model.js b/code/24-using-ajax/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/24-using-ajax/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/24-using-ajax/models/user.model.js b/code/24-using-ajax/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/24-using-ajax/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/24-using-ajax/package.json b/code/24-using-ajax/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/24-using-ajax/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/24-using-ajax/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/24-using-ajax/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/24-using-ajax/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/24-using-ajax/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/24-using-ajax/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/24-using-ajax/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/24-using-ajax/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/24-using-ajax/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/24-using-ajax/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/24-using-ajax/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/24-using-ajax/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/24-using-ajax/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/24-using-ajax/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/24-using-ajax/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/24-using-ajax/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/24-using-ajax/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/24-using-ajax/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/24-using-ajax/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/24-using-ajax/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/24-using-ajax/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/24-using-ajax/public/scripts/image-preview.js b/code/24-using-ajax/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/24-using-ajax/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/24-using-ajax/public/scripts/mobile.js b/code/24-using-ajax/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/24-using-ajax/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/24-using-ajax/public/scripts/product-management.js b/code/24-using-ajax/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/24-using-ajax/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/24-using-ajax/public/styles/auth.css b/code/24-using-ajax/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/24-using-ajax/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/24-using-ajax/public/styles/base.css b/code/24-using-ajax/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/24-using-ajax/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/24-using-ajax/public/styles/forms.css b/code/24-using-ajax/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/24-using-ajax/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/24-using-ajax/public/styles/navigation.css b/code/24-using-ajax/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/24-using-ajax/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/24-using-ajax/public/styles/products.css b/code/24-using-ajax/public/styles/products.css new file mode 100644 index 0000000..ef2dfda --- /dev/null +++ b/code/24-using-ajax/public/styles/products.css @@ -0,0 +1,34 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); +} \ No newline at end of file diff --git a/code/24-using-ajax/routes/admin.routes.js b/code/24-using-ajax/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/24-using-ajax/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/24-using-ajax/routes/auth.routes.js b/code/24-using-ajax/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/24-using-ajax/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/24-using-ajax/routes/base.routes.js b/code/24-using-ajax/routes/base.routes.js new file mode 100644 index 0000000..909fe9f --- /dev/null +++ b/code/24-using-ajax/routes/base.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/24-using-ajax/routes/products.routes.js b/code/24-using-ajax/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/24-using-ajax/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/24-using-ajax/util/authentication.js b/code/24-using-ajax/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/24-using-ajax/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/24-using-ajax/util/session-flash.js b/code/24-using-ajax/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/24-using-ajax/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/24-using-ajax/util/validation.js b/code/24-using-ajax/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/24-using-ajax/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/24-using-ajax/views/admin/products/all-products.ejs b/code/24-using-ajax/views/admin/products/all-products.ejs new file mode 100644 index 0000000..1079607 --- /dev/null +++ b/code/24-using-ajax/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/admin/products/includes/product-form.ejs b/code/24-using-ajax/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/24-using-ajax/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/24-using-ajax/views/admin/products/includes/product-item.ejs b/code/24-using-ajax/views/admin/products/includes/product-item.ejs new file mode 100644 index 0000000..a416aa2 --- /dev/null +++ b/code/24-using-ajax/views/admin/products/includes/product-item.ejs @@ -0,0 +1,10 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ View & Edit + +
+
+
diff --git a/code/24-using-ajax/views/admin/products/new-product.ejs b/code/24-using-ajax/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/24-using-ajax/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/admin/products/update-product.ejs b/code/24-using-ajax/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/24-using-ajax/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/customer/auth/login.ejs b/code/24-using-ajax/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/24-using-ajax/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/customer/auth/signup.ejs b/code/24-using-ajax/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/24-using-ajax/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/customer/products/all-products.ejs b/code/24-using-ajax/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/24-using-ajax/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/shared/500.ejs b/code/24-using-ajax/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/24-using-ajax/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/24-using-ajax/views/shared/includes/footer.ejs b/code/24-using-ajax/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/24-using-ajax/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/24-using-ajax/views/shared/includes/head.ejs b/code/24-using-ajax/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/24-using-ajax/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/24-using-ajax/views/shared/includes/header.ejs b/code/24-using-ajax/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/24-using-ajax/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/24-using-ajax/views/shared/includes/nav-items.ejs b/code/24-using-ajax/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/24-using-ajax/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/app.js b/code/25-various-fixed-proper-route-protection/app.js new file mode 100644 index 0000000..ab8ab6b --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/app.js @@ -0,0 +1,50 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use(protectRoutesMiddleware); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/25-various-fixed-proper-route-protection/config/session.js b/code/25-various-fixed-proper-route-protection/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/controllers/admin.controller.js b/code/25-various-fixed-proper-route-protection/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/25-various-fixed-proper-route-protection/controllers/auth.controller.js b/code/25-various-fixed-proper-route-protection/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/25-various-fixed-proper-route-protection/data/database.js b/code/25-various-fixed-proper-route-protection/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/middlewares/check-auth.js b/code/25-various-fixed-proper-route-protection/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/middlewares/csrf-token.js b/code/25-various-fixed-proper-route-protection/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/middlewares/error-handler.js b/code/25-various-fixed-proper-route-protection/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/middlewares/image-upload.js b/code/25-various-fixed-proper-route-protection/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/middlewares/protect-routes.js b/code/25-various-fixed-proper-route-protection/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/models/product.model.js b/code/25-various-fixed-proper-route-protection/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/25-various-fixed-proper-route-protection/models/user.model.js b/code/25-various-fixed-proper-route-protection/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/25-various-fixed-proper-route-protection/package.json b/code/25-various-fixed-proper-route-protection/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/25-various-fixed-proper-route-protection/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/25-various-fixed-proper-route-protection/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/25-various-fixed-proper-route-protection/public/scripts/image-preview.js b/code/25-various-fixed-proper-route-protection/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/public/scripts/mobile.js b/code/25-various-fixed-proper-route-protection/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/public/scripts/product-management.js b/code/25-various-fixed-proper-route-protection/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/public/styles/auth.css b/code/25-various-fixed-proper-route-protection/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/public/styles/base.css b/code/25-various-fixed-proper-route-protection/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/public/styles/forms.css b/code/25-various-fixed-proper-route-protection/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/25-various-fixed-proper-route-protection/public/styles/navigation.css b/code/25-various-fixed-proper-route-protection/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/public/styles/products.css b/code/25-various-fixed-proper-route-protection/public/styles/products.css new file mode 100644 index 0000000..ef2dfda --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/public/styles/products.css @@ -0,0 +1,34 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); +} \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/routes/admin.routes.js b/code/25-various-fixed-proper-route-protection/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/routes/auth.routes.js b/code/25-various-fixed-proper-route-protection/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/routes/base.routes.js b/code/25-various-fixed-proper-route-protection/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/routes/products.routes.js b/code/25-various-fixed-proper-route-protection/routes/products.routes.js new file mode 100644 index 0000000..3f0ac1f --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/products', function(req, res) { + res.render('customer/products/all-products'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/util/authentication.js b/code/25-various-fixed-proper-route-protection/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/util/session-flash.js b/code/25-various-fixed-proper-route-protection/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/util/validation.js b/code/25-various-fixed-proper-route-protection/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/25-various-fixed-proper-route-protection/views/admin/products/all-products.ejs b/code/25-various-fixed-proper-route-protection/views/admin/products/all-products.ejs new file mode 100644 index 0000000..1079607 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/admin/products/includes/product-form.ejs b/code/25-various-fixed-proper-route-protection/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/admin/products/includes/product-item.ejs b/code/25-various-fixed-proper-route-protection/views/admin/products/includes/product-item.ejs new file mode 100644 index 0000000..a416aa2 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/admin/products/includes/product-item.ejs @@ -0,0 +1,10 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ View & Edit + +
+
+
diff --git a/code/25-various-fixed-proper-route-protection/views/admin/products/new-product.ejs b/code/25-various-fixed-proper-route-protection/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/admin/products/update-product.ejs b/code/25-various-fixed-proper-route-protection/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/customer/auth/login.ejs b/code/25-various-fixed-proper-route-protection/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/customer/auth/signup.ejs b/code/25-various-fixed-proper-route-protection/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/customer/products/all-products.ejs b/code/25-various-fixed-proper-route-protection/views/customer/products/all-products.ejs new file mode 100644 index 0000000..4639d54 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/customer/products/all-products.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Products

+

A list of products...

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/401.ejs b/code/25-various-fixed-proper-route-protection/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/403.ejs b/code/25-various-fixed-proper-route-protection/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/404.ejs b/code/25-various-fixed-proper-route-protection/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/500.ejs b/code/25-various-fixed-proper-route-protection/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/includes/footer.ejs b/code/25-various-fixed-proper-route-protection/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/includes/head.ejs b/code/25-various-fixed-proper-route-protection/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/25-various-fixed-proper-route-protection/views/shared/includes/header.ejs b/code/25-various-fixed-proper-route-protection/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/25-various-fixed-proper-route-protection/views/shared/includes/nav-items.ejs b/code/25-various-fixed-proper-route-protection/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/25-various-fixed-proper-route-protection/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/app.js b/code/26-outputting-products-for-customers/app.js new file mode 100644 index 0000000..ab8ab6b --- /dev/null +++ b/code/26-outputting-products-for-customers/app.js @@ -0,0 +1,50 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use(protectRoutesMiddleware); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/26-outputting-products-for-customers/config/session.js b/code/26-outputting-products-for-customers/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/26-outputting-products-for-customers/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/controllers/admin.controller.js b/code/26-outputting-products-for-customers/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/26-outputting-products-for-customers/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/26-outputting-products-for-customers/controllers/auth.controller.js b/code/26-outputting-products-for-customers/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/26-outputting-products-for-customers/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/26-outputting-products-for-customers/controllers/products.controller.js b/code/26-outputting-products-for-customers/controllers/products.controller.js new file mode 100644 index 0000000..c67aabe --- /dev/null +++ b/code/26-outputting-products-for-customers/controllers/products.controller.js @@ -0,0 +1,14 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts +}; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/data/database.js b/code/26-outputting-products-for-customers/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/26-outputting-products-for-customers/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/middlewares/check-auth.js b/code/26-outputting-products-for-customers/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/26-outputting-products-for-customers/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/middlewares/csrf-token.js b/code/26-outputting-products-for-customers/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/26-outputting-products-for-customers/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/middlewares/error-handler.js b/code/26-outputting-products-for-customers/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/26-outputting-products-for-customers/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/middlewares/image-upload.js b/code/26-outputting-products-for-customers/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/26-outputting-products-for-customers/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/middlewares/protect-routes.js b/code/26-outputting-products-for-customers/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/26-outputting-products-for-customers/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/models/product.model.js b/code/26-outputting-products-for-customers/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/26-outputting-products-for-customers/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/26-outputting-products-for-customers/models/user.model.js b/code/26-outputting-products-for-customers/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/26-outputting-products-for-customers/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/26-outputting-products-for-customers/package.json b/code/26-outputting-products-for-customers/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/26-outputting-products-for-customers/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/26-outputting-products-for-customers/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/26-outputting-products-for-customers/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/26-outputting-products-for-customers/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/26-outputting-products-for-customers/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/26-outputting-products-for-customers/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/26-outputting-products-for-customers/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/26-outputting-products-for-customers/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/26-outputting-products-for-customers/public/scripts/image-preview.js b/code/26-outputting-products-for-customers/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/26-outputting-products-for-customers/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/public/scripts/mobile.js b/code/26-outputting-products-for-customers/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/26-outputting-products-for-customers/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/public/scripts/product-management.js b/code/26-outputting-products-for-customers/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/26-outputting-products-for-customers/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/public/styles/auth.css b/code/26-outputting-products-for-customers/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/26-outputting-products-for-customers/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/public/styles/base.css b/code/26-outputting-products-for-customers/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/26-outputting-products-for-customers/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/public/styles/forms.css b/code/26-outputting-products-for-customers/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/26-outputting-products-for-customers/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/26-outputting-products-for-customers/public/styles/navigation.css b/code/26-outputting-products-for-customers/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/26-outputting-products-for-customers/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/public/styles/products.css b/code/26-outputting-products-for-customers/public/styles/products.css new file mode 100644 index 0000000..aef2a7e --- /dev/null +++ b/code/26-outputting-products-for-customers/public/styles/products.css @@ -0,0 +1,35 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/routes/admin.routes.js b/code/26-outputting-products-for-customers/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/26-outputting-products-for-customers/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/routes/auth.routes.js b/code/26-outputting-products-for-customers/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/26-outputting-products-for-customers/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/routes/base.routes.js b/code/26-outputting-products-for-customers/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/26-outputting-products-for-customers/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/routes/products.routes.js b/code/26-outputting-products-for-customers/routes/products.routes.js new file mode 100644 index 0000000..11ae90d --- /dev/null +++ b/code/26-outputting-products-for-customers/routes/products.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +module.exports = router; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/util/authentication.js b/code/26-outputting-products-for-customers/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/26-outputting-products-for-customers/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/util/session-flash.js b/code/26-outputting-products-for-customers/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/26-outputting-products-for-customers/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/util/validation.js b/code/26-outputting-products-for-customers/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/26-outputting-products-for-customers/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/26-outputting-products-for-customers/views/admin/products/all-products.ejs b/code/26-outputting-products-for-customers/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/26-outputting-products-for-customers/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/admin/products/includes/product-form.ejs b/code/26-outputting-products-for-customers/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/26-outputting-products-for-customers/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/admin/products/new-product.ejs b/code/26-outputting-products-for-customers/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/26-outputting-products-for-customers/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/admin/products/update-product.ejs b/code/26-outputting-products-for-customers/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/26-outputting-products-for-customers/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/customer/auth/login.ejs b/code/26-outputting-products-for-customers/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/customer/auth/signup.ejs b/code/26-outputting-products-for-customers/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/customer/products/all-products.ejs b/code/26-outputting-products-for-customers/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/401.ejs b/code/26-outputting-products-for-customers/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/403.ejs b/code/26-outputting-products-for-customers/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/404.ejs b/code/26-outputting-products-for-customers/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/500.ejs b/code/26-outputting-products-for-customers/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/includes/footer.ejs b/code/26-outputting-products-for-customers/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/includes/head.ejs b/code/26-outputting-products-for-customers/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/26-outputting-products-for-customers/views/shared/includes/header.ejs b/code/26-outputting-products-for-customers/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/includes/nav-items.ejs b/code/26-outputting-products-for-customers/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/26-outputting-products-for-customers/views/shared/includes/product-item.ejs b/code/26-outputting-products-for-customers/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/26-outputting-products-for-customers/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/27-outputting-product-details/app.js b/code/27-outputting-product-details/app.js new file mode 100644 index 0000000..ab8ab6b --- /dev/null +++ b/code/27-outputting-product-details/app.js @@ -0,0 +1,50 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use(protectRoutesMiddleware); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/27-outputting-product-details/config/session.js b/code/27-outputting-product-details/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/27-outputting-product-details/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/27-outputting-product-details/controllers/admin.controller.js b/code/27-outputting-product-details/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/27-outputting-product-details/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/27-outputting-product-details/controllers/auth.controller.js b/code/27-outputting-product-details/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/27-outputting-product-details/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/27-outputting-product-details/controllers/products.controller.js b/code/27-outputting-product-details/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/27-outputting-product-details/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/27-outputting-product-details/data/database.js b/code/27-outputting-product-details/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/27-outputting-product-details/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/27-outputting-product-details/middlewares/check-auth.js b/code/27-outputting-product-details/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/27-outputting-product-details/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/27-outputting-product-details/middlewares/csrf-token.js b/code/27-outputting-product-details/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/27-outputting-product-details/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/27-outputting-product-details/middlewares/error-handler.js b/code/27-outputting-product-details/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/27-outputting-product-details/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/27-outputting-product-details/middlewares/image-upload.js b/code/27-outputting-product-details/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/27-outputting-product-details/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/27-outputting-product-details/middlewares/protect-routes.js b/code/27-outputting-product-details/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/27-outputting-product-details/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/27-outputting-product-details/models/product.model.js b/code/27-outputting-product-details/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/27-outputting-product-details/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/27-outputting-product-details/models/user.model.js b/code/27-outputting-product-details/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/27-outputting-product-details/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/27-outputting-product-details/package.json b/code/27-outputting-product-details/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/27-outputting-product-details/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/27-outputting-product-details/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/27-outputting-product-details/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/27-outputting-product-details/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/27-outputting-product-details/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/27-outputting-product-details/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/27-outputting-product-details/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/27-outputting-product-details/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/27-outputting-product-details/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/27-outputting-product-details/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/27-outputting-product-details/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/27-outputting-product-details/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/27-outputting-product-details/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/27-outputting-product-details/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/27-outputting-product-details/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/27-outputting-product-details/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/27-outputting-product-details/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/27-outputting-product-details/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/27-outputting-product-details/public/scripts/image-preview.js b/code/27-outputting-product-details/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/27-outputting-product-details/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/27-outputting-product-details/public/scripts/mobile.js b/code/27-outputting-product-details/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/27-outputting-product-details/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/27-outputting-product-details/public/scripts/product-management.js b/code/27-outputting-product-details/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/27-outputting-product-details/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/27-outputting-product-details/public/styles/auth.css b/code/27-outputting-product-details/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/27-outputting-product-details/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/27-outputting-product-details/public/styles/base.css b/code/27-outputting-product-details/public/styles/base.css new file mode 100644 index 0000000..14aac79 --- /dev/null +++ b/code/27-outputting-product-details/public/styles/base.css @@ -0,0 +1,107 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} \ No newline at end of file diff --git a/code/27-outputting-product-details/public/styles/forms.css b/code/27-outputting-product-details/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/27-outputting-product-details/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/27-outputting-product-details/public/styles/navigation.css b/code/27-outputting-product-details/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/27-outputting-product-details/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/27-outputting-product-details/public/styles/products.css b/code/27-outputting-product-details/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/27-outputting-product-details/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/27-outputting-product-details/routes/admin.routes.js b/code/27-outputting-product-details/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/27-outputting-product-details/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/27-outputting-product-details/routes/auth.routes.js b/code/27-outputting-product-details/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/27-outputting-product-details/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/27-outputting-product-details/routes/base.routes.js b/code/27-outputting-product-details/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/27-outputting-product-details/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/27-outputting-product-details/routes/products.routes.js b/code/27-outputting-product-details/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/27-outputting-product-details/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/27-outputting-product-details/util/authentication.js b/code/27-outputting-product-details/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/27-outputting-product-details/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/27-outputting-product-details/util/session-flash.js b/code/27-outputting-product-details/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/27-outputting-product-details/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/27-outputting-product-details/util/validation.js b/code/27-outputting-product-details/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/27-outputting-product-details/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/27-outputting-product-details/views/admin/products/all-products.ejs b/code/27-outputting-product-details/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/27-outputting-product-details/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/admin/products/includes/product-form.ejs b/code/27-outputting-product-details/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/27-outputting-product-details/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/27-outputting-product-details/views/admin/products/new-product.ejs b/code/27-outputting-product-details/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/27-outputting-product-details/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/admin/products/update-product.ejs b/code/27-outputting-product-details/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/27-outputting-product-details/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/customer/auth/login.ejs b/code/27-outputting-product-details/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/27-outputting-product-details/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/customer/auth/signup.ejs b/code/27-outputting-product-details/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/27-outputting-product-details/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/customer/products/all-products.ejs b/code/27-outputting-product-details/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/27-outputting-product-details/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/customer/products/product-details.ejs b/code/27-outputting-product-details/views/customer/products/product-details.ejs new file mode 100644 index 0000000..1ac92a6 --- /dev/null +++ b/code/27-outputting-product-details/views/customer/products/product-details.ejs @@ -0,0 +1,18 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/401.ejs b/code/27-outputting-product-details/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/27-outputting-product-details/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/403.ejs b/code/27-outputting-product-details/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/27-outputting-product-details/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/404.ejs b/code/27-outputting-product-details/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/27-outputting-product-details/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/500.ejs b/code/27-outputting-product-details/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/27-outputting-product-details/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/includes/footer.ejs b/code/27-outputting-product-details/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/27-outputting-product-details/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/includes/head.ejs b/code/27-outputting-product-details/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/27-outputting-product-details/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/27-outputting-product-details/views/shared/includes/header.ejs b/code/27-outputting-product-details/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/27-outputting-product-details/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/includes/nav-items.ejs b/code/27-outputting-product-details/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..01c8675 --- /dev/null +++ b/code/27-outputting-product-details/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/27-outputting-product-details/views/shared/includes/product-item.ejs b/code/27-outputting-product-details/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/27-outputting-product-details/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/28-adding-cart-items-via-ajax/app.js b/code/28-adding-cart-items-via-ajax/app.js new file mode 100644 index 0000000..513c38d --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/app.js @@ -0,0 +1,56 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use(protectRoutesMiddleware); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/28-adding-cart-items-via-ajax/config/session.js b/code/28-adding-cart-items-via-ajax/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/controllers/admin.controller.js b/code/28-adding-cart-items-via-ajax/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/28-adding-cart-items-via-ajax/controllers/auth.controller.js b/code/28-adding-cart-items-via-ajax/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/28-adding-cart-items-via-ajax/controllers/cart.controller.js b/code/28-adding-cart-items-via-ajax/controllers/cart.controller.js new file mode 100644 index 0000000..52da866 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/controllers/cart.controller.js @@ -0,0 +1,25 @@ +const Product = require('../models/product.model'); + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity + }); +} + +module.exports = { + addCartItem: addCartItem, +}; diff --git a/code/28-adding-cart-items-via-ajax/controllers/products.controller.js b/code/28-adding-cart-items-via-ajax/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/28-adding-cart-items-via-ajax/data/database.js b/code/28-adding-cart-items-via-ajax/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/middlewares/cart.js b/code/28-adding-cart-items-via-ajax/middlewares/cart.js new file mode 100644 index 0000000..dafe820 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/middlewares/cart.js @@ -0,0 +1,22 @@ +const Cart = require('../models/cart.model'); + +function initializeCart(req, res, next) { + let cart; + + if (!req.session.cart) { + cart = new Cart(); + } else { + const sessionCart = req.session.cart; + cart = new Cart( + sessionCart.items, + sessionCart.totalQuantity, + sessionCart.totalPrice + ); + } + + res.locals.cart = cart; + + next(); +} + +module.exports = initializeCart; diff --git a/code/28-adding-cart-items-via-ajax/middlewares/check-auth.js b/code/28-adding-cart-items-via-ajax/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/middlewares/csrf-token.js b/code/28-adding-cart-items-via-ajax/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/middlewares/error-handler.js b/code/28-adding-cart-items-via-ajax/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/middlewares/image-upload.js b/code/28-adding-cart-items-via-ajax/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/middlewares/protect-routes.js b/code/28-adding-cart-items-via-ajax/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/models/cart.model.js b/code/28-adding-cart-items-via-ajax/models/cart.model.js new file mode 100644 index 0000000..fffa221 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/models/cart.model.js @@ -0,0 +1,34 @@ +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = cartItem.quantity + 1; + cartItem.totalPrice = cartItem.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } +} + +module.exports = Cart; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/models/product.model.js b/code/28-adding-cart-items-via-ajax/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/28-adding-cart-items-via-ajax/models/user.model.js b/code/28-adding-cart-items-via-ajax/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/28-adding-cart-items-via-ajax/package.json b/code/28-adding-cart-items-via-ajax/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/28-adding-cart-items-via-ajax/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/28-adding-cart-items-via-ajax/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/28-adding-cart-items-via-ajax/public/scripts/cart-management.js b/code/28-adding-cart-items-via-ajax/public/scripts/cart-management.js new file mode 100644 index 0000000..9e1268f --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/scripts/cart-management.js @@ -0,0 +1,38 @@ +const addToCartButtonElement = document.querySelector('#product-details button'); +const cartBadgeElement = document.querySelector('.nav-items .badge'); + +async function addToCart() { + const productId = addToCartButtonElement.dataset.productid; + const csrfToken = addToCartButtonElement.dataset.csrf; + + let response; + try { + response = await fetch('/cart/items', { + method: 'POST', + body: JSON.stringify({ + productId: productId, + _csrf: csrfToken + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + const newTotalQuantity = responseData.newTotalItems; + + cartBadgeElement.textContent = newTotalQuantity; +} + +addToCartButtonElement.addEventListener('click', addToCart); \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/scripts/image-preview.js b/code/28-adding-cart-items-via-ajax/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/scripts/mobile.js b/code/28-adding-cart-items-via-ajax/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/scripts/product-management.js b/code/28-adding-cart-items-via-ajax/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/styles/auth.css b/code/28-adding-cart-items-via-ajax/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/styles/base.css b/code/28-adding-cart-items-via-ajax/public/styles/base.css new file mode 100644 index 0000000..f4da056 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/styles/base.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} + +.badge { + margin-left: var(--space-2); + padding: 0.15rem var(--space-4); + border-radius: 10rem; + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); +} \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/styles/forms.css b/code/28-adding-cart-items-via-ajax/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/28-adding-cart-items-via-ajax/public/styles/navigation.css b/code/28-adding-cart-items-via-ajax/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/public/styles/products.css b/code/28-adding-cart-items-via-ajax/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/routes/admin.routes.js b/code/28-adding-cart-items-via-ajax/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/routes/auth.routes.js b/code/28-adding-cart-items-via-ajax/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/routes/base.routes.js b/code/28-adding-cart-items-via-ajax/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/routes/cart.routes.js b/code/28-adding-cart-items-via-ajax/routes/cart.routes.js new file mode 100644 index 0000000..c60a188 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/routes/cart.routes.js @@ -0,0 +1,9 @@ +const express = require('express'); + +const cartController = require('../controllers/cart.controller'); + +const router = express.Router(); + +router.post('/items', cartController.addCartItem); // /cart/items + +module.exports = router; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/routes/products.routes.js b/code/28-adding-cart-items-via-ajax/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/util/authentication.js b/code/28-adding-cart-items-via-ajax/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/util/session-flash.js b/code/28-adding-cart-items-via-ajax/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/util/validation.js b/code/28-adding-cart-items-via-ajax/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/28-adding-cart-items-via-ajax/views/admin/products/all-products.ejs b/code/28-adding-cart-items-via-ajax/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/admin/products/includes/product-form.ejs b/code/28-adding-cart-items-via-ajax/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/admin/products/new-product.ejs b/code/28-adding-cart-items-via-ajax/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/admin/products/update-product.ejs b/code/28-adding-cart-items-via-ajax/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/customer/auth/login.ejs b/code/28-adding-cart-items-via-ajax/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/customer/auth/signup.ejs b/code/28-adding-cart-items-via-ajax/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/customer/products/all-products.ejs b/code/28-adding-cart-items-via-ajax/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/customer/products/product-details.ejs b/code/28-adding-cart-items-via-ajax/views/customer/products/product-details.ejs new file mode 100644 index 0000000..c99ca11 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/customer/products/product-details.ejs @@ -0,0 +1,19 @@ +<%- include('../../shared/includes/head', { pageTitle: product.title }) %> + + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/401.ejs b/code/28-adding-cart-items-via-ajax/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/403.ejs b/code/28-adding-cart-items-via-ajax/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/404.ejs b/code/28-adding-cart-items-via-ajax/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/500.ejs b/code/28-adding-cart-items-via-ajax/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/includes/footer.ejs b/code/28-adding-cart-items-via-ajax/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/includes/head.ejs b/code/28-adding-cart-items-via-ajax/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/28-adding-cart-items-via-ajax/views/shared/includes/header.ejs b/code/28-adding-cart-items-via-ajax/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/includes/nav-items.ejs b/code/28-adding-cart-items-via-ajax/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..6951848 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/28-adding-cart-items-via-ajax/views/shared/includes/product-item.ejs b/code/28-adding-cart-items-via-ajax/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/28-adding-cart-items-via-ajax/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/29-styling-cart-page/app.js b/code/29-styling-cart-page/app.js new file mode 100644 index 0000000..513c38d --- /dev/null +++ b/code/29-styling-cart-page/app.js @@ -0,0 +1,56 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use(protectRoutesMiddleware); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/29-styling-cart-page/config/session.js b/code/29-styling-cart-page/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/29-styling-cart-page/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/29-styling-cart-page/controllers/admin.controller.js b/code/29-styling-cart-page/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/29-styling-cart-page/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/29-styling-cart-page/controllers/auth.controller.js b/code/29-styling-cart-page/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/29-styling-cart-page/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/29-styling-cart-page/controllers/cart.controller.js b/code/29-styling-cart-page/controllers/cart.controller.js new file mode 100644 index 0000000..05d023a --- /dev/null +++ b/code/29-styling-cart-page/controllers/cart.controller.js @@ -0,0 +1,30 @@ +const Product = require('../models/product.model'); + +function getCart(req, res) { + res.render('customer/cart/cart'); +} + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity + }); +} + +module.exports = { + addCartItem: addCartItem, + getCart: getCart +}; diff --git a/code/29-styling-cart-page/controllers/products.controller.js b/code/29-styling-cart-page/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/29-styling-cart-page/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/29-styling-cart-page/data/database.js b/code/29-styling-cart-page/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/29-styling-cart-page/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/29-styling-cart-page/middlewares/cart.js b/code/29-styling-cart-page/middlewares/cart.js new file mode 100644 index 0000000..dafe820 --- /dev/null +++ b/code/29-styling-cart-page/middlewares/cart.js @@ -0,0 +1,22 @@ +const Cart = require('../models/cart.model'); + +function initializeCart(req, res, next) { + let cart; + + if (!req.session.cart) { + cart = new Cart(); + } else { + const sessionCart = req.session.cart; + cart = new Cart( + sessionCart.items, + sessionCart.totalQuantity, + sessionCart.totalPrice + ); + } + + res.locals.cart = cart; + + next(); +} + +module.exports = initializeCart; diff --git a/code/29-styling-cart-page/middlewares/check-auth.js b/code/29-styling-cart-page/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/29-styling-cart-page/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/29-styling-cart-page/middlewares/csrf-token.js b/code/29-styling-cart-page/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/29-styling-cart-page/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/29-styling-cart-page/middlewares/error-handler.js b/code/29-styling-cart-page/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/29-styling-cart-page/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/29-styling-cart-page/middlewares/image-upload.js b/code/29-styling-cart-page/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/29-styling-cart-page/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/29-styling-cart-page/middlewares/protect-routes.js b/code/29-styling-cart-page/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/29-styling-cart-page/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/29-styling-cart-page/models/cart.model.js b/code/29-styling-cart-page/models/cart.model.js new file mode 100644 index 0000000..232db3d --- /dev/null +++ b/code/29-styling-cart-page/models/cart.model.js @@ -0,0 +1,34 @@ +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = item.quantity + 1; + cartItem.totalPrice = item.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } +} + +module.exports = Cart; \ No newline at end of file diff --git a/code/29-styling-cart-page/models/product.model.js b/code/29-styling-cart-page/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/29-styling-cart-page/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/29-styling-cart-page/models/user.model.js b/code/29-styling-cart-page/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/29-styling-cart-page/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/29-styling-cart-page/package.json b/code/29-styling-cart-page/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/29-styling-cart-page/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/29-styling-cart-page/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/29-styling-cart-page/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/29-styling-cart-page/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/29-styling-cart-page/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/29-styling-cart-page/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/29-styling-cart-page/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/29-styling-cart-page/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/29-styling-cart-page/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/29-styling-cart-page/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/29-styling-cart-page/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/29-styling-cart-page/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/29-styling-cart-page/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/29-styling-cart-page/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/29-styling-cart-page/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/29-styling-cart-page/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/29-styling-cart-page/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/29-styling-cart-page/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/29-styling-cart-page/public/scripts/cart-management.js b/code/29-styling-cart-page/public/scripts/cart-management.js new file mode 100644 index 0000000..9e1268f --- /dev/null +++ b/code/29-styling-cart-page/public/scripts/cart-management.js @@ -0,0 +1,38 @@ +const addToCartButtonElement = document.querySelector('#product-details button'); +const cartBadgeElement = document.querySelector('.nav-items .badge'); + +async function addToCart() { + const productId = addToCartButtonElement.dataset.productid; + const csrfToken = addToCartButtonElement.dataset.csrf; + + let response; + try { + response = await fetch('/cart/items', { + method: 'POST', + body: JSON.stringify({ + productId: productId, + _csrf: csrfToken + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + const newTotalQuantity = responseData.newTotalItems; + + cartBadgeElement.textContent = newTotalQuantity; +} + +addToCartButtonElement.addEventListener('click', addToCart); \ No newline at end of file diff --git a/code/29-styling-cart-page/public/scripts/image-preview.js b/code/29-styling-cart-page/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/29-styling-cart-page/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/29-styling-cart-page/public/scripts/mobile.js b/code/29-styling-cart-page/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/29-styling-cart-page/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/29-styling-cart-page/public/scripts/product-management.js b/code/29-styling-cart-page/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/29-styling-cart-page/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/29-styling-cart-page/public/styles/auth.css b/code/29-styling-cart-page/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/29-styling-cart-page/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/29-styling-cart-page/public/styles/base.css b/code/29-styling-cart-page/public/styles/base.css new file mode 100644 index 0000000..f4da056 --- /dev/null +++ b/code/29-styling-cart-page/public/styles/base.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} + +.badge { + margin-left: var(--space-2); + padding: 0.15rem var(--space-4); + border-radius: 10rem; + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); +} \ No newline at end of file diff --git a/code/29-styling-cart-page/public/styles/cart.css b/code/29-styling-cart-page/public/styles/cart.css new file mode 100644 index 0000000..70ef751 --- /dev/null +++ b/code/29-styling-cart-page/public/styles/cart.css @@ -0,0 +1,50 @@ +.cart-item { + display: flex; + flex-direction: column; + background-color: var(--color-gray-700); + padding: var(--space-4); + margin: var(--space-4) 0; + border-radius: var(--border-radius-medium); +} + +.cart-item h2 { + font-size: 1rem; + margin: 0; +} + +.cart-item input { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; +} + +.cart-product-price { + font-style: italic; + color: var(--color-gray-300); +} + +#cart-total { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#cart-total p { + font-size: 1.5rem; + font-weight: bold; + color: var(--color-primary-500); +} + +@media (min-width: 48rem) { + .cart-item { + flex-direction: row; + justify-content: space-between; + } + + #cart-total { + flex-direction: row; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/code/29-styling-cart-page/public/styles/forms.css b/code/29-styling-cart-page/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/29-styling-cart-page/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/29-styling-cart-page/public/styles/navigation.css b/code/29-styling-cart-page/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/29-styling-cart-page/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/29-styling-cart-page/public/styles/products.css b/code/29-styling-cart-page/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/29-styling-cart-page/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/29-styling-cart-page/routes/admin.routes.js b/code/29-styling-cart-page/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/29-styling-cart-page/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/29-styling-cart-page/routes/auth.routes.js b/code/29-styling-cart-page/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/29-styling-cart-page/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/29-styling-cart-page/routes/base.routes.js b/code/29-styling-cart-page/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/29-styling-cart-page/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/29-styling-cart-page/routes/cart.routes.js b/code/29-styling-cart-page/routes/cart.routes.js new file mode 100644 index 0000000..1a442ac --- /dev/null +++ b/code/29-styling-cart-page/routes/cart.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const cartController = require('../controllers/cart.controller'); + +const router = express.Router(); + +router.get('/', cartController.getCart); // /cart/ + +router.post('/items', cartController.addCartItem); // /cart/items + +module.exports = router; \ No newline at end of file diff --git a/code/29-styling-cart-page/routes/products.routes.js b/code/29-styling-cart-page/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/29-styling-cart-page/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/29-styling-cart-page/util/authentication.js b/code/29-styling-cart-page/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/29-styling-cart-page/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/29-styling-cart-page/util/session-flash.js b/code/29-styling-cart-page/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/29-styling-cart-page/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/29-styling-cart-page/util/validation.js b/code/29-styling-cart-page/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/29-styling-cart-page/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/29-styling-cart-page/views/admin/products/all-products.ejs b/code/29-styling-cart-page/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/29-styling-cart-page/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/admin/products/includes/product-form.ejs b/code/29-styling-cart-page/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/29-styling-cart-page/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/29-styling-cart-page/views/admin/products/new-product.ejs b/code/29-styling-cart-page/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/29-styling-cart-page/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/admin/products/update-product.ejs b/code/29-styling-cart-page/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/29-styling-cart-page/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/customer/auth/login.ejs b/code/29-styling-cart-page/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/29-styling-cart-page/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/customer/auth/signup.ejs b/code/29-styling-cart-page/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/29-styling-cart-page/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/customer/cart/cart.ejs b/code/29-styling-cart-page/views/customer/cart/cart.ejs new file mode 100644 index 0000000..2aaa60a --- /dev/null +++ b/code/29-styling-cart-page/views/customer/cart/cart.ejs @@ -0,0 +1,21 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Your Cart' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

Your Cart

+ +
+

Total: $<%= locals.cart.totalPrice.toFixed(2) %>

+ + +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/customer/cart/includes/cart-item.ejs b/code/29-styling-cart-page/views/customer/cart/includes/cart-item.ejs new file mode 100644 index 0000000..135e513 --- /dev/null +++ b/code/29-styling-cart-page/views/customer/cart/includes/cart-item.ejs @@ -0,0 +1,11 @@ +
+
+

<%= item.product.title %>

+

$<%= item.totalPrice.toFixed(2) %> ($<%= item.product.price.toFixed(2) %>)

+
+ +
+ + +
+
\ No newline at end of file diff --git a/code/29-styling-cart-page/views/customer/products/all-products.ejs b/code/29-styling-cart-page/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/29-styling-cart-page/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/customer/products/product-details.ejs b/code/29-styling-cart-page/views/customer/products/product-details.ejs new file mode 100644 index 0000000..c99ca11 --- /dev/null +++ b/code/29-styling-cart-page/views/customer/products/product-details.ejs @@ -0,0 +1,19 @@ +<%- include('../../shared/includes/head', { pageTitle: product.title }) %> + + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/401.ejs b/code/29-styling-cart-page/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/29-styling-cart-page/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/403.ejs b/code/29-styling-cart-page/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/29-styling-cart-page/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/404.ejs b/code/29-styling-cart-page/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/29-styling-cart-page/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/500.ejs b/code/29-styling-cart-page/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/29-styling-cart-page/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/includes/footer.ejs b/code/29-styling-cart-page/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/29-styling-cart-page/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/includes/head.ejs b/code/29-styling-cart-page/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/29-styling-cart-page/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/29-styling-cart-page/views/shared/includes/header.ejs b/code/29-styling-cart-page/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/29-styling-cart-page/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/includes/nav-items.ejs b/code/29-styling-cart-page/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..6951848 --- /dev/null +++ b/code/29-styling-cart-page/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/29-styling-cart-page/views/shared/includes/product-item.ejs b/code/29-styling-cart-page/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/29-styling-cart-page/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/30-updating-dom-after-cart-update/app.js b/code/30-updating-dom-after-cart-update/app.js new file mode 100644 index 0000000..513c38d --- /dev/null +++ b/code/30-updating-dom-after-cart-update/app.js @@ -0,0 +1,56 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use(protectRoutesMiddleware); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/30-updating-dom-after-cart-update/config/session.js b/code/30-updating-dom-after-cart-update/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/30-updating-dom-after-cart-update/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/controllers/admin.controller.js b/code/30-updating-dom-after-cart-update/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/30-updating-dom-after-cart-update/controllers/auth.controller.js b/code/30-updating-dom-after-cart-update/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/30-updating-dom-after-cart-update/controllers/cart.controller.js b/code/30-updating-dom-after-cart-update/controllers/cart.controller.js new file mode 100644 index 0000000..e368169 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/controllers/cart.controller.js @@ -0,0 +1,51 @@ +const Product = require('../models/product.model'); + +function getCart(req, res) { + res.render('customer/cart/cart'); +} + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity, + }); +} + +function updateCartItem(req, res) { + const cart = res.locals.cart; + + const updatedItemData = cart.updateItem( + req.body.productId, + req.body.quantity + ); + + req.session.cart = cart; + + res.json({ + message: 'Item updated!', + updatedCartData: { + newTotalQuantity: cart.totalQuantity, + newTotalPrice: cart.totalPrice, + updatedItemPrice: updatedItemData.updatedItemPrice, + }, + }); +} + +module.exports = { + addCartItem: addCartItem, + getCart: getCart, + updateCartItem: updateCartItem, +}; diff --git a/code/30-updating-dom-after-cart-update/controllers/products.controller.js b/code/30-updating-dom-after-cart-update/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/30-updating-dom-after-cart-update/data/database.js b/code/30-updating-dom-after-cart-update/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/middlewares/cart.js b/code/30-updating-dom-after-cart-update/middlewares/cart.js new file mode 100644 index 0000000..dafe820 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/middlewares/cart.js @@ -0,0 +1,22 @@ +const Cart = require('../models/cart.model'); + +function initializeCart(req, res, next) { + let cart; + + if (!req.session.cart) { + cart = new Cart(); + } else { + const sessionCart = req.session.cart; + cart = new Cart( + sessionCart.items, + sessionCart.totalQuantity, + sessionCart.totalPrice + ); + } + + res.locals.cart = cart; + + next(); +} + +module.exports = initializeCart; diff --git a/code/30-updating-dom-after-cart-update/middlewares/check-auth.js b/code/30-updating-dom-after-cart-update/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/middlewares/csrf-token.js b/code/30-updating-dom-after-cart-update/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/middlewares/error-handler.js b/code/30-updating-dom-after-cart-update/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/middlewares/image-upload.js b/code/30-updating-dom-after-cart-update/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/middlewares/protect-routes.js b/code/30-updating-dom-after-cart-update/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/30-updating-dom-after-cart-update/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/models/cart.model.js b/code/30-updating-dom-after-cart-update/models/cart.model.js new file mode 100644 index 0000000..8fa90f6 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/models/cart.model.js @@ -0,0 +1,56 @@ +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = item.quantity + 1; + cartItem.totalPrice = item.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } + + updateItem(productId, newQuantity) { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === productId && newQuantity > 0) { + const cartItem = { ...item }; + const quantityChange = newQuantity - item.quantity; + cartItem.quantity = newQuantity; + cartItem.totalPrice = newQuantity * item.product.price; + this.items[i] = cartItem; + + this.totalQuantity = this.totalQuantity + quantityChange; + this.totalPrice += quantityChange * item.product.price; + return { updatedItemPrice: cartItem.totalPrice }; + } else if (item.product.id === productId && newQuantity <= 0) { + this.items.splice(i, 1); + this.totalQuantity = this.totalQuantity - item.quantity; + this.totalPrice -= item.totalPrice; + return { updatedItemPrice: 0 }; + } + } + } +} + +module.exports = Cart; diff --git a/code/30-updating-dom-after-cart-update/models/product.model.js b/code/30-updating-dom-after-cart-update/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/30-updating-dom-after-cart-update/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/30-updating-dom-after-cart-update/models/user.model.js b/code/30-updating-dom-after-cart-update/models/user.model.js new file mode 100644 index 0000000..99671fe --- /dev/null +++ b/code/30-updating-dom-after-cart-update/models/user.model.js @@ -0,0 +1,45 @@ +const bcrypt = require('bcryptjs'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/30-updating-dom-after-cart-update/package.json b/code/30-updating-dom-after-cart-update/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/30-updating-dom-after-cart-update/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/30-updating-dom-after-cart-update/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/30-updating-dom-after-cart-update/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/30-updating-dom-after-cart-update/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/30-updating-dom-after-cart-update/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/30-updating-dom-after-cart-update/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/30-updating-dom-after-cart-update/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/30-updating-dom-after-cart-update/public/scripts/cart-item-management.js b/code/30-updating-dom-after-cart-update/public/scripts/cart-item-management.js new file mode 100644 index 0000000..004f459 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/scripts/cart-item-management.js @@ -0,0 +1,58 @@ +const cartItemUpdateFormElements = document.querySelectorAll( + '.cart-item-management' +); +const cartTotalPriceElement = document.getElementById('cart-total-price'); +const cartBadge = document.querySelector('.nav-items .badge'); + +async function updateCartItem(event) { + event.preventDefault(); + + const form = event.target; + + const productId = form.dataset.productid; + const csrfToken = form.dataset.csrf; + const quantity = form.firstElementChild.value; + + let response; + try { + response = await fetch('/cart/items', { + method: 'PATCH', + body: JSON.stringify({ + productId: productId, + quantity: quantity, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + if (responseData.updatedCartData.updatedItemPrice === 0) { + form.parentElement.parentElement.remove(); + } else { + const cartItemTotalPriceElement = + form.parentElement.querySelector('.cart-item-price'); + cartItemTotalPriceElement.textContent = + responseData.updatedCartData.updatedItemPrice.toFixed(2); + } + + cartTotalPriceElement.textContent = + responseData.updatedCartData.newTotalPrice.toFixed(2); + + cartBadge.textContent = responseData.updatedCartData.newTotalQuantity; +} + +for (const formElement of cartItemUpdateFormElements) { + formElement.addEventListener('submit', updateCartItem); +} diff --git a/code/30-updating-dom-after-cart-update/public/scripts/cart-management.js b/code/30-updating-dom-after-cart-update/public/scripts/cart-management.js new file mode 100644 index 0000000..9e1268f --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/scripts/cart-management.js @@ -0,0 +1,38 @@ +const addToCartButtonElement = document.querySelector('#product-details button'); +const cartBadgeElement = document.querySelector('.nav-items .badge'); + +async function addToCart() { + const productId = addToCartButtonElement.dataset.productid; + const csrfToken = addToCartButtonElement.dataset.csrf; + + let response; + try { + response = await fetch('/cart/items', { + method: 'POST', + body: JSON.stringify({ + productId: productId, + _csrf: csrfToken + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + const newTotalQuantity = responseData.newTotalItems; + + cartBadgeElement.textContent = newTotalQuantity; +} + +addToCartButtonElement.addEventListener('click', addToCart); \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/scripts/image-preview.js b/code/30-updating-dom-after-cart-update/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/scripts/mobile.js b/code/30-updating-dom-after-cart-update/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/scripts/product-management.js b/code/30-updating-dom-after-cart-update/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/styles/auth.css b/code/30-updating-dom-after-cart-update/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/styles/base.css b/code/30-updating-dom-after-cart-update/public/styles/base.css new file mode 100644 index 0000000..f4da056 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/styles/base.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} + +.badge { + margin-left: var(--space-2); + padding: 0.15rem var(--space-4); + border-radius: 10rem; + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); +} \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/styles/cart.css b/code/30-updating-dom-after-cart-update/public/styles/cart.css new file mode 100644 index 0000000..70ef751 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/styles/cart.css @@ -0,0 +1,50 @@ +.cart-item { + display: flex; + flex-direction: column; + background-color: var(--color-gray-700); + padding: var(--space-4); + margin: var(--space-4) 0; + border-radius: var(--border-radius-medium); +} + +.cart-item h2 { + font-size: 1rem; + margin: 0; +} + +.cart-item input { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; +} + +.cart-product-price { + font-style: italic; + color: var(--color-gray-300); +} + +#cart-total { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#cart-total p { + font-size: 1.5rem; + font-weight: bold; + color: var(--color-primary-500); +} + +@media (min-width: 48rem) { + .cart-item { + flex-direction: row; + justify-content: space-between; + } + + #cart-total { + flex-direction: row; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/styles/forms.css b/code/30-updating-dom-after-cart-update/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/30-updating-dom-after-cart-update/public/styles/navigation.css b/code/30-updating-dom-after-cart-update/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/public/styles/products.css b/code/30-updating-dom-after-cart-update/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/routes/admin.routes.js b/code/30-updating-dom-after-cart-update/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/30-updating-dom-after-cart-update/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/routes/auth.routes.js b/code/30-updating-dom-after-cart-update/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/30-updating-dom-after-cart-update/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/routes/base.routes.js b/code/30-updating-dom-after-cart-update/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/routes/cart.routes.js b/code/30-updating-dom-after-cart-update/routes/cart.routes.js new file mode 100644 index 0000000..390f3e5 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/routes/cart.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const cartController = require('../controllers/cart.controller'); + +const router = express.Router(); + +router.get('/', cartController.getCart); // /cart/ + +router.post('/items', cartController.addCartItem); // /cart/items + +router.patch('/items', cartController.updateCartItem); + +module.exports = router; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/routes/products.routes.js b/code/30-updating-dom-after-cart-update/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/util/authentication.js b/code/30-updating-dom-after-cart-update/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/util/session-flash.js b/code/30-updating-dom-after-cart-update/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/util/validation.js b/code/30-updating-dom-after-cart-update/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/30-updating-dom-after-cart-update/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/30-updating-dom-after-cart-update/views/admin/products/all-products.ejs b/code/30-updating-dom-after-cart-update/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/admin/products/includes/product-form.ejs b/code/30-updating-dom-after-cart-update/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/admin/products/new-product.ejs b/code/30-updating-dom-after-cart-update/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/admin/products/update-product.ejs b/code/30-updating-dom-after-cart-update/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/customer/auth/login.ejs b/code/30-updating-dom-after-cart-update/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/customer/auth/signup.ejs b/code/30-updating-dom-after-cart-update/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/customer/cart/cart.ejs b/code/30-updating-dom-after-cart-update/views/customer/cart/cart.ejs new file mode 100644 index 0000000..fb36305 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/customer/cart/cart.ejs @@ -0,0 +1,22 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Your Cart' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Your Cart

+ +
+

Total: $<%= locals.cart.totalPrice.toFixed(2) %>

+ + +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/customer/cart/includes/cart-item.ejs b/code/30-updating-dom-after-cart-update/views/customer/cart/includes/cart-item.ejs new file mode 100644 index 0000000..fd37c25 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/customer/cart/includes/cart-item.ejs @@ -0,0 +1,11 @@ +
+
+

<%= item.product.title %>

+

$<%= item.totalPrice.toFixed(2) %> ($<%= item.product.price.toFixed(2) %>)

+
+ +
+ + +
+
\ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/customer/products/all-products.ejs b/code/30-updating-dom-after-cart-update/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/customer/products/product-details.ejs b/code/30-updating-dom-after-cart-update/views/customer/products/product-details.ejs new file mode 100644 index 0000000..c99ca11 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/customer/products/product-details.ejs @@ -0,0 +1,19 @@ +<%- include('../../shared/includes/head', { pageTitle: product.title }) %> + + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/401.ejs b/code/30-updating-dom-after-cart-update/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/403.ejs b/code/30-updating-dom-after-cart-update/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/404.ejs b/code/30-updating-dom-after-cart-update/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/500.ejs b/code/30-updating-dom-after-cart-update/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/includes/footer.ejs b/code/30-updating-dom-after-cart-update/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/includes/head.ejs b/code/30-updating-dom-after-cart-update/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/30-updating-dom-after-cart-update/views/shared/includes/header.ejs b/code/30-updating-dom-after-cart-update/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/includes/nav-items.ejs b/code/30-updating-dom-after-cart-update/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..6951848 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/30-updating-dom-after-cart-update/views/shared/includes/product-item.ejs b/code/30-updating-dom-after-cart-update/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/30-updating-dom-after-cart-update/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/31-saving-orders/app.js b/code/31-saving-orders/app.js new file mode 100644 index 0000000..cb1e49a --- /dev/null +++ b/code/31-saving-orders/app.js @@ -0,0 +1,58 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); +const ordersRoutes = require('./routes/orders.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use(protectRoutesMiddleware); +app.use('/orders', ordersRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/31-saving-orders/config/session.js b/code/31-saving-orders/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/31-saving-orders/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/31-saving-orders/controllers/admin.controller.js b/code/31-saving-orders/controllers/admin.controller.js new file mode 100644 index 0000000..612b891 --- /dev/null +++ b/code/31-saving-orders/controllers/admin.controller.js @@ -0,0 +1,81 @@ +const Product = require('../models/product.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, +}; diff --git a/code/31-saving-orders/controllers/auth.controller.js b/code/31-saving-orders/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/31-saving-orders/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/31-saving-orders/controllers/cart.controller.js b/code/31-saving-orders/controllers/cart.controller.js new file mode 100644 index 0000000..e368169 --- /dev/null +++ b/code/31-saving-orders/controllers/cart.controller.js @@ -0,0 +1,51 @@ +const Product = require('../models/product.model'); + +function getCart(req, res) { + res.render('customer/cart/cart'); +} + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity, + }); +} + +function updateCartItem(req, res) { + const cart = res.locals.cart; + + const updatedItemData = cart.updateItem( + req.body.productId, + req.body.quantity + ); + + req.session.cart = cart; + + res.json({ + message: 'Item updated!', + updatedCartData: { + newTotalQuantity: cart.totalQuantity, + newTotalPrice: cart.totalPrice, + updatedItemPrice: updatedItemData.updatedItemPrice, + }, + }); +} + +module.exports = { + addCartItem: addCartItem, + getCart: getCart, + updateCartItem: updateCartItem, +}; diff --git a/code/31-saving-orders/controllers/orders.controller.js b/code/31-saving-orders/controllers/orders.controller.js new file mode 100644 index 0000000..aae68b5 --- /dev/null +++ b/code/31-saving-orders/controllers/orders.controller.js @@ -0,0 +1,35 @@ +const Order = require('../models/order.model'); +const User = require('../models/user.model'); + +function getOrders(req, res) { + res.render('customer/orders/all-orders'); +} + +async function addOrder(req, res, next) { + const cart = res.locals.cart; + + let userDocument; + try { + userDocument = await User.findById(res.locals.uid); + } catch (error) { + return next(error); + } + + const order = new Order(cart, userDocument); + + try { + await order.save(); + } catch (error) { + next(error); + return; + } + + req.session.cart = null; + + res.redirect('/orders'); +} + +module.exports = { + addOrder: addOrder, + getOrders: getOrders +}; diff --git a/code/31-saving-orders/controllers/products.controller.js b/code/31-saving-orders/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/31-saving-orders/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/31-saving-orders/data/database.js b/code/31-saving-orders/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/31-saving-orders/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/31-saving-orders/middlewares/cart.js b/code/31-saving-orders/middlewares/cart.js new file mode 100644 index 0000000..dafe820 --- /dev/null +++ b/code/31-saving-orders/middlewares/cart.js @@ -0,0 +1,22 @@ +const Cart = require('../models/cart.model'); + +function initializeCart(req, res, next) { + let cart; + + if (!req.session.cart) { + cart = new Cart(); + } else { + const sessionCart = req.session.cart; + cart = new Cart( + sessionCart.items, + sessionCart.totalQuantity, + sessionCart.totalPrice + ); + } + + res.locals.cart = cart; + + next(); +} + +module.exports = initializeCart; diff --git a/code/31-saving-orders/middlewares/check-auth.js b/code/31-saving-orders/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/31-saving-orders/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/31-saving-orders/middlewares/csrf-token.js b/code/31-saving-orders/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/31-saving-orders/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/31-saving-orders/middlewares/error-handler.js b/code/31-saving-orders/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/31-saving-orders/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/31-saving-orders/middlewares/image-upload.js b/code/31-saving-orders/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/31-saving-orders/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/31-saving-orders/middlewares/protect-routes.js b/code/31-saving-orders/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/31-saving-orders/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/31-saving-orders/models/cart.model.js b/code/31-saving-orders/models/cart.model.js new file mode 100644 index 0000000..17798ea --- /dev/null +++ b/code/31-saving-orders/models/cart.model.js @@ -0,0 +1,56 @@ +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = +item.quantity + 1; + cartItem.totalPrice = item.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } + + updateItem(productId, newQuantity) { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === productId && newQuantity > 0) { + const cartItem = { ...item }; + const quantityChange = newQuantity - item.quantity; + cartItem.quantity = newQuantity; + cartItem.totalPrice = newQuantity * item.product.price; + this.items[i] = cartItem; + + this.totalQuantity = this.totalQuantity + quantityChange; + this.totalPrice += quantityChange * item.product.price; + return { updatedItemPrice: cartItem.totalPrice }; + } else if (item.product.id === productId && newQuantity <= 0) { + this.items.splice(i, 1); + this.totalQuantity = this.totalQuantity - item.quantity; + this.totalPrice -= item.totalPrice; + return { updatedItemPrice: 0 }; + } + } + } +} + +module.exports = Cart; diff --git a/code/31-saving-orders/models/order.model.js b/code/31-saving-orders/models/order.model.js new file mode 100644 index 0000000..f230b64 --- /dev/null +++ b/code/31-saving-orders/models/order.model.js @@ -0,0 +1,37 @@ +const db = require('../data/database'); + +class Order { + // Status => pending, fulfilled, cancelled + constructor(cart, userData, status = 'pending', date, orderId) { + this.productData = cart; + this.userData = userData; + this.status = status; + this.date = new Date(date); + if (this.date) { + this.formattedDate = this.date.toLocaleDateString('en-US', { + weekday: 'short', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + this.id = orderId; + } + + save() { + if (this.id) { + // Updating + } else { + const orderDocument = { + userData: this.userData, + productData: this.productData, + date: new Date(), + status: this.status, + }; + + return db.getDb().collection('orders').insertOne(orderDocument); + } + } +} + +module.exports = Order; diff --git a/code/31-saving-orders/models/product.model.js b/code/31-saving-orders/models/product.model.js new file mode 100644 index 0000000..80da3ca --- /dev/null +++ b/code/31-saving-orders/models/product.model.js @@ -0,0 +1,91 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/31-saving-orders/models/user.model.js b/code/31-saving-orders/models/user.model.js new file mode 100644 index 0000000..f6529d8 --- /dev/null +++ b/code/31-saving-orders/models/user.model.js @@ -0,0 +1,55 @@ +const bcrypt = require('bcryptjs'); +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + static findById(userId) { + const uid = new mongodb.ObjectId(userId); + + return db + .getDb() + .collection('users') + .findOne({ _id: uid }, { projection: { password: 0 } }); + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/31-saving-orders/package.json b/code/31-saving-orders/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/31-saving-orders/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/31-saving-orders/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/31-saving-orders/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/31-saving-orders/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/31-saving-orders/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/31-saving-orders/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/31-saving-orders/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/31-saving-orders/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/31-saving-orders/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/31-saving-orders/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/31-saving-orders/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/31-saving-orders/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/31-saving-orders/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/31-saving-orders/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/31-saving-orders/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/31-saving-orders/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/31-saving-orders/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/31-saving-orders/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/31-saving-orders/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/31-saving-orders/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/31-saving-orders/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/31-saving-orders/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/31-saving-orders/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/31-saving-orders/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/31-saving-orders/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/31-saving-orders/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/31-saving-orders/public/scripts/cart-item-management.js b/code/31-saving-orders/public/scripts/cart-item-management.js new file mode 100644 index 0000000..004f459 --- /dev/null +++ b/code/31-saving-orders/public/scripts/cart-item-management.js @@ -0,0 +1,58 @@ +const cartItemUpdateFormElements = document.querySelectorAll( + '.cart-item-management' +); +const cartTotalPriceElement = document.getElementById('cart-total-price'); +const cartBadge = document.querySelector('.nav-items .badge'); + +async function updateCartItem(event) { + event.preventDefault(); + + const form = event.target; + + const productId = form.dataset.productid; + const csrfToken = form.dataset.csrf; + const quantity = form.firstElementChild.value; + + let response; + try { + response = await fetch('/cart/items', { + method: 'PATCH', + body: JSON.stringify({ + productId: productId, + quantity: quantity, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + if (responseData.updatedCartData.updatedItemPrice === 0) { + form.parentElement.parentElement.remove(); + } else { + const cartItemTotalPriceElement = + form.parentElement.querySelector('.cart-item-price'); + cartItemTotalPriceElement.textContent = + responseData.updatedCartData.updatedItemPrice.toFixed(2); + } + + cartTotalPriceElement.textContent = + responseData.updatedCartData.newTotalPrice.toFixed(2); + + cartBadge.textContent = responseData.updatedCartData.newTotalQuantity; +} + +for (const formElement of cartItemUpdateFormElements) { + formElement.addEventListener('submit', updateCartItem); +} diff --git a/code/31-saving-orders/public/scripts/cart-management.js b/code/31-saving-orders/public/scripts/cart-management.js new file mode 100644 index 0000000..9e1268f --- /dev/null +++ b/code/31-saving-orders/public/scripts/cart-management.js @@ -0,0 +1,38 @@ +const addToCartButtonElement = document.querySelector('#product-details button'); +const cartBadgeElement = document.querySelector('.nav-items .badge'); + +async function addToCart() { + const productId = addToCartButtonElement.dataset.productid; + const csrfToken = addToCartButtonElement.dataset.csrf; + + let response; + try { + response = await fetch('/cart/items', { + method: 'POST', + body: JSON.stringify({ + productId: productId, + _csrf: csrfToken + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + const newTotalQuantity = responseData.newTotalItems; + + cartBadgeElement.textContent = newTotalQuantity; +} + +addToCartButtonElement.addEventListener('click', addToCart); \ No newline at end of file diff --git a/code/31-saving-orders/public/scripts/image-preview.js b/code/31-saving-orders/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/31-saving-orders/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/31-saving-orders/public/scripts/mobile.js b/code/31-saving-orders/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/31-saving-orders/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/31-saving-orders/public/scripts/product-management.js b/code/31-saving-orders/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/31-saving-orders/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/31-saving-orders/public/styles/auth.css b/code/31-saving-orders/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/31-saving-orders/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/31-saving-orders/public/styles/base.css b/code/31-saving-orders/public/styles/base.css new file mode 100644 index 0000000..f4da056 --- /dev/null +++ b/code/31-saving-orders/public/styles/base.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} + +.badge { + margin-left: var(--space-2); + padding: 0.15rem var(--space-4); + border-radius: 10rem; + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); +} \ No newline at end of file diff --git a/code/31-saving-orders/public/styles/cart.css b/code/31-saving-orders/public/styles/cart.css new file mode 100644 index 0000000..17ebcab --- /dev/null +++ b/code/31-saving-orders/public/styles/cart.css @@ -0,0 +1,55 @@ +.cart-item { + display: flex; + flex-direction: column; + background-color: var(--color-gray-700); + padding: var(--space-4); + margin: var(--space-4) 0; + border-radius: var(--border-radius-medium); +} + +.cart-item h2 { + font-size: 1rem; + margin: 0; +} + +.cart-item input { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; +} + +.cart-product-price { + font-style: italic; + color: var(--color-gray-300); +} + +#cart-total { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#cart-total p { + font-size: 1.5rem; + font-weight: bold; + color: var(--color-primary-500); +} + +#cart-total #cart-total-fallback { + font-size: 1rem; + font-weight: normal; +} + +@media (min-width: 48rem) { + .cart-item { + flex-direction: row; + justify-content: space-between; + } + + #cart-total { + flex-direction: row; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/code/31-saving-orders/public/styles/forms.css b/code/31-saving-orders/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/31-saving-orders/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/31-saving-orders/public/styles/navigation.css b/code/31-saving-orders/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/31-saving-orders/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/31-saving-orders/public/styles/products.css b/code/31-saving-orders/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/31-saving-orders/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/31-saving-orders/routes/admin.routes.js b/code/31-saving-orders/routes/admin.routes.js new file mode 100644 index 0000000..eecbd1b --- /dev/null +++ b/code/31-saving-orders/routes/admin.routes.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +module.exports = router; \ No newline at end of file diff --git a/code/31-saving-orders/routes/auth.routes.js b/code/31-saving-orders/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/31-saving-orders/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/31-saving-orders/routes/base.routes.js b/code/31-saving-orders/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/31-saving-orders/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/31-saving-orders/routes/cart.routes.js b/code/31-saving-orders/routes/cart.routes.js new file mode 100644 index 0000000..390f3e5 --- /dev/null +++ b/code/31-saving-orders/routes/cart.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const cartController = require('../controllers/cart.controller'); + +const router = express.Router(); + +router.get('/', cartController.getCart); // /cart/ + +router.post('/items', cartController.addCartItem); // /cart/items + +router.patch('/items', cartController.updateCartItem); + +module.exports = router; \ No newline at end of file diff --git a/code/31-saving-orders/routes/orders.routes.js b/code/31-saving-orders/routes/orders.routes.js new file mode 100644 index 0000000..9d29ba6 --- /dev/null +++ b/code/31-saving-orders/routes/orders.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const ordersController = require('../controllers/orders.controller'); + +const router = express.Router(); + +router.post('/', ordersController.addOrder); // /orders + +router.get('/', ordersController.getOrders); // /orders + +module.exports = router; \ No newline at end of file diff --git a/code/31-saving-orders/routes/products.routes.js b/code/31-saving-orders/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/31-saving-orders/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/31-saving-orders/util/authentication.js b/code/31-saving-orders/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/31-saving-orders/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/31-saving-orders/util/session-flash.js b/code/31-saving-orders/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/31-saving-orders/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/31-saving-orders/util/validation.js b/code/31-saving-orders/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/31-saving-orders/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/31-saving-orders/views/admin/products/all-products.ejs b/code/31-saving-orders/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/31-saving-orders/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/admin/products/includes/product-form.ejs b/code/31-saving-orders/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/31-saving-orders/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/31-saving-orders/views/admin/products/new-product.ejs b/code/31-saving-orders/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/31-saving-orders/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/admin/products/update-product.ejs b/code/31-saving-orders/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/31-saving-orders/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/customer/auth/login.ejs b/code/31-saving-orders/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/31-saving-orders/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/customer/auth/signup.ejs b/code/31-saving-orders/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/31-saving-orders/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/customer/cart/cart.ejs b/code/31-saving-orders/views/customer/cart/cart.ejs new file mode 100644 index 0000000..bc19650 --- /dev/null +++ b/code/31-saving-orders/views/customer/cart/cart.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Your Cart' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Your Cart

+ +
+

Total: $<%= locals.cart.totalPrice.toFixed(2) %>

+ + <% if (locals.isAuth) { %> +
+ + +
+ <% } else { %> +

Log in to proceed and purchase the items.

+ <% } %> +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/customer/cart/includes/cart-item.ejs b/code/31-saving-orders/views/customer/cart/includes/cart-item.ejs new file mode 100644 index 0000000..fd37c25 --- /dev/null +++ b/code/31-saving-orders/views/customer/cart/includes/cart-item.ejs @@ -0,0 +1,11 @@ +
+
+

<%= item.product.title %>

+

$<%= item.totalPrice.toFixed(2) %> ($<%= item.product.price.toFixed(2) %>)

+
+ +
+ + +
+
\ No newline at end of file diff --git a/code/31-saving-orders/views/customer/orders/all-orders.ejs b/code/31-saving-orders/views/customer/orders/all-orders.ejs new file mode 100644 index 0000000..e5e71b6 --- /dev/null +++ b/code/31-saving-orders/views/customer/orders/all-orders.ejs @@ -0,0 +1,9 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Your Orders' }) %> + + + <%- include('../../shared/includes/header') %> +
+

All Your Orders

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/customer/products/all-products.ejs b/code/31-saving-orders/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/31-saving-orders/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/customer/products/product-details.ejs b/code/31-saving-orders/views/customer/products/product-details.ejs new file mode 100644 index 0000000..c99ca11 --- /dev/null +++ b/code/31-saving-orders/views/customer/products/product-details.ejs @@ -0,0 +1,19 @@ +<%- include('../../shared/includes/head', { pageTitle: product.title }) %> + + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/401.ejs b/code/31-saving-orders/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/31-saving-orders/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/403.ejs b/code/31-saving-orders/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/31-saving-orders/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/404.ejs b/code/31-saving-orders/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/31-saving-orders/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/500.ejs b/code/31-saving-orders/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/31-saving-orders/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/includes/footer.ejs b/code/31-saving-orders/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/31-saving-orders/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/includes/head.ejs b/code/31-saving-orders/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/31-saving-orders/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/31-saving-orders/views/shared/includes/header.ejs b/code/31-saving-orders/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/31-saving-orders/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/includes/nav-items.ejs b/code/31-saving-orders/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..6951848 --- /dev/null +++ b/code/31-saving-orders/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/31-saving-orders/views/shared/includes/product-item.ejs b/code/31-saving-orders/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/31-saving-orders/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/32-added-orders-logic/app.js b/code/32-added-orders-logic/app.js new file mode 100644 index 0000000..8afc680 --- /dev/null +++ b/code/32-added-orders-logic/app.js @@ -0,0 +1,60 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const updateCartPricesMiddleware = require('./middlewares/update-cart-prices'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); +const ordersRoutes = require('./routes/orders.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); +app.use(updateCartPricesMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use(protectRoutesMiddleware); +app.use('/orders', ordersRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/32-added-orders-logic/config/session.js b/code/32-added-orders-logic/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/32-added-orders-logic/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/32-added-orders-logic/controllers/admin.controller.js b/code/32-added-orders-logic/controllers/admin.controller.js new file mode 100644 index 0000000..a38389b --- /dev/null +++ b/code/32-added-orders-logic/controllers/admin.controller.js @@ -0,0 +1,112 @@ +const Product = require('../models/product.model'); +const Order = require('../models/order.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +async function getOrders(req, res, next) { + try { + const orders = await Order.findAll(); + res.render('admin/orders/admin-orders', { + orders: orders + }); + } catch (error) { + next(error); + } +} + +async function updateOrder(req, res, next) { + const orderId = req.params.id; + const newStatus = req.body.newStatus; + + try { + const order = await Order.findById(orderId); + + order.status = newStatus; + + await order.save(); + + res.json({ message: 'Order updated', newStatus: newStatus }); + } catch (error) { + next(error); + } +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, + getOrders: getOrders, + updateOrder: updateOrder +}; diff --git a/code/32-added-orders-logic/controllers/auth.controller.js b/code/32-added-orders-logic/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/32-added-orders-logic/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/32-added-orders-logic/controllers/cart.controller.js b/code/32-added-orders-logic/controllers/cart.controller.js new file mode 100644 index 0000000..f9d1bfc --- /dev/null +++ b/code/32-added-orders-logic/controllers/cart.controller.js @@ -0,0 +1,51 @@ +const Product = require('../models/product.model'); + +async function getCart(req, res) { + res.render('customer/cart/cart'); +} + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity, + }); +} + +function updateCartItem(req, res) { + const cart = res.locals.cart; + + const updatedItemData = cart.updateItem( + req.body.productId, + +req.body.quantity + ); + + req.session.cart = cart; + + res.json({ + message: 'Item updated!', + updatedCartData: { + newTotalQuantity: cart.totalQuantity, + newTotalPrice: cart.totalPrice, + updatedItemPrice: updatedItemData.updatedItemPrice, + }, + }); +} + +module.exports = { + addCartItem: addCartItem, + getCart: getCart, + updateCartItem: updateCartItem, +}; diff --git a/code/32-added-orders-logic/controllers/orders.controller.js b/code/32-added-orders-logic/controllers/orders.controller.js new file mode 100644 index 0000000..2e051c2 --- /dev/null +++ b/code/32-added-orders-logic/controllers/orders.controller.js @@ -0,0 +1,40 @@ +const Order = require('../models/order.model'); +const User = require('../models/user.model'); + +async function getOrders(req, res) { + try { + const orders = await Order.findAllForUser(res.locals.uid); + res.render('customer/orders/all-orders', { + orders: orders, + }); + } catch (error) { + next(error); + } +} + +async function addOrder(req, res, next) { + let userDocument; + try { + userDocument = await User.findById(res.locals.uid); + } catch (error) { + return next(error); + } + + const order = new Order(cart, userDocument); + + try { + await order.save(); + } catch (error) { + next(error); + return; + } + + req.session.cart = null; + + res.redirect('/orders'); +} + +module.exports = { + addOrder: addOrder, + getOrders: getOrders, +}; diff --git a/code/32-added-orders-logic/controllers/products.controller.js b/code/32-added-orders-logic/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/32-added-orders-logic/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/32-added-orders-logic/data/database.js b/code/32-added-orders-logic/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/32-added-orders-logic/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/32-added-orders-logic/middlewares/cart.js b/code/32-added-orders-logic/middlewares/cart.js new file mode 100644 index 0000000..dafe820 --- /dev/null +++ b/code/32-added-orders-logic/middlewares/cart.js @@ -0,0 +1,22 @@ +const Cart = require('../models/cart.model'); + +function initializeCart(req, res, next) { + let cart; + + if (!req.session.cart) { + cart = new Cart(); + } else { + const sessionCart = req.session.cart; + cart = new Cart( + sessionCart.items, + sessionCart.totalQuantity, + sessionCart.totalPrice + ); + } + + res.locals.cart = cart; + + next(); +} + +module.exports = initializeCart; diff --git a/code/32-added-orders-logic/middlewares/check-auth.js b/code/32-added-orders-logic/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/32-added-orders-logic/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/32-added-orders-logic/middlewares/csrf-token.js b/code/32-added-orders-logic/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/32-added-orders-logic/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/32-added-orders-logic/middlewares/error-handler.js b/code/32-added-orders-logic/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/32-added-orders-logic/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/32-added-orders-logic/middlewares/image-upload.js b/code/32-added-orders-logic/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/32-added-orders-logic/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/32-added-orders-logic/middlewares/protect-routes.js b/code/32-added-orders-logic/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/32-added-orders-logic/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/32-added-orders-logic/middlewares/update-cart-prices.js b/code/32-added-orders-logic/middlewares/update-cart-prices.js new file mode 100644 index 0000000..9b6110f --- /dev/null +++ b/code/32-added-orders-logic/middlewares/update-cart-prices.js @@ -0,0 +1,10 @@ +async function updateCartPrices(req, res, next) { + const cart = res.locals.cart; + + await cart.updatePrices(); + + // req.session.cart = cart; + next(); +} + +module.exports = updateCartPrices; \ No newline at end of file diff --git a/code/32-added-orders-logic/models/cart.model.js b/code/32-added-orders-logic/models/cart.model.js new file mode 100644 index 0000000..be5788c --- /dev/null +++ b/code/32-added-orders-logic/models/cart.model.js @@ -0,0 +1,101 @@ +const Product = require('./product.model'); + +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + async updatePrices() { + const productIds = this.items.map(function (item) { + return item.product.id; + }); + + const products = await Product.findMultiple(productIds); + + const deletableCartItemProductIds = []; + + for (const cartItem of this.items) { + const product = products.find(function (prod) { + return prod.id === cartItem.product.id; + }); + + if (!product) { + // product was deleted! + // "schedule" for removal from cart + deletableCartItemProductIds.push(cartItem.product.id); + continue; + } + + // product was not deleted + // set product data and total price to latest price from database + cartItem.product = product; + cartItem.totalPrice = cartItem.quantity * cartItem.product.price; + } + + if (deletableCartItemProductIds.length > 0) { + this.items = this.items.filter(function (item) { + return deletableCartItemProductIds.indexOf(item.product.id) < 0; + }); + } + + // re-calculate cart totals + this.totalQuantity = 0; + this.totalPrice = 0; + + for (const item of this.items) { + this.totalQuantity = this.totalQuantity + item.quantity; + this.totalPrice = this.totalPrice + item.totalPrice; + } + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = +item.quantity + 1; + cartItem.totalPrice = item.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } + + updateItem(productId, newQuantity) { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === productId && newQuantity > 0) { + const cartItem = { ...item }; + const quantityChange = newQuantity - item.quantity; + cartItem.quantity = newQuantity; + cartItem.totalPrice = newQuantity * item.product.price; + this.items[i] = cartItem; + + this.totalQuantity = this.totalQuantity + quantityChange; + this.totalPrice += quantityChange * item.product.price; + return { updatedItemPrice: cartItem.totalPrice }; + } else if (item.product.id === productId && newQuantity <= 0) { + this.items.splice(i, 1); + this.totalQuantity = this.totalQuantity - item.quantity; + this.totalPrice -= item.totalPrice; + return { updatedItemPrice: 0 }; + } + } + } +} + +module.exports = Cart; diff --git a/code/32-added-orders-logic/models/order.model.js b/code/32-added-orders-logic/models/order.model.js new file mode 100644 index 0000000..f18b3d1 --- /dev/null +++ b/code/32-added-orders-logic/models/order.model.js @@ -0,0 +1,90 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Order { + // Status => pending, fulfilled, cancelled + constructor(cart, userData, status = 'pending', date, orderId) { + this.productData = cart; + this.userData = userData; + this.status = status; + this.date = new Date(date); + if (this.date) { + this.formattedDate = this.date.toLocaleDateString('en-US', { + weekday: 'short', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + this.id = orderId; + } + + static transformOrderDocument(orderDoc) { + return new Order( + orderDoc.productData, + orderDoc.userData, + orderDoc.status, + orderDoc.date, + orderDoc._id + ); + } + + static transformOrderDocuments(orderDocs) { + return orderDocs.map(this.transformOrderDocument); + } + + static async findAll() { + const orders = await db + .getDb() + .collection('orders') + .find() + .sort({ _id: -1 }) + .toArray(); + + return this.transformOrderDocuments(orders); + } + + static async findAllForUser(userId) { + const uid = new mongodb.ObjectId(userId); + + const orders = await db + .getDb() + .collection('orders') + .find({ 'userData._id': uid }) + .sort({ _id: -1 }) + .toArray(); + + return this.transformOrderDocuments(orders); + } + + static async findById(orderId) { + const order = await db + .getDb() + .collection('orders') + .findOne({ _id: new mongodb.ObjectId(orderId) }); + + return this.transformOrderDocument(order); + } + + save() { + if (this.id) { + const orderId = new mongodb.ObjectId(this.id); + return db + .getDb() + .collection('orders') + .updateOne({ _id: orderId }, { $set: { status: this.status } }); + } else { + const orderDocument = { + userData: this.userData, + productData: this.productData, + date: new Date(), + status: this.status, + }; + + return db.getDb().collection('orders').insertOne(orderDocument); + } + } +} + +module.exports = Order; diff --git a/code/32-added-orders-logic/models/product.model.js b/code/32-added-orders-logic/models/product.model.js new file mode 100644 index 0000000..9c4d730 --- /dev/null +++ b/code/32-added-orders-logic/models/product.model.js @@ -0,0 +1,107 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + static async findMultiple(ids) { + const productIds = ids.map(function(id) { + return new mongodb.ObjectId(id); + }) + + const products = await db + .getDb() + .collection('products') + .find({ _id: { $in: productIds } }) + .toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/32-added-orders-logic/models/user.model.js b/code/32-added-orders-logic/models/user.model.js new file mode 100644 index 0000000..f6529d8 --- /dev/null +++ b/code/32-added-orders-logic/models/user.model.js @@ -0,0 +1,55 @@ +const bcrypt = require('bcryptjs'); +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + static findById(userId) { + const uid = new mongodb.ObjectId(userId); + + return db + .getDb() + .collection('users') + .findOne({ _id: uid }, { projection: { password: 0 } }); + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/32-added-orders-logic/package.json b/code/32-added-orders-logic/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/32-added-orders-logic/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/32-added-orders-logic/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/32-added-orders-logic/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/32-added-orders-logic/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/32-added-orders-logic/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/32-added-orders-logic/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/32-added-orders-logic/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/32-added-orders-logic/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/32-added-orders-logic/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/32-added-orders-logic/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/32-added-orders-logic/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/32-added-orders-logic/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/32-added-orders-logic/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/32-added-orders-logic/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/32-added-orders-logic/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/32-added-orders-logic/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/32-added-orders-logic/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/32-added-orders-logic/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/32-added-orders-logic/public/scripts/cart-item-management.js b/code/32-added-orders-logic/public/scripts/cart-item-management.js new file mode 100644 index 0000000..004f459 --- /dev/null +++ b/code/32-added-orders-logic/public/scripts/cart-item-management.js @@ -0,0 +1,58 @@ +const cartItemUpdateFormElements = document.querySelectorAll( + '.cart-item-management' +); +const cartTotalPriceElement = document.getElementById('cart-total-price'); +const cartBadge = document.querySelector('.nav-items .badge'); + +async function updateCartItem(event) { + event.preventDefault(); + + const form = event.target; + + const productId = form.dataset.productid; + const csrfToken = form.dataset.csrf; + const quantity = form.firstElementChild.value; + + let response; + try { + response = await fetch('/cart/items', { + method: 'PATCH', + body: JSON.stringify({ + productId: productId, + quantity: quantity, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + if (responseData.updatedCartData.updatedItemPrice === 0) { + form.parentElement.parentElement.remove(); + } else { + const cartItemTotalPriceElement = + form.parentElement.querySelector('.cart-item-price'); + cartItemTotalPriceElement.textContent = + responseData.updatedCartData.updatedItemPrice.toFixed(2); + } + + cartTotalPriceElement.textContent = + responseData.updatedCartData.newTotalPrice.toFixed(2); + + cartBadge.textContent = responseData.updatedCartData.newTotalQuantity; +} + +for (const formElement of cartItemUpdateFormElements) { + formElement.addEventListener('submit', updateCartItem); +} diff --git a/code/32-added-orders-logic/public/scripts/cart-management.js b/code/32-added-orders-logic/public/scripts/cart-management.js new file mode 100644 index 0000000..9e1268f --- /dev/null +++ b/code/32-added-orders-logic/public/scripts/cart-management.js @@ -0,0 +1,38 @@ +const addToCartButtonElement = document.querySelector('#product-details button'); +const cartBadgeElement = document.querySelector('.nav-items .badge'); + +async function addToCart() { + const productId = addToCartButtonElement.dataset.productid; + const csrfToken = addToCartButtonElement.dataset.csrf; + + let response; + try { + response = await fetch('/cart/items', { + method: 'POST', + body: JSON.stringify({ + productId: productId, + _csrf: csrfToken + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + const newTotalQuantity = responseData.newTotalItems; + + cartBadgeElement.textContent = newTotalQuantity; +} + +addToCartButtonElement.addEventListener('click', addToCart); \ No newline at end of file diff --git a/code/32-added-orders-logic/public/scripts/image-preview.js b/code/32-added-orders-logic/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/32-added-orders-logic/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/32-added-orders-logic/public/scripts/mobile.js b/code/32-added-orders-logic/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/32-added-orders-logic/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/32-added-orders-logic/public/scripts/order-management.js b/code/32-added-orders-logic/public/scripts/order-management.js new file mode 100644 index 0000000..8a85ffc --- /dev/null +++ b/code/32-added-orders-logic/public/scripts/order-management.js @@ -0,0 +1,45 @@ +const updateOrderFormElements = document.querySelectorAll( + '.order-actions form' +); + +async function updateOrder(event) { + event.preventDefault(); + const form = event.target; + + const formData = new FormData(form); + const newStatus = formData.get('status'); + const orderId = formData.get('orderid'); + const csrfToken = formData.get('_csrf'); + + let response; + + try { + response = await fetch(`/admin/orders/${orderId}`, { + method: 'PATCH', + body: JSON.stringify({ + newStatus: newStatus, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong - could not update order status.'); + return; + } + + if (!response.ok) { + alert('Something went wrong - could not update order status.'); + return; + } + + const responseData = await response.json(); + + form.parentElement.parentElement.querySelector('.badge').textContent = + responseData.newStatus.toUpperCase(); +} + +for (const updateOrderFormElement of updateOrderFormElements) { + updateOrderFormElement.addEventListener('submit', updateOrder); +} diff --git a/code/32-added-orders-logic/public/scripts/product-management.js b/code/32-added-orders-logic/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/32-added-orders-logic/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/32-added-orders-logic/public/styles/auth.css b/code/32-added-orders-logic/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/32-added-orders-logic/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/32-added-orders-logic/public/styles/base.css b/code/32-added-orders-logic/public/styles/base.css new file mode 100644 index 0000000..f4da056 --- /dev/null +++ b/code/32-added-orders-logic/public/styles/base.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} + +.badge { + margin-left: var(--space-2); + padding: 0.15rem var(--space-4); + border-radius: 10rem; + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); +} \ No newline at end of file diff --git a/code/32-added-orders-logic/public/styles/cart.css b/code/32-added-orders-logic/public/styles/cart.css new file mode 100644 index 0000000..17ebcab --- /dev/null +++ b/code/32-added-orders-logic/public/styles/cart.css @@ -0,0 +1,55 @@ +.cart-item { + display: flex; + flex-direction: column; + background-color: var(--color-gray-700); + padding: var(--space-4); + margin: var(--space-4) 0; + border-radius: var(--border-radius-medium); +} + +.cart-item h2 { + font-size: 1rem; + margin: 0; +} + +.cart-item input { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; +} + +.cart-product-price { + font-style: italic; + color: var(--color-gray-300); +} + +#cart-total { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#cart-total p { + font-size: 1.5rem; + font-weight: bold; + color: var(--color-primary-500); +} + +#cart-total #cart-total-fallback { + font-size: 1rem; + font-weight: normal; +} + +@media (min-width: 48rem) { + .cart-item { + flex-direction: row; + justify-content: space-between; + } + + #cart-total { + flex-direction: row; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/code/32-added-orders-logic/public/styles/forms.css b/code/32-added-orders-logic/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/32-added-orders-logic/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/32-added-orders-logic/public/styles/navigation.css b/code/32-added-orders-logic/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/32-added-orders-logic/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/32-added-orders-logic/public/styles/orders.css b/code/32-added-orders-logic/public/styles/orders.css new file mode 100644 index 0000000..3c24e9f --- /dev/null +++ b/code/32-added-orders-logic/public/styles/orders.css @@ -0,0 +1,57 @@ + +.order-item { + background-color: var(--color-gray-400); + border-radius: var(--border-radius-small); + padding: var(--space-4); + margin: var(--space-4) 0; +} + +.order-summary { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-2); +} + +.order-summary h2, +.order-summary p { + font-size: 1.25rem; + font-weight: normal; + margin: 0; +} + +.order-item-price { + color: var(--color-primary-500); +} + +.order-item .status { + display: flex; + min-width: 15rem; +} + +.order-item .status .badge { + margin-right: var(--space-4); +} + +.order-details ul { + list-style: square; + margin-left: var(--space-8); +} + +.order-details li { + margin-bottom: var(--space-2); +} + +.order-item select { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); +} + +@media (min-width: 48rem) { + .order-summary { + flex-direction: row; + } +} \ No newline at end of file diff --git a/code/32-added-orders-logic/public/styles/products.css b/code/32-added-orders-logic/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/32-added-orders-logic/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/32-added-orders-logic/routes/admin.routes.js b/code/32-added-orders-logic/routes/admin.routes.js new file mode 100644 index 0000000..a6ae8b7 --- /dev/null +++ b/code/32-added-orders-logic/routes/admin.routes.js @@ -0,0 +1,24 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +router.get('/orders', adminController.getOrders); + +router.patch('/orders/:id', adminController.updateOrder); + +module.exports = router; \ No newline at end of file diff --git a/code/32-added-orders-logic/routes/auth.routes.js b/code/32-added-orders-logic/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/32-added-orders-logic/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/32-added-orders-logic/routes/base.routes.js b/code/32-added-orders-logic/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/32-added-orders-logic/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/32-added-orders-logic/routes/cart.routes.js b/code/32-added-orders-logic/routes/cart.routes.js new file mode 100644 index 0000000..390f3e5 --- /dev/null +++ b/code/32-added-orders-logic/routes/cart.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const cartController = require('../controllers/cart.controller'); + +const router = express.Router(); + +router.get('/', cartController.getCart); // /cart/ + +router.post('/items', cartController.addCartItem); // /cart/items + +router.patch('/items', cartController.updateCartItem); + +module.exports = router; \ No newline at end of file diff --git a/code/32-added-orders-logic/routes/orders.routes.js b/code/32-added-orders-logic/routes/orders.routes.js new file mode 100644 index 0000000..9d29ba6 --- /dev/null +++ b/code/32-added-orders-logic/routes/orders.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const ordersController = require('../controllers/orders.controller'); + +const router = express.Router(); + +router.post('/', ordersController.addOrder); // /orders + +router.get('/', ordersController.getOrders); // /orders + +module.exports = router; \ No newline at end of file diff --git a/code/32-added-orders-logic/routes/products.routes.js b/code/32-added-orders-logic/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/32-added-orders-logic/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/32-added-orders-logic/util/authentication.js b/code/32-added-orders-logic/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/32-added-orders-logic/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/32-added-orders-logic/util/session-flash.js b/code/32-added-orders-logic/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/32-added-orders-logic/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/32-added-orders-logic/util/validation.js b/code/32-added-orders-logic/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/32-added-orders-logic/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/32-added-orders-logic/views/admin/orders/admin-orders.ejs b/code/32-added-orders-logic/views/admin/orders/admin-orders.ejs new file mode 100644 index 0000000..7bc5caa --- /dev/null +++ b/code/32-added-orders-logic/views/admin/orders/admin-orders.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Orders' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+ <%- include('../../shared/includes/order-list') %> +
+ \ No newline at end of file diff --git a/code/32-added-orders-logic/views/admin/products/all-products.ejs b/code/32-added-orders-logic/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/32-added-orders-logic/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/admin/products/includes/product-form.ejs b/code/32-added-orders-logic/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/32-added-orders-logic/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/32-added-orders-logic/views/admin/products/new-product.ejs b/code/32-added-orders-logic/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/32-added-orders-logic/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/admin/products/update-product.ejs b/code/32-added-orders-logic/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/32-added-orders-logic/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/auth/login.ejs b/code/32-added-orders-logic/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/32-added-orders-logic/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/auth/signup.ejs b/code/32-added-orders-logic/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/32-added-orders-logic/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/cart/cart.ejs b/code/32-added-orders-logic/views/customer/cart/cart.ejs new file mode 100644 index 0000000..bc19650 --- /dev/null +++ b/code/32-added-orders-logic/views/customer/cart/cart.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Your Cart' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Your Cart

+ +
+

Total: $<%= locals.cart.totalPrice.toFixed(2) %>

+ + <% if (locals.isAuth) { %> +
+ + +
+ <% } else { %> +

Log in to proceed and purchase the items.

+ <% } %> +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/cart/includes/cart-item.ejs b/code/32-added-orders-logic/views/customer/cart/includes/cart-item.ejs new file mode 100644 index 0000000..fd37c25 --- /dev/null +++ b/code/32-added-orders-logic/views/customer/cart/includes/cart-item.ejs @@ -0,0 +1,11 @@ +
+
+

<%= item.product.title %>

+

$<%= item.totalPrice.toFixed(2) %> ($<%= item.product.price.toFixed(2) %>)

+
+ +
+ + +
+
\ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/orders/all-orders.ejs b/code/32-added-orders-logic/views/customer/orders/all-orders.ejs new file mode 100644 index 0000000..610c19d --- /dev/null +++ b/code/32-added-orders-logic/views/customer/orders/all-orders.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Your Orders' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Your Orders

+ <%- include('../../shared/includes/order-list') %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/products/all-products.ejs b/code/32-added-orders-logic/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/32-added-orders-logic/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/customer/products/product-details.ejs b/code/32-added-orders-logic/views/customer/products/product-details.ejs new file mode 100644 index 0000000..c99ca11 --- /dev/null +++ b/code/32-added-orders-logic/views/customer/products/product-details.ejs @@ -0,0 +1,19 @@ +<%- include('../../shared/includes/head', { pageTitle: product.title }) %> + + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/401.ejs b/code/32-added-orders-logic/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/32-added-orders-logic/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/403.ejs b/code/32-added-orders-logic/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/32-added-orders-logic/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/404.ejs b/code/32-added-orders-logic/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/32-added-orders-logic/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/500.ejs b/code/32-added-orders-logic/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/32-added-orders-logic/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/includes/footer.ejs b/code/32-added-orders-logic/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/includes/head.ejs b/code/32-added-orders-logic/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/32-added-orders-logic/views/shared/includes/header.ejs b/code/32-added-orders-logic/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/includes/nav-items.ejs b/code/32-added-orders-logic/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..6951848 --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/includes/order-item.ejs b/code/32-added-orders-logic/views/shared/includes/order-item.ejs new file mode 100644 index 0000000..fa0361e --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/order-item.ejs @@ -0,0 +1,35 @@ +
+
+

$<%= order.productData.totalPrice.toFixed(2) %> - <%= order.formattedDate %>

+

<%= order.status.toUpperCase() %>

+
+ +
+ <% if (locals.isAdmin) { %> +
+

<%= order.userData.name %>

+

<%= order.userData.address.street %> (<%= order.userData.address.postalCode %> <%= order.userData.address.city %>)

+
+ <% } %> + +
+ + <% if (locals.isAdmin) { %> +
+
+ + + + +
+
+ <% } %> +
\ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/includes/order-list.ejs b/code/32-added-orders-logic/views/shared/includes/order-list.ejs new file mode 100644 index 0000000..322a1ee --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/order-list.ejs @@ -0,0 +1,7 @@ +
    + <% for (const order of orders) { %> +
  1. + <%- include('order-item', { order: order }) %> +
  2. + <% } %> +
\ No newline at end of file diff --git a/code/32-added-orders-logic/views/shared/includes/product-item.ejs b/code/32-added-orders-logic/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/32-added-orders-logic/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/code/32-changed-files/app.js b/code/32-changed-files/app.js new file mode 100644 index 0000000..8afc680 --- /dev/null +++ b/code/32-changed-files/app.js @@ -0,0 +1,60 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const updateCartPricesMiddleware = require('./middlewares/update-cart-prices'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); +const ordersRoutes = require('./routes/orders.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); +app.use(updateCartPricesMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use(protectRoutesMiddleware); +app.use('/orders', ordersRoutes); +app.use('/admin', adminRoutes); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/32-changed-files/controllers/admin.controller.js b/code/32-changed-files/controllers/admin.controller.js new file mode 100644 index 0000000..a38389b --- /dev/null +++ b/code/32-changed-files/controllers/admin.controller.js @@ -0,0 +1,112 @@ +const Product = require('../models/product.model'); +const Order = require('../models/order.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +async function getOrders(req, res, next) { + try { + const orders = await Order.findAll(); + res.render('admin/orders/admin-orders', { + orders: orders + }); + } catch (error) { + next(error); + } +} + +async function updateOrder(req, res, next) { + const orderId = req.params.id; + const newStatus = req.body.newStatus; + + try { + const order = await Order.findById(orderId); + + order.status = newStatus; + + await order.save(); + + res.json({ message: 'Order updated', newStatus: newStatus }); + } catch (error) { + next(error); + } +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, + getOrders: getOrders, + updateOrder: updateOrder +}; diff --git a/code/32-changed-files/controllers/cart.controller.js b/code/32-changed-files/controllers/cart.controller.js new file mode 100644 index 0000000..f9d1bfc --- /dev/null +++ b/code/32-changed-files/controllers/cart.controller.js @@ -0,0 +1,51 @@ +const Product = require('../models/product.model'); + +async function getCart(req, res) { + res.render('customer/cart/cart'); +} + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity, + }); +} + +function updateCartItem(req, res) { + const cart = res.locals.cart; + + const updatedItemData = cart.updateItem( + req.body.productId, + +req.body.quantity + ); + + req.session.cart = cart; + + res.json({ + message: 'Item updated!', + updatedCartData: { + newTotalQuantity: cart.totalQuantity, + newTotalPrice: cart.totalPrice, + updatedItemPrice: updatedItemData.updatedItemPrice, + }, + }); +} + +module.exports = { + addCartItem: addCartItem, + getCart: getCart, + updateCartItem: updateCartItem, +}; diff --git a/code/32-changed-files/controllers/orders.controller.js b/code/32-changed-files/controllers/orders.controller.js new file mode 100644 index 0000000..2e051c2 --- /dev/null +++ b/code/32-changed-files/controllers/orders.controller.js @@ -0,0 +1,40 @@ +const Order = require('../models/order.model'); +const User = require('../models/user.model'); + +async function getOrders(req, res) { + try { + const orders = await Order.findAllForUser(res.locals.uid); + res.render('customer/orders/all-orders', { + orders: orders, + }); + } catch (error) { + next(error); + } +} + +async function addOrder(req, res, next) { + let userDocument; + try { + userDocument = await User.findById(res.locals.uid); + } catch (error) { + return next(error); + } + + const order = new Order(cart, userDocument); + + try { + await order.save(); + } catch (error) { + next(error); + return; + } + + req.session.cart = null; + + res.redirect('/orders'); +} + +module.exports = { + addOrder: addOrder, + getOrders: getOrders, +}; diff --git a/code/32-changed-files/middlewares/update-cart-prices.js b/code/32-changed-files/middlewares/update-cart-prices.js new file mode 100644 index 0000000..9b6110f --- /dev/null +++ b/code/32-changed-files/middlewares/update-cart-prices.js @@ -0,0 +1,10 @@ +async function updateCartPrices(req, res, next) { + const cart = res.locals.cart; + + await cart.updatePrices(); + + // req.session.cart = cart; + next(); +} + +module.exports = updateCartPrices; \ No newline at end of file diff --git a/code/32-changed-files/models/cart.model.js b/code/32-changed-files/models/cart.model.js new file mode 100644 index 0000000..be5788c --- /dev/null +++ b/code/32-changed-files/models/cart.model.js @@ -0,0 +1,101 @@ +const Product = require('./product.model'); + +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + async updatePrices() { + const productIds = this.items.map(function (item) { + return item.product.id; + }); + + const products = await Product.findMultiple(productIds); + + const deletableCartItemProductIds = []; + + for (const cartItem of this.items) { + const product = products.find(function (prod) { + return prod.id === cartItem.product.id; + }); + + if (!product) { + // product was deleted! + // "schedule" for removal from cart + deletableCartItemProductIds.push(cartItem.product.id); + continue; + } + + // product was not deleted + // set product data and total price to latest price from database + cartItem.product = product; + cartItem.totalPrice = cartItem.quantity * cartItem.product.price; + } + + if (deletableCartItemProductIds.length > 0) { + this.items = this.items.filter(function (item) { + return deletableCartItemProductIds.indexOf(item.product.id) < 0; + }); + } + + // re-calculate cart totals + this.totalQuantity = 0; + this.totalPrice = 0; + + for (const item of this.items) { + this.totalQuantity = this.totalQuantity + item.quantity; + this.totalPrice = this.totalPrice + item.totalPrice; + } + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = +item.quantity + 1; + cartItem.totalPrice = item.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } + + updateItem(productId, newQuantity) { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === productId && newQuantity > 0) { + const cartItem = { ...item }; + const quantityChange = newQuantity - item.quantity; + cartItem.quantity = newQuantity; + cartItem.totalPrice = newQuantity * item.product.price; + this.items[i] = cartItem; + + this.totalQuantity = this.totalQuantity + quantityChange; + this.totalPrice += quantityChange * item.product.price; + return { updatedItemPrice: cartItem.totalPrice }; + } else if (item.product.id === productId && newQuantity <= 0) { + this.items.splice(i, 1); + this.totalQuantity = this.totalQuantity - item.quantity; + this.totalPrice -= item.totalPrice; + return { updatedItemPrice: 0 }; + } + } + } +} + +module.exports = Cart; diff --git a/code/32-changed-files/models/order.model.js b/code/32-changed-files/models/order.model.js new file mode 100644 index 0000000..f18b3d1 --- /dev/null +++ b/code/32-changed-files/models/order.model.js @@ -0,0 +1,90 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Order { + // Status => pending, fulfilled, cancelled + constructor(cart, userData, status = 'pending', date, orderId) { + this.productData = cart; + this.userData = userData; + this.status = status; + this.date = new Date(date); + if (this.date) { + this.formattedDate = this.date.toLocaleDateString('en-US', { + weekday: 'short', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + this.id = orderId; + } + + static transformOrderDocument(orderDoc) { + return new Order( + orderDoc.productData, + orderDoc.userData, + orderDoc.status, + orderDoc.date, + orderDoc._id + ); + } + + static transformOrderDocuments(orderDocs) { + return orderDocs.map(this.transformOrderDocument); + } + + static async findAll() { + const orders = await db + .getDb() + .collection('orders') + .find() + .sort({ _id: -1 }) + .toArray(); + + return this.transformOrderDocuments(orders); + } + + static async findAllForUser(userId) { + const uid = new mongodb.ObjectId(userId); + + const orders = await db + .getDb() + .collection('orders') + .find({ 'userData._id': uid }) + .sort({ _id: -1 }) + .toArray(); + + return this.transformOrderDocuments(orders); + } + + static async findById(orderId) { + const order = await db + .getDb() + .collection('orders') + .findOne({ _id: new mongodb.ObjectId(orderId) }); + + return this.transformOrderDocument(order); + } + + save() { + if (this.id) { + const orderId = new mongodb.ObjectId(this.id); + return db + .getDb() + .collection('orders') + .updateOne({ _id: orderId }, { $set: { status: this.status } }); + } else { + const orderDocument = { + userData: this.userData, + productData: this.productData, + date: new Date(), + status: this.status, + }; + + return db.getDb().collection('orders').insertOne(orderDocument); + } + } +} + +module.exports = Order; diff --git a/code/32-changed-files/models/product.model.js b/code/32-changed-files/models/product.model.js new file mode 100644 index 0000000..9c4d730 --- /dev/null +++ b/code/32-changed-files/models/product.model.js @@ -0,0 +1,107 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + static async findMultiple(ids) { + const productIds = ids.map(function(id) { + return new mongodb.ObjectId(id); + }) + + const products = await db + .getDb() + .collection('products') + .find({ _id: { $in: productIds } }) + .toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/32-changed-files/public/scripts/order-management.js b/code/32-changed-files/public/scripts/order-management.js new file mode 100644 index 0000000..8a85ffc --- /dev/null +++ b/code/32-changed-files/public/scripts/order-management.js @@ -0,0 +1,45 @@ +const updateOrderFormElements = document.querySelectorAll( + '.order-actions form' +); + +async function updateOrder(event) { + event.preventDefault(); + const form = event.target; + + const formData = new FormData(form); + const newStatus = formData.get('status'); + const orderId = formData.get('orderid'); + const csrfToken = formData.get('_csrf'); + + let response; + + try { + response = await fetch(`/admin/orders/${orderId}`, { + method: 'PATCH', + body: JSON.stringify({ + newStatus: newStatus, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong - could not update order status.'); + return; + } + + if (!response.ok) { + alert('Something went wrong - could not update order status.'); + return; + } + + const responseData = await response.json(); + + form.parentElement.parentElement.querySelector('.badge').textContent = + responseData.newStatus.toUpperCase(); +} + +for (const updateOrderFormElement of updateOrderFormElements) { + updateOrderFormElement.addEventListener('submit', updateOrder); +} diff --git a/code/32-changed-files/public/styles/orders.css b/code/32-changed-files/public/styles/orders.css new file mode 100644 index 0000000..3c24e9f --- /dev/null +++ b/code/32-changed-files/public/styles/orders.css @@ -0,0 +1,57 @@ + +.order-item { + background-color: var(--color-gray-400); + border-radius: var(--border-radius-small); + padding: var(--space-4); + margin: var(--space-4) 0; +} + +.order-summary { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-2); +} + +.order-summary h2, +.order-summary p { + font-size: 1.25rem; + font-weight: normal; + margin: 0; +} + +.order-item-price { + color: var(--color-primary-500); +} + +.order-item .status { + display: flex; + min-width: 15rem; +} + +.order-item .status .badge { + margin-right: var(--space-4); +} + +.order-details ul { + list-style: square; + margin-left: var(--space-8); +} + +.order-details li { + margin-bottom: var(--space-2); +} + +.order-item select { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); +} + +@media (min-width: 48rem) { + .order-summary { + flex-direction: row; + } +} \ No newline at end of file diff --git a/code/32-changed-files/routes/admin.routes.js b/code/32-changed-files/routes/admin.routes.js new file mode 100644 index 0000000..a6ae8b7 --- /dev/null +++ b/code/32-changed-files/routes/admin.routes.js @@ -0,0 +1,24 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +router.get('/orders', adminController.getOrders); + +router.patch('/orders/:id', adminController.updateOrder); + +module.exports = router; \ No newline at end of file diff --git a/code/32-changed-files/views/admin/orders/admin-orders.ejs b/code/32-changed-files/views/admin/orders/admin-orders.ejs new file mode 100644 index 0000000..7bc5caa --- /dev/null +++ b/code/32-changed-files/views/admin/orders/admin-orders.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Orders' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+ <%- include('../../shared/includes/order-list') %> +
+ \ No newline at end of file diff --git a/code/32-changed-files/views/customer/orders/all-orders.ejs b/code/32-changed-files/views/customer/orders/all-orders.ejs new file mode 100644 index 0000000..610c19d --- /dev/null +++ b/code/32-changed-files/views/customer/orders/all-orders.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Your Orders' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Your Orders

+ <%- include('../../shared/includes/order-list') %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/32-changed-files/views/shared/includes/order-item.ejs b/code/32-changed-files/views/shared/includes/order-item.ejs new file mode 100644 index 0000000..fa0361e --- /dev/null +++ b/code/32-changed-files/views/shared/includes/order-item.ejs @@ -0,0 +1,35 @@ +
+
+

$<%= order.productData.totalPrice.toFixed(2) %> - <%= order.formattedDate %>

+

<%= order.status.toUpperCase() %>

+
+ +
+ <% if (locals.isAdmin) { %> +
+

<%= order.userData.name %>

+

<%= order.userData.address.street %> (<%= order.userData.address.postalCode %> <%= order.userData.address.city %>)

+
+ <% } %> + +
+ + <% if (locals.isAdmin) { %> +
+
+ + + + +
+
+ <% } %> +
\ No newline at end of file diff --git a/code/32-changed-files/views/shared/includes/order-list.ejs b/code/32-changed-files/views/shared/includes/order-list.ejs new file mode 100644 index 0000000..322a1ee --- /dev/null +++ b/code/32-changed-files/views/shared/includes/order-list.ejs @@ -0,0 +1,7 @@ +
    + <% for (const order of orders) { %> +
  1. + <%- include('order-item', { order: order }) %> +
  2. + <% } %> +
\ No newline at end of file diff --git a/code/33-finished/app.js b/code/33-finished/app.js new file mode 100644 index 0000000..8520238 --- /dev/null +++ b/code/33-finished/app.js @@ -0,0 +1,62 @@ +const path = require('path'); + +const express = require('express'); +const csrf = require('csurf'); +const expressSession = require('express-session'); + +const createSessionConfig = require('./config/session'); +const db = require('./data/database'); +const addCsrfTokenMiddleware = require('./middlewares/csrf-token'); +const errorHandlerMiddleware = require('./middlewares/error-handler'); +const checkAuthStatusMiddleware = require('./middlewares/check-auth'); +const protectRoutesMiddleware = require('./middlewares/protect-routes'); +const cartMiddleware = require('./middlewares/cart'); +const updateCartPricesMiddleware = require('./middlewares/update-cart-prices'); +const notFoundMiddleware = require('./middlewares/not-found'); +const authRoutes = require('./routes/auth.routes'); +const productsRoutes = require('./routes/products.routes'); +const baseRoutes = require('./routes/base.routes'); +const adminRoutes = require('./routes/admin.routes'); +const cartRoutes = require('./routes/cart.routes'); +const ordersRoutes = require('./routes/orders.routes'); + +const app = express(); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static('public')); +app.use('/products/assets', express.static('product-data')); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const sessionConfig = createSessionConfig(); + +app.use(expressSession(sessionConfig)); +app.use(csrf()); + +app.use(cartMiddleware); +app.use(updateCartPricesMiddleware); + +app.use(addCsrfTokenMiddleware); +app.use(checkAuthStatusMiddleware); + +app.use(baseRoutes); +app.use(authRoutes); +app.use(productsRoutes); +app.use('/cart', cartRoutes); +app.use('/orders', protectRoutesMiddleware, ordersRoutes); +app.use('/admin', protectRoutesMiddleware, adminRoutes); + +app.use(notFoundMiddleware); + +app.use(errorHandlerMiddleware); + +db.connectToDatabase() + .then(function () { + app.listen(3000); + }) + .catch(function (error) { + console.log('Failed to connect to the database!'); + console.log(error); + }); diff --git a/code/33-finished/config/session.js b/code/33-finished/config/session.js new file mode 100644 index 0000000..4f9880d --- /dev/null +++ b/code/33-finished/config/session.js @@ -0,0 +1,28 @@ +const expressSession = require('express-session'); +const mongoDbStore = require('connect-mongodb-session'); + +function createSessionStore() { + const MongoDBStore = mongoDbStore(expressSession); + + const store = new MongoDBStore({ + uri: 'mongodb://127.0.0.1:27017', + databaseName: 'online-shop', + collection: 'sessions' + }); + + return store; +} + +function createSessionConfig() { + return { + secret: 'super-secret', + resave: false, + saveUninitialized: false, + store: createSessionStore(), + cookie: { + maxAge: 2 * 24 * 60 * 60 * 1000 + } + }; +} + +module.exports = createSessionConfig; \ No newline at end of file diff --git a/code/33-finished/controllers/admin.controller.js b/code/33-finished/controllers/admin.controller.js new file mode 100644 index 0000000..a38389b --- /dev/null +++ b/code/33-finished/controllers/admin.controller.js @@ -0,0 +1,112 @@ +const Product = require('../models/product.model'); +const Order = require('../models/order.model'); + +async function getProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('admin/products/all-products', { products: products }); + } catch (error) { + next(error); + return; + } +} + +function getNewProduct(req, res) { + res.render('admin/products/new-product'); +} + +async function createNewProduct(req, res, next) { + const product = new Product({ + ...req.body, + image: req.file.filename, + }); + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function getUpdateProduct(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('admin/products/update-product', { product: product }); + } catch (error) { + next(error); + } +} + +async function updateProduct(req, res, next) { + const product = new Product({ + ...req.body, + _id: req.params.id, + }); + + if (req.file) { + product.replaceImage(req.file.filename); + } + + try { + await product.save(); + } catch (error) { + next(error); + return; + } + + res.redirect('/admin/products'); +} + +async function deleteProduct(req, res, next) { + let product; + try { + product = await Product.findById(req.params.id); + await product.remove(); + } catch (error) { + return next(error); + } + + res.json({ message: 'Deleted product!' }); +} + +async function getOrders(req, res, next) { + try { + const orders = await Order.findAll(); + res.render('admin/orders/admin-orders', { + orders: orders + }); + } catch (error) { + next(error); + } +} + +async function updateOrder(req, res, next) { + const orderId = req.params.id; + const newStatus = req.body.newStatus; + + try { + const order = await Order.findById(orderId); + + order.status = newStatus; + + await order.save(); + + res.json({ message: 'Order updated', newStatus: newStatus }); + } catch (error) { + next(error); + } +} + +module.exports = { + getProducts: getProducts, + getNewProduct: getNewProduct, + createNewProduct: createNewProduct, + getUpdateProduct: getUpdateProduct, + updateProduct: updateProduct, + deleteProduct: deleteProduct, + getOrders: getOrders, + updateOrder: updateOrder +}; diff --git a/code/33-finished/controllers/auth.controller.js b/code/33-finished/controllers/auth.controller.js new file mode 100644 index 0000000..2d03323 --- /dev/null +++ b/code/33-finished/controllers/auth.controller.js @@ -0,0 +1,159 @@ +const User = require('../models/user.model'); +const authUtil = require('../util/authentication'); +const validation = require('../util/validation'); +const sessionFlash = require('../util/session-flash'); + +function getSignup(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + confirmEmail: '', + password: '', + fullname: '', + street: '', + postal: '', + city: '', + }; + } + + res.render('customer/auth/signup', { inputData: sessionData }); +} + +async function signup(req, res, next) { + const enteredData = { + email: req.body.email, + confirmEmail: req.body['confirm-email'], + password: req.body.password, + fullname: req.body.fullname, + street: req.body.street, + postal: req.body.postal, + city: req.body.city, + }; + + if ( + !validation.userDetailsAreValid( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ) || + !validation.emailIsConfirmed(req.body.email, req.body['confirm-email']) + ) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: + 'Please check your input. Password must be at least 6 character slong, postal code must be 5 characters long.', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + const user = new User( + req.body.email, + req.body.password, + req.body.fullname, + req.body.street, + req.body.postal, + req.body.city + ); + + try { + const existsAlready = await user.existsAlready(); + + if (existsAlready) { + sessionFlash.flashDataToSession( + req, + { + errorMessage: 'User exists already! Try logging in instead!', + ...enteredData, + }, + function () { + res.redirect('/signup'); + } + ); + return; + } + + await user.signup(); + } catch (error) { + next(error); + return; + } + + res.redirect('/login'); +} + +function getLogin(req, res) { + let sessionData = sessionFlash.getSessionData(req); + + if (!sessionData) { + sessionData = { + email: '', + password: '', + }; + } + + res.render('customer/auth/login', { inputData: sessionData }); +} + +async function login(req, res, next) { + const user = new User(req.body.email, req.body.password); + let existingUser; + try { + existingUser = await user.getUserWithSameEmail(); + } catch (error) { + next(error); + return; + } + + const sessionErrorData = { + errorMessage: + 'Invalid credentials - please double-check your email and password!', + email: user.email, + password: user.password, + }; + + if (!existingUser) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + const passwordIsCorrect = await user.hasMatchingPassword( + existingUser.password + ); + + if (!passwordIsCorrect) { + sessionFlash.flashDataToSession(req, sessionErrorData, function () { + res.redirect('/login'); + }); + return; + } + + authUtil.createUserSession(req, existingUser, function () { + res.redirect('/'); + }); +} + +function logout(req, res) { + authUtil.destroyUserAuthSession(req); + res.redirect('/login'); +} + +module.exports = { + getSignup: getSignup, + getLogin: getLogin, + signup: signup, + login: login, + logout: logout, +}; diff --git a/code/33-finished/controllers/cart.controller.js b/code/33-finished/controllers/cart.controller.js new file mode 100644 index 0000000..f9d1bfc --- /dev/null +++ b/code/33-finished/controllers/cart.controller.js @@ -0,0 +1,51 @@ +const Product = require('../models/product.model'); + +async function getCart(req, res) { + res.render('customer/cart/cart'); +} + +async function addCartItem(req, res, next) { + let product; + try { + product = await Product.findById(req.body.productId); + } catch (error) { + next(error); + return; + } + + const cart = res.locals.cart; + + cart.addItem(product); + req.session.cart = cart; + + res.status(201).json({ + message: 'Cart updated!', + newTotalItems: cart.totalQuantity, + }); +} + +function updateCartItem(req, res) { + const cart = res.locals.cart; + + const updatedItemData = cart.updateItem( + req.body.productId, + +req.body.quantity + ); + + req.session.cart = cart; + + res.json({ + message: 'Item updated!', + updatedCartData: { + newTotalQuantity: cart.totalQuantity, + newTotalPrice: cart.totalPrice, + updatedItemPrice: updatedItemData.updatedItemPrice, + }, + }); +} + +module.exports = { + addCartItem: addCartItem, + getCart: getCart, + updateCartItem: updateCartItem, +}; diff --git a/code/33-finished/controllers/orders.controller.js b/code/33-finished/controllers/orders.controller.js new file mode 100644 index 0000000..f448eec --- /dev/null +++ b/code/33-finished/controllers/orders.controller.js @@ -0,0 +1,42 @@ +const Order = require('../models/order.model'); +const User = require('../models/user.model'); + +async function getOrders(req, res) { + try { + const orders = await Order.findAllForUser(res.locals.uid); + res.render('customer/orders/all-orders', { + orders: orders + }); + } catch (error) { + next(error); + } +} + +async function addOrder(req, res, next) { + const cart = res.locals.cart; + + let userDocument; + try { + userDocument = await User.findById(res.locals.uid); + } catch (error) { + return next(error); + } + + const order = new Order(cart, userDocument); + + try { + await order.save(); + } catch (error) { + next(error); + return; + } + + req.session.cart = null; + + res.redirect('/orders'); +} + +module.exports = { + addOrder: addOrder, + getOrders: getOrders, +}; diff --git a/code/33-finished/controllers/products.controller.js b/code/33-finished/controllers/products.controller.js new file mode 100644 index 0000000..bfb8b97 --- /dev/null +++ b/code/33-finished/controllers/products.controller.js @@ -0,0 +1,24 @@ +const Product = require('../models/product.model'); + +async function getAllProducts(req, res, next) { + try { + const products = await Product.findAll(); + res.render('customer/products/all-products', { products: products }); + } catch (error) { + next(error); + } +} + +async function getProductDetails(req, res, next) { + try { + const product = await Product.findById(req.params.id); + res.render('customer/products/product-details', { product: product }); + } catch (error) { + next(error); + } +} + +module.exports = { + getAllProducts: getAllProducts, + getProductDetails: getProductDetails +}; diff --git a/code/33-finished/data/database.js b/code/33-finished/data/database.js new file mode 100644 index 0000000..c393ac0 --- /dev/null +++ b/code/33-finished/data/database.js @@ -0,0 +1,23 @@ +const mongodb = require('mongodb'); + +const MongoClient = mongodb.MongoClient; + +let database; + +async function connectToDatabase() { + const client = await MongoClient.connect('mongodb://127.0.0.1:27017'); + database = client.db('online-shop'); +} + +function getDb() { + if (!database) { + throw new Error('You must connect first!'); + } + + return database; +} + +module.exports = { + connectToDatabase: connectToDatabase, + getDb: getDb +}; \ No newline at end of file diff --git a/code/33-finished/middlewares/cart.js b/code/33-finished/middlewares/cart.js new file mode 100644 index 0000000..dafe820 --- /dev/null +++ b/code/33-finished/middlewares/cart.js @@ -0,0 +1,22 @@ +const Cart = require('../models/cart.model'); + +function initializeCart(req, res, next) { + let cart; + + if (!req.session.cart) { + cart = new Cart(); + } else { + const sessionCart = req.session.cart; + cart = new Cart( + sessionCart.items, + sessionCart.totalQuantity, + sessionCart.totalPrice + ); + } + + res.locals.cart = cart; + + next(); +} + +module.exports = initializeCart; diff --git a/code/33-finished/middlewares/check-auth.js b/code/33-finished/middlewares/check-auth.js new file mode 100644 index 0000000..2b7bc70 --- /dev/null +++ b/code/33-finished/middlewares/check-auth.js @@ -0,0 +1,14 @@ +function checkAuthStatus(req, res, next) { + const uid = req.session.uid; + + if (!uid) { + return next(); + } + + res.locals.uid = uid; + res.locals.isAuth = true; + res.locals.isAdmin = req.session.isAdmin; + next(); +} + +module.exports = checkAuthStatus; \ No newline at end of file diff --git a/code/33-finished/middlewares/csrf-token.js b/code/33-finished/middlewares/csrf-token.js new file mode 100644 index 0000000..8ab7639 --- /dev/null +++ b/code/33-finished/middlewares/csrf-token.js @@ -0,0 +1,6 @@ +function addCsrfToken(req, res, next) { + res.locals.csrfToken = req.csrfToken(); + next(); +} + +module.exports = addCsrfToken; \ No newline at end of file diff --git a/code/33-finished/middlewares/error-handler.js b/code/33-finished/middlewares/error-handler.js new file mode 100644 index 0000000..2565763 --- /dev/null +++ b/code/33-finished/middlewares/error-handler.js @@ -0,0 +1,11 @@ +function handleErrors(error, req, res, next) { + console.log(error); + + if (error.code === 404) { + return res.status(404).render('shared/404'); + } + + res.status(500).render('shared/500'); +} + +module.exports = handleErrors; \ No newline at end of file diff --git a/code/33-finished/middlewares/image-upload.js b/code/33-finished/middlewares/image-upload.js new file mode 100644 index 0000000..e7faa47 --- /dev/null +++ b/code/33-finished/middlewares/image-upload.js @@ -0,0 +1,15 @@ +const multer = require('multer'); +const uuid = require('uuid').v4; + +const upload = multer({ + storage: multer.diskStorage({ + destination: 'product-data/images', + filename: function(req, file, cb) { + cb(null, uuid() + '-' + file.originalname); + } + }) +}); + +const configuredMulterMiddleware = upload.single('image'); + +module.exports = configuredMulterMiddleware; \ No newline at end of file diff --git a/code/33-finished/middlewares/not-found.js b/code/33-finished/middlewares/not-found.js new file mode 100644 index 0000000..3d1f2c8 --- /dev/null +++ b/code/33-finished/middlewares/not-found.js @@ -0,0 +1,5 @@ +function notFoundHandler(req, res) { + res.render('shared/404'); +} + +module.exports = notFoundHandler; \ No newline at end of file diff --git a/code/33-finished/middlewares/protect-routes.js b/code/33-finished/middlewares/protect-routes.js new file mode 100644 index 0000000..b991e5e --- /dev/null +++ b/code/33-finished/middlewares/protect-routes.js @@ -0,0 +1,13 @@ +function protectRoutes(req, res, next) { + if (!res.locals.isAuth) { + return res.redirect('/401'); + } + + if (req.path.startsWith('/admin') && !res.locals.isAdmin) { + return res.redirect('/403'); + } + + next(); +} + +module.exports = protectRoutes; \ No newline at end of file diff --git a/code/33-finished/middlewares/update-cart-prices.js b/code/33-finished/middlewares/update-cart-prices.js new file mode 100644 index 0000000..9b6110f --- /dev/null +++ b/code/33-finished/middlewares/update-cart-prices.js @@ -0,0 +1,10 @@ +async function updateCartPrices(req, res, next) { + const cart = res.locals.cart; + + await cart.updatePrices(); + + // req.session.cart = cart; + next(); +} + +module.exports = updateCartPrices; \ No newline at end of file diff --git a/code/33-finished/models/cart.model.js b/code/33-finished/models/cart.model.js new file mode 100644 index 0000000..be5788c --- /dev/null +++ b/code/33-finished/models/cart.model.js @@ -0,0 +1,101 @@ +const Product = require('./product.model'); + +class Cart { + constructor(items = [], totalQuantity = 0, totalPrice = 0) { + this.items = items; + this.totalQuantity = totalQuantity; + this.totalPrice = totalPrice; + } + + async updatePrices() { + const productIds = this.items.map(function (item) { + return item.product.id; + }); + + const products = await Product.findMultiple(productIds); + + const deletableCartItemProductIds = []; + + for (const cartItem of this.items) { + const product = products.find(function (prod) { + return prod.id === cartItem.product.id; + }); + + if (!product) { + // product was deleted! + // "schedule" for removal from cart + deletableCartItemProductIds.push(cartItem.product.id); + continue; + } + + // product was not deleted + // set product data and total price to latest price from database + cartItem.product = product; + cartItem.totalPrice = cartItem.quantity * cartItem.product.price; + } + + if (deletableCartItemProductIds.length > 0) { + this.items = this.items.filter(function (item) { + return deletableCartItemProductIds.indexOf(item.product.id) < 0; + }); + } + + // re-calculate cart totals + this.totalQuantity = 0; + this.totalPrice = 0; + + for (const item of this.items) { + this.totalQuantity = this.totalQuantity + item.quantity; + this.totalPrice = this.totalPrice + item.totalPrice; + } + } + + addItem(product) { + const cartItem = { + product: product, + quantity: 1, + totalPrice: product.price, + }; + + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === product.id) { + cartItem.quantity = +item.quantity + 1; + cartItem.totalPrice = item.totalPrice + product.price; + this.items[i] = cartItem; + + this.totalQuantity++; + this.totalPrice += product.price; + return; + } + } + + this.items.push(cartItem); + this.totalQuantity++; + this.totalPrice += product.price; + } + + updateItem(productId, newQuantity) { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + if (item.product.id === productId && newQuantity > 0) { + const cartItem = { ...item }; + const quantityChange = newQuantity - item.quantity; + cartItem.quantity = newQuantity; + cartItem.totalPrice = newQuantity * item.product.price; + this.items[i] = cartItem; + + this.totalQuantity = this.totalQuantity + quantityChange; + this.totalPrice += quantityChange * item.product.price; + return { updatedItemPrice: cartItem.totalPrice }; + } else if (item.product.id === productId && newQuantity <= 0) { + this.items.splice(i, 1); + this.totalQuantity = this.totalQuantity - item.quantity; + this.totalPrice -= item.totalPrice; + return { updatedItemPrice: 0 }; + } + } + } +} + +module.exports = Cart; diff --git a/code/33-finished/models/order.model.js b/code/33-finished/models/order.model.js new file mode 100644 index 0000000..f18b3d1 --- /dev/null +++ b/code/33-finished/models/order.model.js @@ -0,0 +1,90 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Order { + // Status => pending, fulfilled, cancelled + constructor(cart, userData, status = 'pending', date, orderId) { + this.productData = cart; + this.userData = userData; + this.status = status; + this.date = new Date(date); + if (this.date) { + this.formattedDate = this.date.toLocaleDateString('en-US', { + weekday: 'short', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + this.id = orderId; + } + + static transformOrderDocument(orderDoc) { + return new Order( + orderDoc.productData, + orderDoc.userData, + orderDoc.status, + orderDoc.date, + orderDoc._id + ); + } + + static transformOrderDocuments(orderDocs) { + return orderDocs.map(this.transformOrderDocument); + } + + static async findAll() { + const orders = await db + .getDb() + .collection('orders') + .find() + .sort({ _id: -1 }) + .toArray(); + + return this.transformOrderDocuments(orders); + } + + static async findAllForUser(userId) { + const uid = new mongodb.ObjectId(userId); + + const orders = await db + .getDb() + .collection('orders') + .find({ 'userData._id': uid }) + .sort({ _id: -1 }) + .toArray(); + + return this.transformOrderDocuments(orders); + } + + static async findById(orderId) { + const order = await db + .getDb() + .collection('orders') + .findOne({ _id: new mongodb.ObjectId(orderId) }); + + return this.transformOrderDocument(order); + } + + save() { + if (this.id) { + const orderId = new mongodb.ObjectId(this.id); + return db + .getDb() + .collection('orders') + .updateOne({ _id: orderId }, { $set: { status: this.status } }); + } else { + const orderDocument = { + userData: this.userData, + productData: this.productData, + date: new Date(), + status: this.status, + }; + + return db.getDb().collection('orders').insertOne(orderDocument); + } + } +} + +module.exports = Order; diff --git a/code/33-finished/models/product.model.js b/code/33-finished/models/product.model.js new file mode 100644 index 0000000..9c4d730 --- /dev/null +++ b/code/33-finished/models/product.model.js @@ -0,0 +1,107 @@ +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class Product { + constructor(productData) { + this.title = productData.title; + this.summary = productData.summary; + this.price = +productData.price; + this.description = productData.description; + this.image = productData.image; // the name of the image file + this.updateImageData(); + if (productData._id) { + this.id = productData._id.toString(); + } + } + + static async findById(productId) { + let prodId; + try { + prodId = new mongodb.ObjectId(productId); + } catch (error) { + error.code = 404; + throw error; + } + const product = await db + .getDb() + .collection('products') + .findOne({ _id: prodId }); + + if (!product) { + const error = new Error('Could not find product with provided id.'); + error.code = 404; + throw error; + } + + return new Product(product); + } + + static async findAll() { + const products = await db.getDb().collection('products').find().toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + static async findMultiple(ids) { + const productIds = ids.map(function(id) { + return new mongodb.ObjectId(id); + }) + + const products = await db + .getDb() + .collection('products') + .find({ _id: { $in: productIds } }) + .toArray(); + + return products.map(function (productDocument) { + return new Product(productDocument); + }); + } + + updateImageData() { + this.imagePath = `product-data/images/${this.image}`; + this.imageUrl = `/products/assets/images/${this.image}`; + } + + async save() { + const productData = { + title: this.title, + summary: this.summary, + price: this.price, + description: this.description, + image: this.image, + }; + + if (this.id) { + const productId = new mongodb.ObjectId(this.id); + + if (!this.image) { + delete productData.image; + } + + await db.getDb().collection('products').updateOne( + { _id: productId }, + { + $set: productData, + } + ); + } else { + await db.getDb().collection('products').insertOne(productData); + } + } + + replaceImage(newImage) { + this.image = newImage; + this.updateImageData(); + } + + remove() { + const productId = new mongodb.ObjectId(this.id); + return db.getDb().collection('products').deleteOne({ _id: productId }); + } +} + +module.exports = Product; diff --git a/code/33-finished/models/user.model.js b/code/33-finished/models/user.model.js new file mode 100644 index 0000000..f6529d8 --- /dev/null +++ b/code/33-finished/models/user.model.js @@ -0,0 +1,55 @@ +const bcrypt = require('bcryptjs'); +const mongodb = require('mongodb'); + +const db = require('../data/database'); + +class User { + constructor(email, password, fullname, street, postal, city) { + this.email = email; + this.password = password; + this.name = fullname; + this.address = { + street: street, + postalCode: postal, + city: city, + }; + } + + static findById(userId) { + const uid = new mongodb.ObjectId(userId); + + return db + .getDb() + .collection('users') + .findOne({ _id: uid }, { projection: { password: 0 } }); + } + + getUserWithSameEmail() { + return db.getDb().collection('users').findOne({ email: this.email }); + } + + async existsAlready() { + const existingUser = await this.getUserWithSameEmail(); + if (existingUser) { + return true; + } + return false; + } + + async signup() { + const hashedPassword = await bcrypt.hash(this.password, 12); + + await db.getDb().collection('users').insertOne({ + email: this.email, + password: hashedPassword, + name: this.name, + address: this.address, + }); + } + + hasMatchingPassword(hashedPassword) { + return bcrypt.compare(this.password, hashedPassword); + } +} + +module.exports = User; diff --git a/code/33-finished/package.json b/code/33-finished/package.json new file mode 100644 index 0000000..ec18f57 --- /dev/null +++ b/code/33-finished/package.json @@ -0,0 +1,26 @@ +{ + "name": "web-dev-complete-guide", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "nodemon app.js" + }, + "keywords": [], + "author": "Maximilian Schwarzmüller", + "license": "ISC", + "dependencies": { + "bcryptjs": "^2.4.3", + "connect-mongodb-session": "^3.0.0", + "csurf": "^1.11.0", + "ejs": "^3.1.6", + "express": "^4.17.1", + "express-session": "^1.17.2", + "mongodb": "^4.1.0", + "multer": "^1.4.3", + "uuid": "^8.3.2" + }, + "devDependencies": { + "nodemon": "^2.0.12" + } +} diff --git a/code/33-finished/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg b/code/33-finished/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/024f5b77-7c3c-4a88-8d03-82ab9f2678e2-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg b/code/33-finished/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/133e45dc-9204-4930-8df9-5a0243be32bc-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg b/code/33-finished/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/1c7f6b77-c3ca-4fbe-9dff-66b24e926aa4-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg b/code/33-finished/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/31c5f7e6-6deb-4189-b54c-f86367abbc89-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg b/code/33-finished/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/33-finished/product-data/images/3e641b91-4a3b-4ab0-8181-19b019632fe0-trackpad.jpg differ diff --git a/code/33-finished/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg b/code/33-finished/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/33-finished/product-data/images/485d6421-e298-40ef-8ba2-e93f7fb4dbe5-trackpad.jpg differ diff --git a/code/33-finished/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg b/code/33-finished/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/33-finished/product-data/images/6820e095-a21d-486e-9b97-29fd325d4b8b-trackpad.jpg differ diff --git a/code/33-finished/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg b/code/33-finished/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/6946a319-02e2-4256-8971-595304e2ff70-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/7ce6744f-d1e1-4646-81e9-cabf5df3f4d6-keyboard.jpg b/code/33-finished/product-data/images/7ce6744f-d1e1-4646-81e9-cabf5df3f4d6-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/7ce6744f-d1e1-4646-81e9-cabf5df3f4d6-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg b/code/33-finished/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/90b9084e-d620-4781-a988-93b32b664aef-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg b/code/33-finished/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/a38d019e-a0c5-4571-8e41-b5e378472e50-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg b/code/33-finished/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg new file mode 100644 index 0000000..515daa0 Binary files /dev/null and b/code/33-finished/product-data/images/a69c7e8a-6f16-4135-8f79-49c30bfce89a-trackpad.jpg differ diff --git a/code/33-finished/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg b/code/33-finished/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/adc1d634-fbb4-49c7-8385-a4761fcf69fe-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg b/code/33-finished/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/cb0f0c94-0834-4858-9892-54acf1ba8056-keyboard.jpg differ diff --git a/code/33-finished/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg b/code/33-finished/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg new file mode 100644 index 0000000..b8284de Binary files /dev/null and b/code/33-finished/product-data/images/f5c420ce-7b34-4e28-8e53-01a94c984964-keyboard.jpg differ diff --git a/code/33-finished/public/scripts/cart-item-management.js b/code/33-finished/public/scripts/cart-item-management.js new file mode 100644 index 0000000..85f6b6a --- /dev/null +++ b/code/33-finished/public/scripts/cart-item-management.js @@ -0,0 +1,61 @@ +const cartItemUpdateFormElements = document.querySelectorAll( + '.cart-item-management' +); +const cartTotalPriceElement = document.getElementById('cart-total-price'); +const cartBadgeElements = document.querySelectorAll('.nav-items .badge'); + +async function updateCartItem(event) { + event.preventDefault(); + + const form = event.target; + + const productId = form.dataset.productid; + const csrfToken = form.dataset.csrf; + const quantity = form.firstElementChild.value; + + let response; + try { + response = await fetch('/cart/items', { + method: 'PATCH', + body: JSON.stringify({ + productId: productId, + quantity: quantity, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + if (responseData.updatedCartData.updatedItemPrice === 0) { + form.parentElement.parentElement.remove(); + } else { + const cartItemTotalPriceElement = + form.parentElement.querySelector('.cart-item-price'); + cartItemTotalPriceElement.textContent = + responseData.updatedCartData.updatedItemPrice.toFixed(2); + } + + cartTotalPriceElement.textContent = + responseData.updatedCartData.newTotalPrice.toFixed(2); + + for (const cartBadgeElement of cartBadgeElements) { + cartBadgeElement.textContent = + responseData.updatedCartData.newTotalQuantity; + } +} + +for (const formElement of cartItemUpdateFormElements) { + formElement.addEventListener('submit', updateCartItem); +} diff --git a/code/33-finished/public/scripts/cart-management.js b/code/33-finished/public/scripts/cart-management.js new file mode 100644 index 0000000..950f609 --- /dev/null +++ b/code/33-finished/public/scripts/cart-management.js @@ -0,0 +1,40 @@ +const addToCartButtonElement = document.querySelector('#product-details button'); +const cartBadgeElements = document.querySelectorAll('.nav-items .badge'); + +async function addToCart() { + const productId = addToCartButtonElement.dataset.productid; + const csrfToken = addToCartButtonElement.dataset.csrf; + + let response; + try { + response = await fetch('/cart/items', { + method: 'POST', + body: JSON.stringify({ + productId: productId, + _csrf: csrfToken + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + } catch (error) { + alert('Something went wrong!'); + return; + } + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + const responseData = await response.json(); + + const newTotalQuantity = responseData.newTotalItems; + + for (const cartBadgeElement of cartBadgeElements) { + cartBadgeElement.textContent = newTotalQuantity; + } +} + +addToCartButtonElement.addEventListener('click', addToCart); \ No newline at end of file diff --git a/code/33-finished/public/scripts/image-preview.js b/code/33-finished/public/scripts/image-preview.js new file mode 100644 index 0000000..65c8fb7 --- /dev/null +++ b/code/33-finished/public/scripts/image-preview.js @@ -0,0 +1,18 @@ +const imagePickerElement = document.querySelector('#image-upload-control input'); +const imagePreviewElement = document.querySelector('#image-upload-control img'); + +function updateImagePreview() { + const files = imagePickerElement.files; + + if (!files || files.length === 0) { + imagePreviewElement.style.display = 'none'; + return; + } + + const pickedFile = files[0]; + + imagePreviewElement.src = URL.createObjectURL(pickedFile); + imagePreviewElement.style.display = 'block'; +} + +imagePickerElement.addEventListener('change', updateImagePreview); \ No newline at end of file diff --git a/code/33-finished/public/scripts/mobile.js b/code/33-finished/public/scripts/mobile.js new file mode 100644 index 0000000..974fc5b --- /dev/null +++ b/code/33-finished/public/scripts/mobile.js @@ -0,0 +1,8 @@ +const mobileMenuBtnElement = document.getElementById('mobile-menu-btn'); +const mobileMenuElement = document.getElementById('mobile-menu'); + +function toggleMobileMenu() { + mobileMenuElement.classList.toggle('open'); +} + +mobileMenuBtnElement.addEventListener('click', toggleMobileMenu); \ No newline at end of file diff --git a/code/33-finished/public/scripts/order-management.js b/code/33-finished/public/scripts/order-management.js new file mode 100644 index 0000000..8a85ffc --- /dev/null +++ b/code/33-finished/public/scripts/order-management.js @@ -0,0 +1,45 @@ +const updateOrderFormElements = document.querySelectorAll( + '.order-actions form' +); + +async function updateOrder(event) { + event.preventDefault(); + const form = event.target; + + const formData = new FormData(form); + const newStatus = formData.get('status'); + const orderId = formData.get('orderid'); + const csrfToken = formData.get('_csrf'); + + let response; + + try { + response = await fetch(`/admin/orders/${orderId}`, { + method: 'PATCH', + body: JSON.stringify({ + newStatus: newStatus, + _csrf: csrfToken, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + alert('Something went wrong - could not update order status.'); + return; + } + + if (!response.ok) { + alert('Something went wrong - could not update order status.'); + return; + } + + const responseData = await response.json(); + + form.parentElement.parentElement.querySelector('.badge').textContent = + responseData.newStatus.toUpperCase(); +} + +for (const updateOrderFormElement of updateOrderFormElements) { + updateOrderFormElement.addEventListener('submit', updateOrder); +} diff --git a/code/33-finished/public/scripts/product-management.js b/code/33-finished/public/scripts/product-management.js new file mode 100644 index 0000000..f1e3b49 --- /dev/null +++ b/code/33-finished/public/scripts/product-management.js @@ -0,0 +1,22 @@ +const deleteProductButtonElements = document.querySelectorAll('.product-item button'); + +async function deleteProduct(event) { + const buttonElement = event.target; + const productId = buttonElement.dataset.productid; + const csrfToken = buttonElement.dataset.csrf; + + const response = await fetch('/admin/products/' + productId + '?_csrf=' + csrfToken, { + method: 'DELETE' + }); + + if (!response.ok) { + alert('Something went wrong!'); + return; + } + + buttonElement.parentElement.parentElement.parentElement.parentElement.remove(); +} + +for (const deleteProductButtonElement of deleteProductButtonElements) { + deleteProductButtonElement.addEventListener('click', deleteProduct); +} \ No newline at end of file diff --git a/code/33-finished/public/styles/auth.css b/code/33-finished/public/styles/auth.css new file mode 100644 index 0000000..886df1e --- /dev/null +++ b/code/33-finished/public/styles/auth.css @@ -0,0 +1,22 @@ +h1 { + text-align: center; + color: var(--color-gray-300); +} + +form { + max-width: 25rem; + margin: var(--space-8) auto; + padding: var(--space-4); + background-color: var(--color-gray-600); + border-radius: var(--border-radius-medium); + text-align: center; +} + +form a { + color: var(--color-primary-200); +} + +form a:hover, +form a:active { + color: var(--color-primary-400); +} \ No newline at end of file diff --git a/code/33-finished/public/styles/base.css b/code/33-finished/public/styles/base.css new file mode 100644 index 0000000..f4da056 --- /dev/null +++ b/code/33-finished/public/styles/base.css @@ -0,0 +1,115 @@ +* { + box-sizing: border-box; +} + +html { + font-family: "Montserrat", "sans-serif"; + + --color-gray-50: rgb(243, 236, 230); + --color-gray-100: rgb(207, 201, 195); + --color-gray-300: rgb(99, 92, 86); + --color-gray-400: rgb(70, 65, 60); + --color-gray-500: rgb(37, 34, 31); + --color-gray-600: rgb(32, 29, 26); + --color-gray-700: rgb(31, 26, 22); + + --color-primary-50: rgb(253, 224, 200); + --color-primary-100: rgb(253, 214, 183); + --color-primary-200: rgb(250, 191, 143); + --color-primary-400: rgb(223, 159, 41); + --color-primary-500: rgb(212, 136, 14); + --color-primary-700: rgb(212, 120, 14); + --color-primary-200-contrast: rgb(100, 46, 2); + --color-primary-500-contrast: white; + + --color-error-100: rgb(255, 192, 180); + --color-error-500: rgb(199, 51, 15); + + --color-primary-500-bg: rgb(63, 60, 58); + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --border-radius-small: 4px; + --border-radius-medium: 6px; + + --shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +body { + background-color: var(--color-gray-500); + color: var(--color-gray-100); + margin: 0; +} + +main { + width: 90%; + max-width: 50rem; + margin: 0 auto; +} + +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + color: var(--color-primary-400); +} + +.btn { + cursor: pointer; + font: inherit; + padding: var(--space-2) var(--space-6); + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); + border: 1px solid var(--color-primary-500); + border-radius: var(--border-radius-small); +} + +.btn:hover, +.btn:active { + background-color: var(--color-primary-700); + border-color: var(--color-primary-700); +} + +.btn-alt { + background-color: transparent; + color: var(--color-primary-500); +} + +.btn-alt:hover, +.btn-alt:active { + background-color: var(--color-primary-500-bg); +} + +.alert { + border-radius: var(--border-radius-small); + background-color: var(--color-error-100); + color: var(--color-error-500); + padding: var(--space-4); +} + +.alert h2 { + font-size: 1rem; + margin: var(--space-2) 0; + text-transform: uppercase; +} + +.alert p { + margin: var(--space-2) 0; +} + +.badge { + margin-left: var(--space-2); + padding: 0.15rem var(--space-4); + border-radius: 10rem; + background-color: var(--color-primary-500); + color: var(--color-primary-500-contrast); +} \ No newline at end of file diff --git a/code/33-finished/public/styles/cart.css b/code/33-finished/public/styles/cart.css new file mode 100644 index 0000000..17ebcab --- /dev/null +++ b/code/33-finished/public/styles/cart.css @@ -0,0 +1,55 @@ +.cart-item { + display: flex; + flex-direction: column; + background-color: var(--color-gray-700); + padding: var(--space-4); + margin: var(--space-4) 0; + border-radius: var(--border-radius-medium); +} + +.cart-item h2 { + font-size: 1rem; + margin: 0; +} + +.cart-item input { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; +} + +.cart-product-price { + font-style: italic; + color: var(--color-gray-300); +} + +#cart-total { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +#cart-total p { + font-size: 1.5rem; + font-weight: bold; + color: var(--color-primary-500); +} + +#cart-total #cart-total-fallback { + font-size: 1rem; + font-weight: normal; +} + +@media (min-width: 48rem) { + .cart-item { + flex-direction: row; + justify-content: space-between; + } + + #cart-total { + flex-direction: row; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/code/33-finished/public/styles/forms.css b/code/33-finished/public/styles/forms.css new file mode 100644 index 0000000..83d0392 --- /dev/null +++ b/code/33-finished/public/styles/forms.css @@ -0,0 +1,35 @@ +form hr { + border-color: var(--color-primary-200); + margin: var(--space-4); +} + +label { + color: var(--color-gray-100); + display: block; + margin-bottom: var(--space-2); +} + +input, textarea { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); + border: none; + width: 90%; +} + +#image-upload-control { + display: flex; + align-items: center; +} + +#image-upload-control input { + max-width: 15rem; +} + +#image-upload-control img { + display: none; + width: 4rem; + height: 4rem; + object-fit: cover; + border-radius: var(--border-radius-small); +} diff --git a/code/33-finished/public/styles/navigation.css b/code/33-finished/public/styles/navigation.css new file mode 100644 index 0000000..a1e8cb4 --- /dev/null +++ b/code/33-finished/public/styles/navigation.css @@ -0,0 +1,136 @@ +main { + margin-top: 6rem; +} + +#main-header { + position: fixed; + top: 0; + left: 0; + width: 100%; + max-width: 60rem; + height: 5rem; + margin: 0 auto; + padding: 0 var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--color-primary-500); + background-color: var(--color-gray-500); +} + +#logo a { + font-weight: bold; + font-size: 2rem; +} + +#main-header nav { + display: none; +} + +.nav-items { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.nav-items li { + margin: 0 var(--space-2); +} + +.nav-items button { + cursor: pointer; + font: inherit; + border: 1px solid var(--color-primary-100); + border-radius: var(--border-radius-small); + background-color: transparent; + padding: var(--space-2) var(--space-4); +} + +#mobile-menu-btn { + display: flex; + flex-direction: column; + justify-content: space-around; + border: none; + cursor: pointer; + width: 2.25rem; + height: 2.25rem; + background-color: transparent; + padding: 0; +} + +#mobile-menu-btn span { + width: 2.25rem; + height: 0.2rem; + background-color: var(--color-gray-100); +} + +#mobile-menu { + position: fixed; + top: 5rem; + left: 0; + height: calc(100vh - 5rem); + width: 100%; + background-color: var(--color-gray-700); + display: none; + flex-direction: column; + align-items: center; +} + +#mobile-menu.open { + display: flex; +} + +#mobile-menu nav { + height: 20rem; + width: 90%; + margin: var(--space-4) auto; +} + +#mobile-menu .nav-items a, +#mobile-menu .nav-items button { + font-size: 1.75rem; + color: var(--color-primary-100); +} + +@media (min-width: 48rem) { + main { + margin-top: 0; + } + + #main-header { + position: static; + } + + #main-header nav { + display: block; + } + + .nav-items button { + color: var(--color-primary-500); + border-color: var(--color-primary-500); + } + + #mobile-menu-btn { + display: none; + } + + #mobile-menu { + display: none; + } + + .nav-items { + flex-direction: row; + } + + .nav-items a { + padding: var(--space-2) var(--space-4); + border-radius: var(--border-radius-small); + } + + .nav-items a:hover, + .nav-items a:active { + background-color: var(--color-primary-500-bg); + } +} \ No newline at end of file diff --git a/code/33-finished/public/styles/orders.css b/code/33-finished/public/styles/orders.css new file mode 100644 index 0000000..3c24e9f --- /dev/null +++ b/code/33-finished/public/styles/orders.css @@ -0,0 +1,57 @@ + +.order-item { + background-color: var(--color-gray-400); + border-radius: var(--border-radius-small); + padding: var(--space-4); + margin: var(--space-4) 0; +} + +.order-summary { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-2); +} + +.order-summary h2, +.order-summary p { + font-size: 1.25rem; + font-weight: normal; + margin: 0; +} + +.order-item-price { + color: var(--color-primary-500); +} + +.order-item .status { + display: flex; + min-width: 15rem; +} + +.order-item .status .badge { + margin-right: var(--space-4); +} + +.order-details ul { + list-style: square; + margin-left: var(--space-8); +} + +.order-details li { + margin-bottom: var(--space-2); +} + +.order-item select { + font: inherit; + padding: var(--space-2); + border-radius: var(--border-radius-small); +} + +@media (min-width: 48rem) { + .order-summary { + flex-direction: row; + } +} \ No newline at end of file diff --git a/code/33-finished/public/styles/products.css b/code/33-finished/public/styles/products.css new file mode 100644 index 0000000..b66cc21 --- /dev/null +++ b/code/33-finished/public/styles/products.css @@ -0,0 +1,77 @@ +#products-grid { + margin: var(--space-8) auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--space-4); +} + +.product-item { + border-radius: var(--border-radius-medium); + text-align: center; + background-color: var(--color-gray-600); + overflow: hidden; +} + +.product-item img { + width: 100%; + height: 10rem; + object-fit: cover; +} + +.product-item-content { + padding: var(--space-4); +} + +.product-item-content h2 { + font-size: 1.15rem; + margin: var(--space-2) 0; +} + +.product-item-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-4); + justify-content: center; +} + +#product-details header { + margin-top: var(--space-8); + padding: var(--space-8); + background-color: var(--color-gray-600); + gap: var(--space-8); +} + +#product-details img { + width: 100%; + height: 6rem; + object-fit: cover; + border-radius: var(--border-radius-medium); + border: 1px solid var(--color-gray-100); +} + +#product-info { + text-align: center; +} + +#product-description { + background-color: var(--color-primary-500-bg); + padding: var(--space-8); + white-space: pre-wrap; +} + +@media (min-width: 48rem) { + #product-details header { + display: flex; + } + + #product-details img { + width: 20rem; + height: 15rem; + transform: rotateZ(-10deg); + margin: var(--space-8); + } + + #product-info { + text-align: left; + } +} \ No newline at end of file diff --git a/code/33-finished/routes/admin.routes.js b/code/33-finished/routes/admin.routes.js new file mode 100644 index 0000000..a6ae8b7 --- /dev/null +++ b/code/33-finished/routes/admin.routes.js @@ -0,0 +1,24 @@ +const express = require('express'); + +const adminController = require('../controllers/admin.controller'); +const imageUploadMiddleware = require('../middlewares/image-upload'); + +const router = express.Router(); + +router.get('/products', adminController.getProducts); // /admin/products + +router.get('/products/new', adminController.getNewProduct); + +router.post('/products', imageUploadMiddleware, adminController.createNewProduct); + +router.get('/products/:id', adminController.getUpdateProduct); + +router.post('/products/:id', imageUploadMiddleware, adminController.updateProduct); + +router.delete('/products/:id', adminController.deleteProduct); + +router.get('/orders', adminController.getOrders); + +router.patch('/orders/:id', adminController.updateOrder); + +module.exports = router; \ No newline at end of file diff --git a/code/33-finished/routes/auth.routes.js b/code/33-finished/routes/auth.routes.js new file mode 100644 index 0000000..1e3a76c --- /dev/null +++ b/code/33-finished/routes/auth.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const authController = require('../controllers/auth.controller'); + +const router = express.Router(); + +router.get('/signup', authController.getSignup); + +router.post('/signup', authController.signup); + +router.get('/login', authController.getLogin); + +router.post('/login', authController.login); + +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/code/33-finished/routes/base.routes.js b/code/33-finished/routes/base.routes.js new file mode 100644 index 0000000..39024d9 --- /dev/null +++ b/code/33-finished/routes/base.routes.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router(); + +router.get('/', function(req, res) { + res.redirect('/products'); +}); + +router.get('/401', function(req, res) { + res.status(401).render('shared/401'); +}); + +router.get('/403', function(req, res) { + res.status(403).render('shared/403'); +}); + +module.exports = router; \ No newline at end of file diff --git a/code/33-finished/routes/cart.routes.js b/code/33-finished/routes/cart.routes.js new file mode 100644 index 0000000..390f3e5 --- /dev/null +++ b/code/33-finished/routes/cart.routes.js @@ -0,0 +1,13 @@ +const express = require('express'); + +const cartController = require('../controllers/cart.controller'); + +const router = express.Router(); + +router.get('/', cartController.getCart); // /cart/ + +router.post('/items', cartController.addCartItem); // /cart/items + +router.patch('/items', cartController.updateCartItem); + +module.exports = router; \ No newline at end of file diff --git a/code/33-finished/routes/orders.routes.js b/code/33-finished/routes/orders.routes.js new file mode 100644 index 0000000..9d29ba6 --- /dev/null +++ b/code/33-finished/routes/orders.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const ordersController = require('../controllers/orders.controller'); + +const router = express.Router(); + +router.post('/', ordersController.addOrder); // /orders + +router.get('/', ordersController.getOrders); // /orders + +module.exports = router; \ No newline at end of file diff --git a/code/33-finished/routes/products.routes.js b/code/33-finished/routes/products.routes.js new file mode 100644 index 0000000..2bfc5f4 --- /dev/null +++ b/code/33-finished/routes/products.routes.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const productsController = require('../controllers/products.controller'); + +const router = express.Router(); + +router.get('/products', productsController.getAllProducts); + +router.get('/products/:id', productsController.getProductDetails); + +module.exports = router; \ No newline at end of file diff --git a/code/33-finished/util/authentication.js b/code/33-finished/util/authentication.js new file mode 100644 index 0000000..f4ba235 --- /dev/null +++ b/code/33-finished/util/authentication.js @@ -0,0 +1,14 @@ +function createUserSession(req, user, action) { + req.session.uid = user._id.toString(); + req.session.isAdmin = user.isAdmin; + req.session.save(action); +} + +function destroyUserAuthSession(req) { + req.session.uid = null; +} + +module.exports = { + createUserSession: createUserSession, + destroyUserAuthSession: destroyUserAuthSession +}; \ No newline at end of file diff --git a/code/33-finished/util/session-flash.js b/code/33-finished/util/session-flash.js new file mode 100644 index 0000000..6d82019 --- /dev/null +++ b/code/33-finished/util/session-flash.js @@ -0,0 +1,17 @@ +function getSessionData(req) { + const sessionData = req.session.flashedData; + + req.session.flashedData = null; + + return sessionData; +} + +function flashDataToSession(req, data, action) { + req.session.flashedData = data; + req.session.save(action); +} + +module.exports = { + getSessionData: getSessionData, + flashDataToSession: flashDataToSession +}; \ No newline at end of file diff --git a/code/33-finished/util/validation.js b/code/33-finished/util/validation.js new file mode 100644 index 0000000..669981b --- /dev/null +++ b/code/33-finished/util/validation.js @@ -0,0 +1,28 @@ +function isEmpty(value) { + return !value || value.trim() === ''; +} + +function userCredentialsAreValid(email, password) { + return ( + email && email.includes('@') && password && password.trim().length >= 6 + ); +} + +function userDetailsAreValid(email, password, name, street, postal, city) { + return ( + userCredentialsAreValid(email, password) && + !isEmpty(name) && + !isEmpty(street) && + !isEmpty(postal) && + !isEmpty(city) + ); +} + +function emailIsConfirmed(email, confirmEmail) { + return email === confirmEmail; +} + +module.exports = { + userDetailsAreValid: userDetailsAreValid, + emailIsConfirmed: emailIsConfirmed, +}; diff --git a/code/33-finished/views/admin/orders/admin-orders.ejs b/code/33-finished/views/admin/orders/admin-orders.ejs new file mode 100644 index 0000000..7bc5caa --- /dev/null +++ b/code/33-finished/views/admin/orders/admin-orders.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Orders' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+ <%- include('../../shared/includes/order-list') %> +
+ \ No newline at end of file diff --git a/code/33-finished/views/admin/products/all-products.ejs b/code/33-finished/views/admin/products/all-products.ejs new file mode 100644 index 0000000..729930f --- /dev/null +++ b/code/33-finished/views/admin/products/all-products.ejs @@ -0,0 +1,25 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Product Administration

+
+

Manage Products

+

+ Add Product +

+
+
+ +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/admin/products/includes/product-form.ejs b/code/33-finished/views/admin/products/includes/product-form.ejs new file mode 100644 index 0000000..656d9af --- /dev/null +++ b/code/33-finished/views/admin/products/includes/product-form.ejs @@ -0,0 +1,36 @@ +
+

+ + +

+ +
+

+ + required<% } %>> +

+ + Selected image. +
+ + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+
\ No newline at end of file diff --git a/code/33-finished/views/admin/products/new-product.ejs b/code/33-finished/views/admin/products/new-product.ejs new file mode 100644 index 0000000..e88d4fa --- /dev/null +++ b/code/33-finished/views/admin/products/new-product.ejs @@ -0,0 +1,17 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Add new product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Add new Product

+ <%- include('includes/product-form', { submitPath: '/admin/products', product: { + title: '', + summary: '', + price: '', + description: '' + }, + imageRequired: true }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/admin/products/update-product.ejs b/code/33-finished/views/admin/products/update-product.ejs new file mode 100644 index 0000000..68138ba --- /dev/null +++ b/code/33-finished/views/admin/products/update-product.ejs @@ -0,0 +1,11 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Update product' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Update Product

+ <%- include('includes/product-form', { submitPath: '/admin/products/' + product.id, product: product, imageRequired: false }) %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/customer/auth/login.ejs b/code/33-finished/views/customer/auth/login.ejs new file mode 100644 index 0000000..8076a99 --- /dev/null +++ b/code/33-finished/views/customer/auth/login.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Login' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Login

+ <% if (inputData.errorMessage) { %> +
+

Invalid Credentials

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+ +

Create a new user

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/customer/auth/signup.ejs b/code/33-finished/views/customer/auth/signup.ejs new file mode 100644 index 0000000..fe7aaa6 --- /dev/null +++ b/code/33-finished/views/customer/auth/signup.ejs @@ -0,0 +1,57 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Signup' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Create New Account

+ <% if (inputData.errorMessage) { %> +
+

Invalid Input

+

<%= inputData.errorMessage %>

+
+ <% } %> +
+ +

+ + +

+

+ + +

+

+ + +

+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ +

Login instead

+
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/customer/cart/cart.ejs b/code/33-finished/views/customer/cart/cart.ejs new file mode 100644 index 0000000..bc19650 --- /dev/null +++ b/code/33-finished/views/customer/cart/cart.ejs @@ -0,0 +1,29 @@ +<%- include('../../shared/includes/head', { pageTitle: 'Your Cart' }) %> + + + + + <%- include('../../shared/includes/header') %> +
+

Your Cart

+ +
+

Total: $<%= locals.cart.totalPrice.toFixed(2) %>

+ + <% if (locals.isAuth) { %> +
+ + +
+ <% } else { %> +

Log in to proceed and purchase the items.

+ <% } %> +
+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/customer/cart/includes/cart-item.ejs b/code/33-finished/views/customer/cart/includes/cart-item.ejs new file mode 100644 index 0000000..fd37c25 --- /dev/null +++ b/code/33-finished/views/customer/cart/includes/cart-item.ejs @@ -0,0 +1,11 @@ +
+
+

<%= item.product.title %>

+

$<%= item.totalPrice.toFixed(2) %> ($<%= item.product.price.toFixed(2) %>)

+
+ +
+ + +
+
\ No newline at end of file diff --git a/code/33-finished/views/customer/orders/all-orders.ejs b/code/33-finished/views/customer/orders/all-orders.ejs new file mode 100644 index 0000000..610c19d --- /dev/null +++ b/code/33-finished/views/customer/orders/all-orders.ejs @@ -0,0 +1,10 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Your Orders' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Your Orders

+ <%- include('../../shared/includes/order-list') %> +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/customer/products/all-products.ejs b/code/33-finished/views/customer/products/all-products.ejs new file mode 100644 index 0000000..83641b9 --- /dev/null +++ b/code/33-finished/views/customer/products/all-products.ejs @@ -0,0 +1,16 @@ +<%- include('../../shared/includes/head', { pageTitle: 'All Products' }) %> + + + + <%- include('../../shared/includes/header') %> +
+

All Products

+ +
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/customer/products/product-details.ejs b/code/33-finished/views/customer/products/product-details.ejs new file mode 100644 index 0000000..c99ca11 --- /dev/null +++ b/code/33-finished/views/customer/products/product-details.ejs @@ -0,0 +1,19 @@ +<%- include('../../shared/includes/head', { pageTitle: product.title }) %> + + + + + <%- include('../../shared/includes/header') %> +
+
+ <%= product.title %> +
+

<%= product.title %>

+

$<%= product.price %>

+ +
+
+ +

<%= product.description %>

+
+<%- include('../../shared/includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/shared/401.ejs b/code/33-finished/views/shared/401.ejs new file mode 100644 index 0000000..2472f57 --- /dev/null +++ b/code/33-finished/views/shared/401.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authenticated!

+

You are not authenticated!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/shared/403.ejs b/code/33-finished/views/shared/403.ejs new file mode 100644 index 0000000..c5fd33b --- /dev/null +++ b/code/33-finished/views/shared/403.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Not authorized!

+

You are not authorized!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/shared/404.ejs b/code/33-finished/views/shared/404.ejs new file mode 100644 index 0000000..4c177ad --- /dev/null +++ b/code/33-finished/views/shared/404.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Could not find resource

+

Unfortunately, we could not find the requested resource!

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/shared/500.ejs b/code/33-finished/views/shared/500.ejs new file mode 100644 index 0000000..4e46cc6 --- /dev/null +++ b/code/33-finished/views/shared/500.ejs @@ -0,0 +1,10 @@ +<%- include('includes/head', { pageTitle: 'An error occurred' }) %> + + + <%- include('includes/header') %> +
+

Something went wrong!

+

Unfortunately, something went wrong - please try again later.

+

Back to safety!

+
+<%- include('includes/footer') %> \ No newline at end of file diff --git a/code/33-finished/views/shared/includes/footer.ejs b/code/33-finished/views/shared/includes/footer.ejs new file mode 100644 index 0000000..691287b --- /dev/null +++ b/code/33-finished/views/shared/includes/footer.ejs @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/code/33-finished/views/shared/includes/head.ejs b/code/33-finished/views/shared/includes/head.ejs new file mode 100644 index 0000000..ab73552 --- /dev/null +++ b/code/33-finished/views/shared/includes/head.ejs @@ -0,0 +1,17 @@ + + + + + + + <%= pageTitle %> + + + + + + + diff --git a/code/33-finished/views/shared/includes/header.ejs b/code/33-finished/views/shared/includes/header.ejs new file mode 100644 index 0000000..4376474 --- /dev/null +++ b/code/33-finished/views/shared/includes/header.ejs @@ -0,0 +1,16 @@ +
+ + + +
+ \ No newline at end of file diff --git a/code/33-finished/views/shared/includes/nav-items.ejs b/code/33-finished/views/shared/includes/nav-items.ejs new file mode 100644 index 0000000..6951848 --- /dev/null +++ b/code/33-finished/views/shared/includes/nav-items.ejs @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/code/33-finished/views/shared/includes/order-item.ejs b/code/33-finished/views/shared/includes/order-item.ejs new file mode 100644 index 0000000..fa0361e --- /dev/null +++ b/code/33-finished/views/shared/includes/order-item.ejs @@ -0,0 +1,35 @@ +
+
+

$<%= order.productData.totalPrice.toFixed(2) %> - <%= order.formattedDate %>

+

<%= order.status.toUpperCase() %>

+
+ +
+ <% if (locals.isAdmin) { %> +
+

<%= order.userData.name %>

+

<%= order.userData.address.street %> (<%= order.userData.address.postalCode %> <%= order.userData.address.city %>)

+
+ <% } %> + +
+ + <% if (locals.isAdmin) { %> +
+
+ + + + +
+
+ <% } %> +
\ No newline at end of file diff --git a/code/33-finished/views/shared/includes/order-list.ejs b/code/33-finished/views/shared/includes/order-list.ejs new file mode 100644 index 0000000..322a1ee --- /dev/null +++ b/code/33-finished/views/shared/includes/order-list.ejs @@ -0,0 +1,7 @@ +
    + <% for (const order of orders) { %> +
  1. + <%- include('order-item', { order: order }) %> +
  2. + <% } %> +
\ No newline at end of file diff --git a/code/33-finished/views/shared/includes/product-item.ejs b/code/33-finished/views/shared/includes/product-item.ejs new file mode 100644 index 0000000..e61bb54 --- /dev/null +++ b/code/33-finished/views/shared/includes/product-item.ejs @@ -0,0 +1,14 @@ +
+ <%= product.title %> +
+

<%= product.title %>

+
+ <% if (locals.isAdmin) { %> + View & Edit + + <% } else { %> + View Details + <% } %> +
+
+
diff --git a/slides/slides.pdf b/slides/slides.pdf new file mode 100644 index 0000000..0d2ed42 Binary files /dev/null and b/slides/slides.pdf differ