diff --git a/README.md b/README.md index dc3a7bf..7c46f7d 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,9 @@ $ yarn lint ``` $ yarn test ``` + +### Authentication + +To manage pages you need to authorize (available on `/auth`). + +To generate password use `yarn generatePassword [password]` command. diff --git a/config/index.js b/config/index.js index 42e364b..47bff79 100644 --- a/config/index.js +++ b/config/index.js @@ -15,7 +15,8 @@ if (fs.existsSync(path.resolve(__dirname, configPath))) { } else { config = { database: '.db', - port: 3000 + port: 3000, + secret: 'secret' }; } diff --git a/generatePassword.js b/generatePassword.js new file mode 100644 index 0000000..9b5d32e --- /dev/null +++ b/generatePassword.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +let { password: db } = require('./src/utils/database'); +const program = require('commander'); + +const bcrypt = require('bcrypt'); +const saltRounds = 12; + +/** + * Script for generating password, that will be used to create and edit pages in CodeX.Docs. + * Hashes password with bcrypt and inserts it to the database. + * @see {https://github.com/tj/commander.js | CommanderJS} + */ +program + .description('Application for generating password, that will be used to create and edit pages in CodeX.Docs.') + .usage('[password]') + .arguments('') + .action(async function (password) { + bcrypt.hash(password, saltRounds, async (error, hash) => { + if (error) { + return 'Hash generating error'; + } + + const userDoc = { passHash: hash }; + + await db.remove({}, {multi: true}); + await db.insert(userDoc); + + console.log('Password was successfully generated'); + }); + }); + +program.on('--help', () => { + console.log(''); + console.log('Example:'); + console.log('yarn generatePassword qwerty'); + console.log(''); +}); + +program.parse(process.argv); + +if (process.argv.length !== 3) { + console.error('Invalid command: %s\nSee --help or -h for a list of available commands.', program.args.join(' ')); + process.exit(1); +} diff --git a/package.json b/package.json index e5a31b8..a12f49e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "codex.docs", "version": "0.0.0", + "bin": { "generatePassword": "bin/generatePassword.js"}, "private": true, "scripts": { "start": "cross-env NODE_ENV=production nodemon ./bin/www", @@ -8,20 +9,26 @@ "test": "cross-env NODE_ENV=testing mocha --recursive ./test", "lint": "eslint --fix --cache ./src/**/*.js", "build": "webpack ./src/frontend/js/app.js --o='./public/dist/[name].bundle.js' --output-library=Docs --output-public-path=/dist/ -p --watch", - "precommit": "yarn lint && yarn test --exit" + "precommit": "yarn lint && yarn test --exit", + "generatePassword": "node ./generatePassword.js" }, "dependencies": { "@babel/polyfill": "^7.0.0", + "bcrypt": "^3.0.3", "body-parser": "latest", "codex.editor": "^2.8.1", "codex.editor.delimiter": "^1.0.2", "codex.editor.embed": "^2.1.2", "codex.editor.header": "^2.0.5", + "commander": "^2.19.0", "codex.editor.image": "^2.0.3", "codex.editor.quote": "^2.1.5", "codex.editor.raw": "^2.0.2", "cookie-parser": "~1.4.3", + "csurf": "^1.9.0", "debug": "~4.1.0", + "dotenv": "^6.2.0", + "jsonwebtoken": "^8.4.0", "eslint-plugin-standard": "^4.0.0", "express": "~4.16.0", "http-errors": "~1.7.1", diff --git a/src/app.js b/src/app.js index 65beda0..9bbf125 100644 --- a/src/app.js +++ b/src/app.js @@ -19,7 +19,7 @@ require('./utils/twig'); app.use(logger('dev')); app.use(express.json()); -app.use(express.urlencoded({extended: true})); +app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, '../public'))); diff --git a/src/controllers/pages.js b/src/controllers/pages.js index 2eb64d0..7068549 100644 --- a/src/controllers/pages.js +++ b/src/controllers/pages.js @@ -49,7 +49,7 @@ class Pages { * @param {string} parent - id of current page * @returns {Promise} */ - static async getAllExceptChildrens(parent) { + static async getAllExceptChildren(parent) { let pagesAvailable = this.removeChildren(await Pages.getAll(), parent); return pagesAvailable.filter((item) => item !== null); diff --git a/src/controllers/users.js b/src/controllers/users.js new file mode 100644 index 0000000..6bd1457 --- /dev/null +++ b/src/controllers/users.js @@ -0,0 +1,21 @@ +const Model = require('../models/user'); + +/** + * @class Users + * @classdesc Users controller + */ +class Users { + /** + * @static + * Find and return user model. + * + * @returns {Promise} + */ + static async get() { + const userDoc = await Model.get(); + + return userDoc; + } +} + +module.exports = Users; diff --git a/src/frontend/js/classes/codeStyler.js b/src/frontend/js/classes/codeStyler.js index 7813296..95b3051 100644 --- a/src/frontend/js/classes/codeStyler.js +++ b/src/frontend/js/classes/codeStyler.js @@ -2,6 +2,7 @@ import hljs from 'highlight.js/lib/highlight'; import javascript from 'highlight.js/lib/languages/javascript'; import xml from 'highlight.js/lib/languages/xml'; import json from 'highlight.js/lib/languages/json'; +// eslint-disable-next-line import style from 'highlight.js/styles/atom-one-dark.css'; /** @@ -13,7 +14,7 @@ export default class CodeStyler { * @param {string} selector - CSS selector for code blocks * @param {string[]} languages - list of languages to highlight, see hljs.listLanguages() */ - constructor({selector, languages = [ 'javascript', 'xml', 'json' ]}) { + constructor({ selector, languages = ['javascript', 'xml', 'json'] }) { this.codeBlocksSelector = selector; this.languages = languages; this.langsAvailable = { diff --git a/src/frontend/js/classes/editor.js b/src/frontend/js/classes/editor.js index 6ae4eca..b49344f 100644 --- a/src/frontend/js/classes/editor.js +++ b/src/frontend/js/classes/editor.js @@ -11,7 +11,6 @@ import Delimiter from 'codex.editor.delimiter'; import InlineCode from 'codex.editor.inline-code'; import List from 'codex.editor.list'; import RawTool from 'codex.editor.raw'; -import ImageTool from 'codex.editor.image'; import Embed from 'codex.editor.embed'; /** diff --git a/src/frontend/js/modules/page.js b/src/frontend/js/modules/page.js index 0a55b72..589e498 100644 --- a/src/frontend/js/modules/page.js +++ b/src/frontend/js/modules/page.js @@ -27,7 +27,7 @@ export default class Writing { * Init code highlighting */ async createCodeStyling() { - const {default: CodeStyler} = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler'); + const { default: CodeStyler } = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler'); return new CodeStyler({ selector: '.block-code' diff --git a/src/frontend/js/modules/writing.js b/src/frontend/js/modules/writing.js index 57ebfbf..bb3ea77 100644 --- a/src/frontend/js/modules/writing.js +++ b/src/frontend/js/modules/writing.js @@ -64,7 +64,8 @@ export default class Writing { if (this.nodes.removeButton) { this.nodes.removeButton.addEventListener('click', () => { - const isUserAgree = confirm("Are you sure?"); + const isUserAgree = window.confirm('Are you sure?'); + if (!isUserAgree) { return; } @@ -83,7 +84,7 @@ export default class Writing { * @return {Promise} */ async loadEditor() { - const {default: Editor} = await import(/* webpackChunkName: "editor" */ './../classes/editor'); + const { default: Editor } = await import(/* webpackChunkName: "editor" */ './../classes/editor'); const editorConfig = this.page ? { data: this.page.body @@ -119,6 +120,7 @@ export default class Writing { /** get ordering selector value */ let putAbovePageId = null; + if (this.nodes.putAboveIdSelector) { putAbovePageId = this.nodes.putAboveIdSelector.value; } diff --git a/src/models/page.js b/src/models/page.js index f214b3d..fce3fb3 100644 --- a/src/models/page.js +++ b/src/models/page.js @@ -89,7 +89,7 @@ class Page { /** * Return PageData object * - * @returns {PageData} + * @return {PageData} */ get data() { return { diff --git a/src/models/user.js b/src/models/user.js new file mode 100644 index 0000000..ef29e69 --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,36 @@ +const { password: db } = require('../utils/database/index'); + +/** + * @class User + * @class User model + * + * @property {string} passHash - hashed password + */ +class User { + /** + * Find and return model of user. + * User is only one. + * + * @returns {Promise} + */ + static async get() { + const data = await db.findOne({}); + + if (!data) { + return null; + } + + return new User(data); + } + + /** + * @constructor + * + * @param {Object} userData + */ + constructor(userData) { + this.passHash = userData.passHash; + } +} + +module.exports = User; diff --git a/src/routes/api/pages.js b/src/routes/api/pages.js index 294e01b..a981da8 100644 --- a/src/routes/api/pages.js +++ b/src/routes/api/pages.js @@ -8,6 +8,7 @@ const PagesOrder = require('../../controllers/pagesOrder'); * * Return PageData of page with given id */ + router.get('/page/:id', async (req, res) => { try { const page = await Pages.get(req.params.id); @@ -52,8 +53,8 @@ router.get('/pages', async (req, res) => { */ router.put('/page', multer.any(), async (req, res) => { try { - const {title, body, parent} = req.body; - const page = await Pages.insert({title, body, parent}); + const { title, body, parent } = req.body; + const page = await Pages.insert({ title, body, parent }); /** push to the orders array */ await PagesOrder.push(parent, page._id); @@ -76,10 +77,10 @@ router.put('/page', multer.any(), async (req, res) => { * Update page data in the database */ router.post('/page/:id', multer.any(), async (req, res) => { - const {id} = req.params; + const { id } = req.params; try { - const {title, body, parent, putAbovePageId, uri} = req.body; + const { title, body, parent, putAbovePageId, uri } = req.body; const pages = await Pages.getAll(); let page = await Pages.get(id); @@ -87,12 +88,13 @@ router.post('/page/:id', multer.any(), async (req, res) => { await PagesOrder.move(page._parent, parent, id); } else { if (putAbovePageId && putAbovePageId !== '0') { - const unordered = pages.filter( _page => _page._parent === page._parent).map(_page => _page._id); + const unordered = pages.filter(_page => _page._parent === page._parent).map(_page => _page._id); + await PagesOrder.update(unordered, page._id, page._parent, putAbovePageId); } } - page = await Pages.update(id, {title, body, parent, uri}); + page = await Pages.update(id, { title, body, parent, uri }); res.json({ success: true, result: page @@ -119,12 +121,13 @@ router.delete('/page/:id', async (req, res) => { const pageAfterId = parentPageOrder.getPageAfter(page._id); let pageToRedirect; + if (pageBeforeId) { pageToRedirect = await Pages.get(pageBeforeId); } else if (pageAfterId) { pageToRedirect = await Pages.get(pageAfterId); } else { - pageToRedirect = page._parent !== "0" ? await Pages.get(page._parent) : null; + pageToRedirect = page._parent !== '0' ? await Pages.get(page._parent) : null; } /** @@ -133,10 +136,12 @@ router.delete('/page/:id', async (req, res) => { * @param startFrom * @returns {Promise} */ - async function deleteRecursively(startFrom) { + const deleteRecursively = async function (startFrom) { let order = []; + try { const children = await PagesOrder.get(startFrom); + order = children.order; } catch (e) {} @@ -148,7 +153,7 @@ router.delete('/page/:id', async (req, res) => { try { await PagesOrder.remove(startFrom); } catch (e) {} - } + }; await deleteRecursively(req.params.id); diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..5ba1b0a --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,54 @@ +require('dotenv').config(); + +const express = require('express'); +const bodyParser = require('body-parser'); +const jwt = require('jsonwebtoken'); +const router = express.Router(); +const Users = require('../controllers/users'); +const config = require('../../config/index'); +const bcrypt = require('bcrypt'); +const csrf = require('csurf'); +const csrfProtection = csrf({ cookie: true }); +const parseForm = bodyParser.urlencoded({ extended: false }); + +/** + * Authorization page + */ +router.get('/auth', csrfProtection, function (req, res) { + res.render('auth', { + title: 'Login page', + header: 'Enter password', + csrfToken: req.csrfToken() + }); +}); + +/** + * Process given password + */ +router.post('/auth', parseForm, csrfProtection, async (req, res) => { + let userDoc = await Users.get(); + + const passHash = userDoc.passHash; + + bcrypt.compare(req.body.password, passHash, async (err, result) => { + if (err || result === false) { + res.render('auth', { + title: 'Login page', + header: 'Wrong password', + csrfToken: req.csrfToken() + }); + } + + const token = jwt.sign({ + 'iss': 'Codex Team', + 'sub': 'auth', + 'iat': Date.now() + }, passHash + config.secret); + + res.cookie('authToken', token, { httpOnly: true }); + + res.redirect('/'); + }); +}); + +module.exports = router; diff --git a/src/routes/home.js b/src/routes/home.js index 2312395..0581855 100644 --- a/src/routes/home.js +++ b/src/routes/home.js @@ -1,9 +1,10 @@ const express = require('express'); +const verifyToken = require('./middlewares/token'); const router = express.Router(); /* GET home page. */ -router.get('/', function (req, res, next) { - res.render('pages/index'); +router.get('/', verifyToken, async (req, res) => { + res.render('pages/index', { isAuthorized: res.locals.isAuthorized }); }); module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index 3828882..aab43a2 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -3,6 +3,7 @@ const router = express.Router(); const home = require('./home'); const pages = require('./pages'); +const auth = require('./auth'); const aliases = require('./aliases'); const api = require('./api'); @@ -10,6 +11,7 @@ const pagesMiddleware = require('./middlewares/pages'); router.use('/', pagesMiddleware, home); router.use('/', pagesMiddleware, pages); +router.use('/', pagesMiddleware, auth); router.use('/api', api); router.use('/', aliases); diff --git a/src/routes/middlewares/locals.js b/src/routes/middlewares/locals.js new file mode 100644 index 0000000..bd584cb --- /dev/null +++ b/src/routes/middlewares/locals.js @@ -0,0 +1,13 @@ +/** + * Middleware for checking locals.isAuthorized property, which allows to edit/create pages + * @param req + * @param res + * @param next + */ +module.exports = function allowEdit(req, res, next) { + if (res.locals.isAuthorized) { + next(); + } else { + res.redirect('/auth'); + } +}; diff --git a/src/routes/middlewares/pages.js b/src/routes/middlewares/pages.js index 2ea9205..21386bb 100644 --- a/src/routes/middlewares/pages.js +++ b/src/routes/middlewares/pages.js @@ -22,14 +22,15 @@ function createMenuTree(parentPageId, pages, pagesOrder, level = 1, currentLevel * otherwise just find all pages includes parent tree */ let ordered = []; + if (childrenOrder) { - ordered = childrenOrder.order.map( pageId => { - return pages.find( page => page._id === pageId); + ordered = childrenOrder.order.map(pageId => { + return pages.find(page => page._id === pageId); }); } - const unordered = pages.filter( page => page._parent === parentPageId); - const branch = [...new Set([...ordered, ...unordered])]; + const unordered = pages.filter(page => page._parent === parentPageId); + const branch = [ ...new Set([...ordered, ...unordered]) ]; /** * stop recursion when we got the passed max level @@ -41,12 +42,11 @@ function createMenuTree(parentPageId, pages, pagesOrder, level = 1, currentLevel /** * Each parents children can have subbranches */ - return branch.filter(page => page && page._id).map( page => { + return branch.filter(page => page && page._id).map(page => { return Object.assign({ children: createMenuTree(page._id, pages, pagesOrder, level, currentLevel + 1) }, page.data); }); - } /** @@ -55,15 +55,17 @@ function createMenuTree(parentPageId, pages, pagesOrder, level = 1, currentLevel * @param res * @param next */ -module.exports = asyncMiddleware(async function (req, res, next) { +module.exports = asyncMiddleware(async (req, res, next) => { /** * Pages without parent * @type {string} */ const parentIdOfRootPages = '0'; + try { const pages = await Pages.getAll(); const pagesOrder = await PagesOrder.getAll(); + res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder, 2); } catch (error) { console.log('Can not load menu:', error); diff --git a/src/routes/middlewares/token.js b/src/routes/middlewares/token.js new file mode 100644 index 0000000..851b763 --- /dev/null +++ b/src/routes/middlewares/token.js @@ -0,0 +1,25 @@ +require('dotenv').config(); +const config = require('../../../config/index'); +const jwt = require('jsonwebtoken'); +const Users = require('../../controllers/users'); + +/** + * Middleware for checking jwt token + * @param req + * @param res + * @param next + */ +module.exports = async function verifyToken(req, res, next) { + let token = req.cookies.authToken; + const userDoc = await Users.get(); + + if (!userDoc) { + res.locals.isAuthorized = false; + next() + } + + jwt.verify(token, userDoc.passHash + config.secret, (err, decodedToken) => { + res.locals.isAuthorized = !(err || !decodedToken); + next(); + }); +}; diff --git a/src/routes/pages.js b/src/routes/pages.js index 31590db..27e3680 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -3,10 +3,13 @@ const router = express.Router(); const Pages = require('../controllers/pages'); const PagesOrder = require('../controllers/pagesOrder'); +const verifyToken = require('./middlewares/token'); +const allowEdit = require('./middlewares/locals'); + /** * Create new page form */ -router.get('/page/new', async (req, res) => { +router.get('/page/new', verifyToken, allowEdit, async (req, res, next) => { let pagesAvailable = await Pages.getAll(); res.render('pages/form', { @@ -18,7 +21,7 @@ router.get('/page/new', async (req, res) => { /** * Edit page form */ -router.get('/page/edit/:id', async (req, res, next) => { +router.get('/page/edit/:id', verifyToken, allowEdit, async (req, res, next) => { const pageId = req.params.id; try { @@ -40,7 +43,7 @@ router.get('/page/edit/:id', async (req, res, next) => { /** * View page */ -router.get('/page/:id', async (req, res, next) => { +router.get('/page/:id', verifyToken, async (req, res, next) => { const pageId = req.params.id; try { diff --git a/src/utils/asyncMiddleware.js b/src/utils/asyncMiddleware.js index 4bb27ce..6f2278f 100644 --- a/src/utils/asyncMiddleware.js +++ b/src/utils/asyncMiddleware.js @@ -3,7 +3,7 @@ * @param fn * @return {function(*=, *=, *=)} */ -module.exports = function asyncMiddlware(fn) { +module.exports = function asyncMiddleware(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)) .catch(next); diff --git a/src/utils/database/aliases.js b/src/utils/database/aliases.js index 895bb3f..2770232 100644 --- a/src/utils/database/aliases.js +++ b/src/utils/database/aliases.js @@ -1,6 +1,6 @@ const Datastore = require('nedb'); const config = require('../../../config'); -const db = new Datastore({filename: `./${config.database}/aliases.db`, autoload: true}); +const db = new Datastore({ filename: `./${config.database}/aliases.db`, autoload: true }); module.exports = db; diff --git a/src/utils/database/index.js b/src/utils/database/index.js index f2d5ac5..e9d82e3 100644 --- a/src/utils/database/index.js +++ b/src/utils/database/index.js @@ -1,4 +1,5 @@ const pages = require('./pages'); +const password = require('./password'); const aliases = require('./aliases'); const pagesOrder = require('./pagesOrder'); @@ -145,6 +146,7 @@ class Database { module.exports = { class: Database, pages: new Database(pages), + password: new Database(password), aliases: new Database(aliases), pagesOrder: new Database(pagesOrder) }; diff --git a/src/utils/database/pages.js b/src/utils/database/pages.js index 493ab25..115738e 100644 --- a/src/utils/database/pages.js +++ b/src/utils/database/pages.js @@ -1,6 +1,6 @@ const Datastore = require('nedb'); const config = require('../../../config'); -const db = new Datastore({filename: `./${config.database}/pages.db`, autoload: true}); +const db = new Datastore({ filename: `./${config.database}/pages.db`, autoload: true }); module.exports = db; diff --git a/src/utils/database/pagesOrder.js b/src/utils/database/pagesOrder.js index 5914f1f..253b72c 100644 --- a/src/utils/database/pagesOrder.js +++ b/src/utils/database/pagesOrder.js @@ -1,5 +1,5 @@ const Datastore = require('nedb'); const config = require('../../../config'); -const db = new Datastore({filename: `./${config.database}/pagesOrder.db`, autoload: true}); +const db = new Datastore({ filename: `./${config.database}/pagesOrder.db`, autoload: true }); module.exports = db; diff --git a/src/utils/database/password.js b/src/utils/database/password.js new file mode 100644 index 0000000..33d0295 --- /dev/null +++ b/src/utils/database/password.js @@ -0,0 +1,6 @@ +const Datastore = require('nedb'); +const config = require('../../../config'); + +const db = new Datastore({ filename: `./${config.database}/password.db`, autoload: true }); + +module.exports = db; diff --git a/src/views/auth.twig b/src/views/auth.twig new file mode 100644 index 0000000..251d17f --- /dev/null +++ b/src/views/auth.twig @@ -0,0 +1,11 @@ +{% extends 'layout.twig' %} + +{% block body %} +

{{header}}

+
+ + + +
+{% endblock %} + diff --git a/src/views/components/header.twig b/src/views/components/header.twig index 3ac3722..751bce1 100644 --- a/src/views/components/header.twig +++ b/src/views/components/header.twig @@ -3,12 +3,14 @@ {{ config.title }}
    -
  • - - {{ svg('plus') }} - Add Page - -
  • + {% if isAuthorized == true %} +
  • + + {{ svg('plus') }} + Add Page + +
  • + {% endif %} {% for option in config.menu %}
  • - {% include "components/header.twig" %} + {% include "components/header.twig" with res.locals.isAuthorized %}