Jamie Curnow 7 gadi atpakaļ
vecāks
revīzija
80d78cbf25
39 mainītis faili ar 2837 papildinājumiem un 0 dzēšanām
  1. 95 0
      src/backend/app.js
  2. 23 0
      src/backend/db.js
  3. 45 0
      src/backend/index.js
  4. 166 0
      src/backend/internal/token.js
  5. 382 0
      src/backend/internal/user.js
  6. 256 0
      src/backend/lib/access.js
  7. 45 0
      src/backend/lib/access/roles.json
  8. 23 0
      src/backend/lib/access/users-get.json
  9. 7 0
      src/backend/lib/access/users-list.json
  10. 83 0
      src/backend/lib/error.js
  11. 32 0
      src/backend/lib/express/cors.js
  12. 17 0
      src/backend/lib/express/jwt-decode.js
  13. 15 0
      src/backend/lib/express/jwt.js
  14. 57 0
      src/backend/lib/express/pagination.js
  15. 11 0
      src/backend/lib/express/user-id-from-me.js
  16. 35 0
      src/backend/lib/helpers.js
  17. 57 0
      src/backend/lib/migrate_template.js
  18. 47 0
      src/backend/lib/validator/api.js
  19. 53 0
      src/backend/lib/validator/index.js
  20. 7 0
      src/backend/logger.js
  21. 17 0
      src/backend/migrate.js
  22. 60 0
      src/backend/migrations/20180618015850_initial.js
  23. 82 0
      src/backend/models/auth.js
  24. 133 0
      src/backend/models/token.js
  25. 35 0
      src/backend/models/user.js
  26. 32 0
      src/backend/routes/api/main.js
  27. 56 0
      src/backend/routes/api/tokens.js
  28. 256 0
      src/backend/routes/api/users.js
  29. 44 0
      src/backend/routes/main.js
  30. 139 0
      src/backend/schema/definitions.json
  31. 100 0
      src/backend/schema/endpoints/tokens.json
  32. 240 0
      src/backend/schema/endpoints/users.json
  33. 23 0
      src/backend/schema/examples.json
  34. 21 0
      src/backend/schema/index.json
  35. 87 0
      src/backend/setup.js
  36. 9 0
      src/backend/views/index.ejs
  37. 9 0
      src/backend/views/login.ejs
  38. 2 0
      src/backend/views/partials/footer.ejs
  39. 36 0
      src/backend/views/partials/header.ejs

+ 95 - 0
src/backend/app.js

@@ -0,0 +1,95 @@
+'use strict';
+
+const path        = require('path');
+const express     = require('express');
+const bodyParser  = require('body-parser');
+const compression = require('compression');
+const log         = require('./logger').express;
+
+/**
+ * App
+ */
+const app = express();
+app.use(bodyParser.json());
+app.use(bodyParser.urlencoded({extended: true}));
+
+// Gzip
+app.use(compression());
+
+/**
+ * General Logging, BEFORE routes
+ */
+
+app.disable('x-powered-by');
+app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
+app.enable('strict routing');
+
+// pretty print JSON when not live
+if (process.env.NODE_ENV !== 'production') {
+    app.set('json spaces', 2);
+}
+
+// set the view engine to ejs
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, '/views'));
+
+// CORS for everything
+app.use(require('./lib/express/cors'));
+
+// General security/cache related headers + server header
+app.use(function (req, res, next) {
+    res.set({
+        'Strict-Transport-Security': 'includeSubDomains; max-age=631138519; preload',
+        'X-XSS-Protection':          '0',
+        'X-Content-Type-Options':    'nosniff',
+        'X-Frame-Options':           'DENY',
+        'Cache-Control':             'no-cache, no-store, max-age=0, must-revalidate',
+        Pragma:                      'no-cache',
+        Expires:                     0
+    });
+    next();
+});
+
+// ATTACH JWT value - FOR ANY RATE LIMITERS and JWT DECODE
+app.use(require('./lib/express/jwt')());
+
+/**
+ * Routes
+ */
+app.use('/assets', express.static('dist/assets'));
+app.use('/css', express.static('dist/css'));
+app.use('/fonts', express.static('dist/fonts'));
+app.use('/images', express.static('dist/images'));
+app.use('/js', express.static('dist/js'));
+app.use('/api', require('./routes/api/main'));
+app.use('/', require('./routes/main'));
+
+// production error handler
+// no stacktraces leaked to user
+app.use(function (err, req, res, next) {
+
+    let payload = {
+        error: {
+            code:    err.status,
+            message: err.public ? err.message : 'Internal Error'
+        }
+    };
+
+    if (process.env.NODE_ENV === 'development') {
+        payload.debug = {
+            stack:    typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null,
+            previous: err.previous
+        };
+    }
+
+    // Not every error is worth logging - but this is good for now until it gets annoying.
+    if (typeof err.stack !== 'undefined' && err.stack) {
+        log.warn(err.stack);
+    }
+
+    res
+        .status(err.status || 500)
+        .send(payload);
+});
+
+module.exports = app;

+ 23 - 0
src/backend/db.js

@@ -0,0 +1,23 @@
+'use strict';
+
+let config = require('config');
+
+if (!config.has('database')) {
+    throw new Error('Database config does not exist! Read the README for instructions.');
+}
+
+let knex = require('knex')({
+    client:     config.database.engine,
+    connection: {
+        host:     config.database.host,
+        user:     config.database.user,
+        password: config.database.password,
+        database: config.database.name,
+        port:     config.database.port
+    },
+    migrations: {
+        tableName: 'migrations'
+    }
+});
+
+module.exports = knex;

+ 45 - 0
src/backend/index.js

@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+
+'use strict';
+
+const config       = require('config');
+const app          = require('./app');
+const logger       = require('./logger').global;
+const migrate      = require('./migrate');
+const setup        = require('./setup');
+const apiValidator = require('./lib/validator/api');
+
+let port = process.env.PORT || 81;
+
+if (config.has('port')) {
+    port = config.get('port');
+}
+
+function appStart () {
+    return migrate.latest()
+        .then(() => {
+            return setup();
+        })
+        .then(() => {
+            return apiValidator.loadSchemas;
+        })
+        .then(() => {
+            const server = app.listen(port, () => {
+                logger.info('PID ' + process.pid + ' listening on port ' + port + ' ...');
+
+                process.on('SIGTERM', () => {
+                    logger.info('PID ' + process.pid + ' received SIGTERM');
+                    server.close(() => {
+                        logger.info('Stopping.');
+                        process.exit(0);
+                    });
+                });
+            });
+        })
+        .catch(err => {
+            logger.error(err.message);
+            setTimeout(appStart, 1000);
+        });
+}
+
+appStart();

+ 166 - 0
src/backend/internal/token.js

@@ -0,0 +1,166 @@
+'use strict';
+
+const _          = require('lodash');
+const error      = require('../lib/error');
+const userModel  = require('../models/user');
+const authModel  = require('../models/auth');
+const helpers    = require('../lib/helpers');
+const TokenModel = require('../models/token');
+
+module.exports = {
+
+    /**
+     * @param   {Object} data
+     * @param   {String} data.identity
+     * @param   {String} data.secret
+     * @param   {String} [data.scope]
+     * @param   {String} [data.expiry]
+     * @param   {String} [issuer]
+     * @returns {Promise}
+     */
+    getTokenFromEmail: (data, issuer) => {
+        let Token = new TokenModel();
+
+        data.scope  = data.scope || 'user';
+        data.expiry = data.expiry || '30d';
+
+        return userModel
+            .query()
+            .where('email', data.identity)
+            .andWhere('is_deleted', 0)
+            .andWhere('is_disabled', 0)
+            .first()
+            .then(user => {
+                if (user) {
+                    // Get auth
+                    return authModel
+                        .query()
+                        .where('user_id', '=', user.id)
+                        .where('type', '=', 'password')
+                        .first()
+                        .then(auth => {
+                            if (auth) {
+                                return auth.verifyPassword(data.secret)
+                                    .then(valid => {
+                                        if (valid) {
+
+                                            if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) {
+                                                // The scope requested doesn't exist as a role against the user,
+                                                // you shall not pass.
+                                                throw new error.AuthError('Invalid scope: ' + data.scope);
+                                            }
+
+                                            // Create a moment of the expiry expression
+                                            let expiry = helpers.parseDatePeriod(data.expiry);
+                                            if (expiry === null) {
+                                                throw new error.AuthError('Invalid expiry time: ' + data.expiry);
+                                            }
+
+                                            return Token.create({
+                                                iss:   issuer || 'api',
+                                                attrs: {
+                                                    id: user.id
+                                                },
+                                                scope: [data.scope]
+                                            }, {
+                                                expiresIn: expiry.unix()
+                                            })
+                                                .then(signed => {
+                                                    return {
+                                                        token:   signed.token,
+                                                        expires: expiry.toISOString()
+                                                    };
+                                                });
+                                        } else {
+                                            throw new error.AuthError('Invalid password');
+                                        }
+                                    });
+                            } else {
+                                throw new error.AuthError('No password auth for user');
+                            }
+                        });
+                } else {
+                    throw new error.AuthError('No relevant user found');
+                }
+            });
+    },
+
+    /**
+     * @param {Access} access
+     * @param {Object} [data]
+     * @param {String} [data.expiry]
+     * @param {String} [data.scope]   Only considered if existing token scope is admin
+     * @returns {Promise}
+     */
+    getFreshToken: (access, data) => {
+        let Token = new TokenModel();
+
+        data        = data || {};
+        data.expiry = data.expiry || '30d';
+
+        if (access && access.token.get('attrs').id) {
+
+            // Create a moment of the expiry expression
+            let expiry = helpers.parseDatePeriod(data.expiry);
+            if (expiry === null) {
+                throw new error.AuthError('Invalid expiry time: ' + data.expiry);
+            }
+
+            let token_attrs = {
+                id: access.token.get('attrs').id
+            };
+
+            // Only admins can request otherwise scoped tokens
+            let scope = access.token.get('scope');
+            if (data.scope && access.token.hasScope('admin')) {
+                scope = [data.scope];
+
+                if (data.scope === 'job-board' || data.scope === 'worker') {
+                    token_attrs.id = 0;
+                }
+            }
+
+            return Token.create({
+                iss:   'api',
+                scope: scope,
+                attrs: token_attrs
+            }, {
+                expiresIn: expiry.unix()
+            })
+                .then(signed => {
+                    return {
+                        token:   signed.token,
+                        expires: expiry.toISOString()
+                    };
+                });
+        } else {
+            throw new error.AssertionFailedError('Existing token contained invalid user data');
+        }
+    },
+
+    /**
+     * @param   {Object} user
+     * @returns {Promise}
+     */
+    getTokenFromUser: user => {
+        let Token  = new TokenModel();
+        let expiry = helpers.parseDatePeriod('1d');
+
+        return Token.create({
+            iss:   'api',
+            attrs: {
+                id: user.id
+            },
+            scope: ['user']
+        }, {
+            expiresIn: expiry.unix()
+        })
+            .then(signed => {
+                return {
+                    token:   signed.token,
+                    expires: expiry.toISOString(),
+                    user:    user
+                };
+            });
+    }
+};

+ 382 - 0
src/backend/internal/user.js

@@ -0,0 +1,382 @@
+'use strict';
+
+const _             = require('lodash');
+const error         = require('../lib/error');
+const userModel     = require('../models/user');
+const authModel     = require('../models/auth');
+const gravatar      = require('gravatar');
+const internalToken = require('./token');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalUser = {
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        let auth = data.auth;
+        delete data.auth;
+
+        data.avatar = data.avatar || '';
+        data.roles  = data.roles || [];
+
+        if (typeof data.is_disabled !== 'undefined') {
+            data.is_disabled = data.is_disabled ? 1 : 0;
+        }
+
+        return access.can('users:create', data)
+            .then(() => {
+                data.avatar = gravatar.url(data.email, {default: 'mm'});
+
+                return userModel
+                    .query()
+                    .omit(omissions())
+                    .insertAndFetch(data);
+            })
+            .then(user => {
+                return authModel
+                    .query()
+                    .insert({
+                        user_id: user.id,
+                        type:    auth.type,
+                        secret:  auth.secret,
+                        meta:    {}
+                    })
+                    .then(() => {
+                        return internalUser.get(access, {id: user.id, expand: ['services']});
+                    });
+            });
+    },
+
+    /**
+     * @param  {Access}  access
+     * @param  {Object}  data
+     * @param  {Integer} data.id
+     * @param  {String}  [data.name]
+     * @return {Promise}
+     */
+    update: (access, data) => {
+        if (typeof data.is_disabled !== 'undefined') {
+            data.is_disabled = data.is_disabled ? 1 : 0;
+        }
+
+        return access.can('users:update', data.id)
+            .then(() => {
+
+                // Make sure that the user being updated doesn't change their email to another user that is already using it
+                // 1. get user we want to update
+                return internalUser.get(access, {id: data.id})
+                    .then(user => {
+
+                        // 2. if email is to be changed, find other users with that email
+                        if (typeof data.email !== 'undefined') {
+                            data.email = data.email.toLowerCase().trim();
+
+                            if (user.email !== data.email) {
+                                return internalUser.isEmailAvailable(data.email, data.id)
+                                    .then(available => {
+                                        if (!available) {
+                                            throw new error.ValidationError('Email address already in use - ' + data.email);
+                                        }
+
+                                        return user;
+                                    });
+                            }
+                        }
+
+                        // No change to email:
+                        return user;
+                    });
+            })
+            .then(user => {
+                if (user.id !== data.id) {
+                    // Sanity check that something crazy hasn't happened
+                    throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
+                }
+
+                data.avatar = gravatar.url(data.email || user.email, {default: 'mm'});
+
+                return userModel
+                    .query()
+                    .omit(omissions())
+                    .patchAndFetchById(user.id, data)
+                    .then(saved_user => {
+                        return _.omit(saved_user, omissions());
+                    });
+            })
+            .then(() => {
+                return internalUser.get(access, {id: data.id, expand: ['services']});
+            });
+    },
+
+    /**
+     * @param  {Access}   access
+     * @param  {Object}   [data]
+     * @param  {Integer}  [data.id]          Defaults to the token user
+     * @param  {Array}    [data.expand]
+     * @param  {Array}    [data.omit]
+     * @return {Promise}
+     */
+    get: (access, data) => {
+        if (typeof data === 'undefined') {
+            data = {};
+        }
+
+        if (typeof data.id === 'undefined' || !data.id) {
+            data.id = access.token.get('attrs').id;
+        }
+
+        return access.can('users:get', data.id)
+            .then(() => {
+                let query = userModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .andWhere('id', data.id)
+                    .first();
+
+                // Custom omissions
+                if (typeof data.omit !== 'undefined' && data.omit !== null) {
+                    query.omit(data.omit);
+                }
+
+                if (typeof data.expand !== 'undefined' && data.expand !== null) {
+                    query.eager('[' + data.expand.join(', ') + ']');
+                }
+
+                return query;
+            })
+            .then(row => {
+                if (row) {
+                    return _.omit(row, omissions());
+                } else {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+            });
+    },
+
+    /**
+     * Checks if an email address is available, but if a user_id is supplied, it will ignore checking
+     * against that user.
+     *
+     * @param email
+     * @param user_id
+     */
+    isEmailAvailable: (email, user_id) => {
+        let query = userModel
+            .query()
+            .where('email', '=', email.toLowerCase().trim())
+            .where('is_deleted', 0)
+            .first();
+
+        if (typeof user_id !== 'undefined') {
+            query.where('id', '!=', user_id);
+        }
+
+        return query
+            .then(user => {
+                return !user;
+            });
+    },
+
+    /**
+     * @param {Access}  access
+     * @param {Object}  data
+     * @param {Integer} data.id
+     * @param {String}  [data.reason]
+     * @returns {Promise}
+     */
+    delete: (access, data) => {
+        return access.can('users:delete', data.id)
+            .then(() => {
+                return internalUser.get(access, {id: data.id});
+            })
+            .then(user => {
+                if (!user) {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+
+                // Make sure user can't delete themselves
+                if (user.id === access.token.get('attrs').id) {
+                    throw new error.PermissionError('You cannot delete yourself.');
+                }
+
+                return userModel
+                    .query()
+                    .where('id', user.id)
+                    .patch({
+                        is_deleted: 1
+                    });
+            })
+            .then(() => {
+                return true;
+            });
+    },
+
+    /**
+     * This will only count the users
+     *
+     * @param {Access}  access
+     * @param {String}  [search_query]
+     * @returns {*}
+     */
+    getCount: (access, search_query) => {
+        return access.can('users:list')
+            .then(() => {
+                let query = userModel
+                    .query()
+                    .count('id as count')
+                    .where('is_deleted', 0)
+                    .first();
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('user.name', 'like', '%' + search_query + '%')
+                            .orWhere('user.email', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                return query;
+            })
+            .then(row => {
+                return parseInt(row.count, 10);
+            });
+    },
+
+    /**
+     * All users
+     *
+     * @param   {Access}  access
+     * @param   {Integer} [start]
+     * @param   {Integer} [limit]
+     * @param   {Array}   [sort]
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, start, limit, sort, expand, search_query) => {
+        return access.can('users:list')
+            .then(() => {
+                let query = userModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .limit(limit ? limit : 100)
+                    .omit(['is_deleted']);
+
+                if (typeof start !== 'undefined' && start !== null) {
+                    query.offset(start);
+                }
+
+                if (typeof sort !== 'undefined' && sort !== null) {
+                    _.map(sort, (item) => {
+                        query.orderBy(item.field, item.dir);
+                    });
+                } else {
+                    query.orderBy('name', 'DESC');
+                }
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('name', 'like', '%' + search_query + '%')
+                            .orWhere('email', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    },
+
+    /**
+     * @param   {Access} access
+     * @param   {Integer} [id_requested]
+     * @returns {[String]}
+     */
+    getUserOmisionsByAccess: (access, id_requested) => {
+        let response = []; // Admin response
+
+        if (!access.token.hasScope('admin') && access.token.get('attrs').id !== id_requested) {
+            response = ['roles', 'is_deleted']; // Restricted response
+        }
+
+        return response;
+    },
+
+    /**
+     * @param  {Access}  access
+     * @param  {Object}  data
+     * @param  {Integer} data.id
+     * @param  {String}  data.type
+     * @param  {String}  data.secret
+     * @return {Promise}
+     */
+    setPassword: (access, data) => {
+        return access.can('users:password', data.id)
+            .then(() => {
+                return internalUser.get(access, {id: data.id});
+            })
+            .then(user => {
+                if (user.id !== data.id) {
+                    // Sanity check that something crazy hasn't happened
+                    throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
+                }
+
+                if (user.id === access.token.get('attrs').id) {
+                    // they're setting their own password. Make sure their current password is correct
+                    if (typeof data.current === 'undefined' || !data.current) {
+                        throw new error.ValidationError('Current password was not supplied');
+                    }
+
+                    return internalToken.getTokenFromEmail({
+                        identity: user.email,
+                        secret:   data.current
+                    })
+                        .then(() => {
+                            return user;
+                        });
+                }
+
+                return user;
+            })
+            .then(user => {
+                return authModel
+                    .query()
+                    .where('user_id', user.id)
+                    .andWhere('type', data.type)
+                    .patch({
+                        type:   data.type,
+                        secret: data.secret
+                    })
+                    .then(() => {
+                        return true;
+                    });
+            });
+    },
+
+    /**
+     * @param {Access}   access
+     * @param {Object}   data
+     * @param {Integer}  data.id
+     */
+    loginAs: (access, data) => {
+        return access.can('users:loginas', data.id)
+            .then(() => {
+                return internalUser.get(access, data);
+            })
+            .then(user => {
+                return internalToken.getTokenFromUser(user);
+            });
+    }
+};
+
+module.exports = internalUser;

+ 256 - 0
src/backend/lib/access.js

@@ -0,0 +1,256 @@
+'use strict';
+
+const _          = require('lodash');
+const validator  = require('ajv');
+const error      = require('./error');
+const userModel  = require('../models/user');
+const TokenModel = require('../models/token');
+const roleSchema = require('./access/roles.json');
+
+module.exports = function (token_string) {
+    let Token                 = new TokenModel();
+    let token_data            = null;
+    let initialised           = false;
+    let object_cache          = {};
+    let allow_internal_access = false;
+    let user_roles            = [];
+
+    /**
+     * Loads the Token object from the token string
+     *
+     * @returns {Promise}
+     */
+    this.init = () => {
+        return new Promise((resolve, reject) => {
+            if (initialised) {
+                resolve();
+            } else if (!token_string) {
+                reject(new error.PermissionError('Permission Denied'));
+            } else {
+                resolve(Token.load(token_string)
+                    .then((data) => {
+                        token_data = data;
+
+                        // At this point we need to load the user from the DB and make sure they:
+                        // - exist (and not soft deleted)
+                        // - still have the appropriate scopes for this token
+                        // This is only required when the User ID is supplied or if the token scope has `user`
+
+                        if (token_data.attrs.id || (typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'user') !== -1)) {
+                            // Has token user id or token user scope
+                            return userModel
+                                .query()
+                                .where('id', token_data.attrs.id)
+                                .andWhere('is_deleted', 0)
+                                .andWhere('is_disabled', 0)
+                                .first('id')
+                                .then((user) => {
+                                    if (user) {
+                                        // make sure user has all scopes of the token
+                                        // The `user` role is not added against the user row, so we have to just add it here to get past this check.
+                                        user.roles.push('user');
+
+                                        let is_ok = true;
+                                        _.forEach(token_data.scope, (scope_item) => {
+                                            if (_.indexOf(user.roles, scope_item) === -1) {
+                                                is_ok = false;
+                                            }
+                                        });
+
+                                        if (!is_ok) {
+                                            throw new error.AuthError('Invalid token scope for User');
+                                        } else {
+                                            initialised = true;
+                                            user_roles  = user.roles;
+                                        }
+                                    } else {
+                                        throw new error.AuthError('User cannot be loaded for Token');
+                                    }
+                                });
+                        } else {
+                            initialised = true;
+                        }
+                    }));
+            }
+        });
+    };
+
+    /**
+     * Fetches the object ids from the database, only once per object type, for this token.
+     * This only applies to USER token scopes, as all other tokens are not really bound
+     * by object scopes
+     *
+     * @param   {String} object_type
+     * @returns {Promise}
+     */
+    this.loadObjects = object_type => {
+        return new Promise((resolve, reject) => {
+            if (Token.hasScope('user')) {
+                if (typeof token_data.attrs.id === 'undefined' || !token_data.attrs.id) {
+                    reject(new error.AuthError('User Token supplied without a User ID'));
+                } else {
+                    let token_user_id = token_data.attrs.id ? token_data.attrs.id : 0;
+
+                    if (typeof object_cache[object_type] === 'undefined') {
+                        switch (object_type) {
+
+                            // USERS - should only return yourself
+                            case 'users':
+                                resolve(token_user_id ? [token_user_id] : []);
+                                break;
+
+                            // DEFAULT: null
+                            default:
+                                resolve(null);
+                                break;
+                        }
+                    } else {
+                        resolve(object_cache[object_type]);
+                    }
+                }
+            } else {
+                resolve(null);
+            }
+        })
+            .then(objects => {
+                object_cache[object_type] = objects;
+                return objects;
+            });
+    };
+
+    /**
+     * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema
+     *
+     * @param {String} permission_label
+     * @returns {Object}
+     */
+    this.getObjectSchema = permission_label => {
+        let base_object_type = permission_label.split(':').shift();
+
+        let schema = {
+            $id:                  'objects',
+            $schema:              'http://json-schema.org/draft-07/schema#',
+            description:          'Actor Properties',
+            type:                 'object',
+            additionalProperties: false,
+            properties:           {
+                user_id: {
+                    anyOf: [
+                        {
+                            type: 'number',
+                            enum: [Token.get('attrs').id]
+                        }
+                    ]
+                },
+                scope:   {
+                    type:    'string',
+                    pattern: '^' + Token.get('scope') + '$'
+                }
+            }
+        };
+
+        return this.loadObjects(base_object_type)
+            .then(object_result => {
+                if (typeof object_result === 'object' && object_result !== null) {
+                    schema.properties[base_object_type] = {
+                        type:    'number',
+                        enum:    object_result,
+                        minimum: 1
+                    };
+                } else {
+                    schema.properties[base_object_type] = {
+                        type:    'number',
+                        minimum: 1
+                    };
+                }
+
+                return schema;
+            });
+    };
+
+    return {
+
+        token: Token,
+
+        /**
+         *
+         * @param   {Boolean}  [allow_internal]
+         * @returns {Promise}
+         */
+        load: allow_internal => {
+            return new Promise(function (resolve/*, reject*/) {
+                if (token_string) {
+                    resolve(Token.load(token_string));
+                } else {
+                    allow_internal_access = allow_internal;
+                    resolve(allow_internal_access || null);
+                }
+            });
+        },
+
+        /**
+         *
+         * @param {String}  permission
+         * @param {*}       [data]
+         * @returns {Promise}
+         */
+        can: (permission, data) => {
+            if (allow_internal_access === true) {
+                return Promise.resolve(true);
+                //return true;
+            } else {
+                return this.init()
+                    .then(() => {
+                        // Initialised, token decoded ok
+
+                        return this.getObjectSchema(permission)
+                            .then(objectSchema => {
+                                let data_schema = {
+                                    [permission]: {
+                                        data:  data,
+                                        scope: Token.get('scope'),
+                                        roles: user_roles
+                                    }
+                                };
+
+                                let permissionSchema = {
+                                    $schema:              'http://json-schema.org/draft-07/schema#',
+                                    $async:               true,
+                                    $id:                  'permissions',
+                                    additionalProperties: false,
+                                    properties:           {}
+                                };
+
+                                permissionSchema.properties[permission] = require('./access/' + permission.replace(/:/gim, '-') + '.json');
+
+                                //console.log('objectSchema:', JSON.stringify(objectSchema, null, 2));
+                                //console.log('permissionSchema:', JSON.stringify(permissionSchema, null, 2));
+                                //console.log('data_schema:', JSON.stringify(data_schema, null, 2));
+
+                                let ajv = validator({
+                                    verbose:      true,
+                                    allErrors:    true,
+                                    format:       'full',
+                                    missingRefs:  'fail',
+                                    breakOnError: true,
+                                    coerceTypes:  true,
+                                    schemas:      [
+                                        roleSchema,
+                                        objectSchema,
+                                        permissionSchema
+                                    ]
+                                });
+
+                                return ajv.validate('permissions', data_schema);
+                            });
+                    })
+                    .catch(err => {
+                        //console.log(err.message);
+                        //console.log(err.errors);
+
+                        throw new error.PermissionError('Permission Denied', err);
+                    });
+            }
+        }
+    };
+};

+ 45 - 0
src/backend/lib/access/roles.json

@@ -0,0 +1,45 @@
+{
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "$id": "roles",
+    "definitions": {
+        "admin": {
+            "type": "object",
+            "required": [
+                "scope",
+                "roles"
+            ],
+            "properties": {
+                "scope": {
+                    "type": "array",
+                    "contains": {
+                        "type": "string",
+                        "pattern": "^user$"
+                    }
+                },
+                "roles": {
+                    "type": "array",
+                    "contains": {
+                        "type": "string",
+                        "pattern": "^admin$"
+                    }
+                }
+            }
+        },
+        "user": {
+            "type": "object",
+            "required": [
+                "scope"
+            ],
+            "properties": {
+                "scope": {
+                    "type": "array",
+                    "contains": {
+                        "type": "string",
+                        "pattern": "^user$"
+                    }
+                }
+            }
+        }
+    }
+}
+

+ 23 - 0
src/backend/lib/access/users-get.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["data", "scope"],
+      "properties": {
+        "data": {
+          "$ref": "objects#/properties/users"
+        },
+        "scope": {
+          "type": "array",
+          "contains": {
+            "type": "string",
+            "pattern": "^user$"
+          }
+        }
+      }
+    }
+  ]
+}

+ 7 - 0
src/backend/lib/access/users-list.json

@@ -0,0 +1,7 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    }
+  ]
+}

+ 83 - 0
src/backend/lib/error.js

@@ -0,0 +1,83 @@
+'use strict';
+
+const _    = require('lodash');
+const util = require('util');
+
+module.exports = {
+
+    PermissionError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = 'Permission Denied';
+        this.public   = true;
+        this.status   = 403;
+    },
+
+    ItemNotFoundError: function (id, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = 'Item Not Found - ' + id;
+        this.public   = true;
+        this.status   = 404;
+    },
+
+    AuthError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = message;
+        this.public   = true;
+        this.status   = 401;
+    },
+
+    InternalError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = message;
+        this.status   = 500;
+        this.public   = false;
+    },
+
+    InternalValidationError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = message;
+        this.status   = 400;
+        this.public   = false;
+    },
+
+    CacheError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.message  = message;
+        this.previous = previous;
+        this.status   = 500;
+        this.public   = false;
+    },
+
+    ValidationError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = message;
+        this.public   = true;
+        this.status   = 400;
+    },
+
+    AssertionFailedError: function (message, previous) {
+        Error.captureStackTrace(this, this.constructor);
+        this.name     = this.constructor.name;
+        this.previous = previous;
+        this.message  = message;
+        this.public   = false;
+        this.status   = 400;
+    }
+};
+
+_.forEach(module.exports, function (error) {
+    util.inherits(error, Error);
+});

+ 32 - 0
src/backend/lib/express/cors.js

@@ -0,0 +1,32 @@
+'use strict';
+
+const validator = require('../validator');
+
+module.exports = function (req, res, next) {
+
+    if (req.headers.origin) {
+
+        // very relaxed validation....
+        validator({
+            type:    'string',
+            pattern: '^[a-z\\-]+:\\/\\/(?:[\\w\\-\\.]+(:[0-9]+)?/?)?$'
+        }, req.headers.origin)
+            .then(function () {
+                res.set({
+                    'Access-Control-Allow-Origin':      req.headers.origin,
+                    'Access-Control-Allow-Credentials': true,
+                    'Access-Control-Allow-Methods':     'OPTIONS, GET, POST',
+                    'Access-Control-Allow-Headers':     'Content-Type, Cache-Control, Pragma, Expires, Authorization, X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit',
+                    'Access-Control-Max-Age':           5 * 60,
+                    'Access-Control-Expose-Headers':    'X-Dataset-Total, X-Dataset-Offset, X-Dataset-Limit'
+                });
+                next();
+            })
+            .catch(next);
+
+    } else {
+        // No origin
+        next();
+    }
+
+};

+ 17 - 0
src/backend/lib/express/jwt-decode.js

@@ -0,0 +1,17 @@
+'use strict';
+
+const Access = require('../access');
+
+module.exports = () => {
+    return function (req, res, next) {
+        res.locals.access = null;
+        let access        = new Access(res.locals.token || null);
+        access.load()
+            .then(() => {
+                res.locals.access = access;
+                next();
+            })
+            .catch(next);
+    };
+};
+

+ 15 - 0
src/backend/lib/express/jwt.js

@@ -0,0 +1,15 @@
+'use strict';
+
+module.exports = function () {
+    return function (req, res, next) {
+        if (req.headers.authorization) {
+            let parts = req.headers.authorization.split(' ');
+
+            if (parts && parts[0] === 'Bearer' && parts[1]) {
+                res.locals.token = parts[1];
+            }
+        }
+
+        next();
+    };
+};

+ 57 - 0
src/backend/lib/express/pagination.js

@@ -0,0 +1,57 @@
+'use strict';
+
+let _ = require('lodash');
+
+module.exports = function (default_sort, default_offset, default_limit, max_limit) {
+
+    /**
+     * This will setup the req query params with filtered data and defaults
+     *
+     * sort    will be an array of fields and their direction
+     * offset  will be an int, defaulting to zero if no other default supplied
+     * limit   will be an int, defaulting to 50 if no other default supplied, and limited to the max if that was supplied
+     *
+     */
+
+    return function (req, res, next) {
+
+        req.query.offset = typeof req.query.limit === 'undefined' ? default_offset || 0 : parseInt(req.query.offset, 10);
+        req.query.limit  = typeof req.query.limit === 'undefined' ? default_limit || 50 : parseInt(req.query.limit, 10);
+
+        if (max_limit && req.query.limit > max_limit) {
+            req.query.limit = max_limit;
+        }
+
+        // Sorting
+        let sort       = typeof req.query.sort === 'undefined' ? default_sort : req.query.sort;
+        let myRegexp   = /.*\.(asc|desc)$/ig;
+        let sort_array = [];
+
+        sort = sort.split(',');
+        _.map(sort, function (val) {
+            let matches = myRegexp.exec(val);
+
+            if (matches !== null) {
+                let dir = matches[1];
+                sort_array.push({
+                    field: val.substr(0, val.length - (dir.length + 1)),
+                    dir:   dir.toLowerCase()
+                });
+            } else {
+                sort_array.push({
+                    field: val,
+                    dir:   'asc'
+                });
+            }
+        });
+
+        // Sort will now be in this format:
+        // [
+        //    { field: 'field1', dir: 'asc' },
+        //    { field: 'field2', dir: 'desc' }
+        // ]
+
+        req.query.sort = sort_array;
+        next();
+    };
+};

+ 11 - 0
src/backend/lib/express/user-id-from-me.js

@@ -0,0 +1,11 @@
+'use strict';
+
+module.exports = (req, res, next) => {
+    if (req.params.user_id === 'me' && res.locals.access) {
+        req.params.user_id = res.locals.access.token.get('attrs').id;
+    } else {
+        req.params.user_id = parseInt(req.params.user_id, 10);
+    }
+
+    next();
+};

+ 35 - 0
src/backend/lib/helpers.js

@@ -0,0 +1,35 @@
+'use strict';
+
+const moment = require('moment');
+const _      = require('lodash');
+
+module.exports = {
+
+    /**
+     * Takes an expression such as 30d and returns a moment object of that date in future
+     *
+     * Key      Shorthand
+     * ==================
+     * years         y
+     * quarters      Q
+     * months        M
+     * weeks         w
+     * days          d
+     * hours         h
+     * minutes       m
+     * seconds       s
+     * milliseconds  ms
+     *
+     * @param {String}  expression
+     * @returns {Object}
+     */
+    parseDatePeriod: function (expression) {
+        let matches = expression.match(/^([0-9]+)(y|Q|M|w|d|h|m|s|ms)$/m);
+        if (matches) {
+            return moment().add(matches[1], matches[2]);
+        }
+
+        return null;
+    }
+
+};

+ 57 - 0
src/backend/lib/migrate_template.js

@@ -0,0 +1,57 @@
+'use strict';
+
+const migrate_name = 'identifier_for_migrate';
+const logger       = require('../logger').migrate;
+
+/**
+ * Migrate
+ *
+ * @see http://knexjs.org/#Schema
+ *
+ * @param {Object} knex
+ * @param {Promise} Promise
+ * @returns {Promise}
+ */
+exports.up = function (knex, Promise) {
+
+    logger.info('[' + migrate_name + '] Migrating Up...');
+
+    // Create Table example:
+
+    /*return knex.schema.createTable('notification', (table) => {
+         table.increments().primary();
+         table.string('name').notNull();
+         table.string('type').notNull();
+         table.integer('created_on').notNull();
+         table.integer('modified_on').notNull();
+     })
+     .then(function () {
+        logger.info('[' + migrate_name + '] Notification Table created');
+     });*/
+
+    logger.info('[' + migrate_name + '] Migrating Up Complete');
+
+    return Promise.resolve(true);
+};
+
+/**
+ * Undo Migrate
+ *
+ * @param {Object} knex
+ * @param {Promise} Promise
+ * @returns {Promise}
+ */
+exports.down = function (knex, Promise) {
+    logger.info('[' + migrate_name + '] Migrating Down...');
+
+    // Drop table example:
+
+    /*return knex.schema.dropTable('notification')
+     .then(() => {
+        logger.info('[' + migrate_name + '] Notification Table dropped');
+     });*/
+
+    logger.info('[' + migrate_name + '] Migrating Down Complete');
+
+    return Promise.resolve(true);
+};

+ 47 - 0
src/backend/lib/validator/api.js

@@ -0,0 +1,47 @@
+'use strict';
+
+const error  = require('../error');
+const path   = require('path');
+const parser = require('json-schema-ref-parser');
+
+const ajv = require('ajv')({
+    verbose:        true,
+    validateSchema: true,
+    allErrors:      false,
+    format:         'full',
+    coerceTypes:    true
+});
+
+/**
+ * @param {Object} schema
+ * @param {Object} payload
+ * @returns {Promise}
+ */
+function apiValidator (schema, payload/*, description*/) {
+    return new Promise(function Promise_apiValidator (resolve, reject) {
+        if (typeof payload === 'undefined') {
+            reject(new error.ValidationError('Payload is undefined'));
+        }
+
+        let validate = ajv.compile(schema);
+        let valid    = validate(payload);
+
+        if (valid && !validate.errors) {
+            resolve(payload);
+        } else {
+            let message = ajv.errorsText(validate.errors);
+            let err     = new error.ValidationError(message);
+            err.debug   = [validate.errors, payload];
+            reject(err);
+        }
+    });
+}
+
+apiValidator.loadSchemas = parser
+    .dereference(path.resolve('src/backend/schema/index.json'))
+    .then(schema => {
+        ajv.addSchema(schema);
+        return schema;
+    });
+
+module.exports = apiValidator;

+ 53 - 0
src/backend/lib/validator/index.js

@@ -0,0 +1,53 @@
+'use strict';
+
+const _           = require('lodash');
+const error       = require('../error');
+const definitions = require('../../schema/definitions.json');
+
+RegExp.prototype.toJSON = RegExp.prototype.toString;
+
+const ajv = require('ajv')({
+    verbose:     true, //process.env.NODE_ENV === 'development',
+    allErrors:   true,
+    format:      'full',  // strict regexes for format checks
+    coerceTypes: true,
+    schemas:     [
+        definitions
+    ]
+});
+
+/**
+ *
+ * @param {Object} schema
+ * @param {Object} payload
+ * @returns {Promise}
+ */
+function validator (schema, payload) {
+    return new Promise(function (resolve, reject) {
+        if (!payload) {
+            reject(new error.InternalValidationError('Payload is falsy'));
+        } else {
+            try {
+                let validate = ajv.compile(schema);
+
+                let valid = validate(payload);
+                if (valid && !validate.errors) {
+                    resolve(_.cloneDeep(payload));
+                } else {
+                    //console.log('Validation failed:', schema, payload);
+
+                    let message = ajv.errorsText(validate.errors);
+                    reject(new error.InternalValidationError(message));
+                }
+
+            } catch (err) {
+                reject(err);
+            }
+
+        }
+
+    });
+
+}
+
+module.exports = validator;

+ 7 - 0
src/backend/logger.js

@@ -0,0 +1,7 @@
+const {Signale} = require('signale');
+
+module.exports = {
+    global:  new Signale({scope: 'Global    '}),
+    migrate: new Signale({scope: 'Migrate   '}),
+    express: new Signale({scope: 'Express   '})
+};

+ 17 - 0
src/backend/migrate.js

@@ -0,0 +1,17 @@
+'use strict';
+
+const db     = require('./db');
+const logger = require('./logger').migrate;
+
+module.exports = {
+    latest: function () {
+        return db.migrate.currentVersion()
+            .then(version => {
+                logger.info('Current database version:', version);
+                return db.migrate.latest({
+                    tableName: 'migrations',
+                    directory: 'src/backend/migrations'
+                });
+            });
+    }
+};

+ 60 - 0
src/backend/migrations/20180618015850_initial.js

@@ -0,0 +1,60 @@
+'use strict';
+
+const migrate_name = 'initial-schema';
+const logger       = require('../logger').migrate;
+
+/**
+ * Migrate
+ *
+ * @see http://knexjs.org/#Schema
+ *
+ * @param   {Object}  knex
+ * @param   {Promise} Promise
+ * @returns {Promise}
+ */
+exports.up = function (knex/*, Promise*/) {
+    logger.info('[' + migrate_name + '] Migrating Up...');
+
+    return knex.schema.createTable('auth', table => {
+        table.increments().primary();
+        table.dateTime('created_on').notNull();
+        table.dateTime('modified_on').notNull();
+        table.integer('user_id').notNull().unsigned();
+        table.string('type', 30).notNull();
+        table.string('secret').notNull();
+        table.json('meta').notNull();
+        table.integer('is_deleted').notNull().unsigned().defaultTo(0);
+    })
+        .then(() => {
+            logger.info('[' + migrate_name + '] auth Table created');
+
+            return knex.schema.createTable('user', table => {
+                table.increments().primary();
+                table.dateTime('created_on').notNull();
+                table.dateTime('modified_on').notNull();
+                table.integer('is_deleted').notNull().unsigned().defaultTo(0);
+                table.integer('is_disabled').notNull().unsigned().defaultTo(0);
+                table.string('email').notNull();
+                table.string('name').notNull();
+                table.string('nickname').notNull();
+                table.string('avatar').notNull();
+                table.json('roles').notNull();
+            });
+        })
+        .then(() => {
+            logger.info('[' + migrate_name + '] user Table created');
+        });
+
+};
+
+/**
+ * Undo Migrate
+ *
+ * @param   {Object}  knex
+ * @param   {Promise} Promise
+ * @returns {Promise}
+ */
+exports.down = function (knex, Promise) {
+    logger.warn('[' + migrate_name + '] You can\'t migrate down the initial data.');
+    return Promise.resolve(true);
+};

+ 82 - 0
src/backend/models/auth.js

@@ -0,0 +1,82 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const bcrypt = require('bcrypt-then');
+const db     = require('../db');
+const Model  = require('objection').Model;
+const User   = require('./user');
+
+Model.knex(db);
+
+function encryptPassword () {
+    /* jshint -W040 */
+    let _this = this;
+
+    if (_this.type === 'password' && _this.secret) {
+        return bcrypt.hash(_this.secret, 13)
+            .then(function (hash) {
+                _this.secret = hash;
+            });
+    }
+
+    return null;
+}
+
+class Auth extends Model {
+    $beforeInsert (queryContext) {
+        this.created_on  = Model.raw('NOW()');
+        this.modified_on = Model.raw('NOW()');
+
+        return encryptPassword.apply(this, queryContext);
+    }
+
+    $beforeUpdate (queryContext) {
+        this.modified_on = Model.raw('NOW()');
+        return encryptPassword.apply(this, queryContext);
+    }
+
+    /**
+     * Verify a plain password against the encrypted password
+     *
+     * @param {String} password
+     * @returns {Promise}
+     */
+    verifyPassword (password) {
+        return bcrypt.compare(password, this.secret);
+    }
+
+    static get name () {
+        return 'Auth';
+    }
+
+    static get tableName () {
+        return 'auth';
+    }
+
+    static get jsonAttributes () {
+        return ['meta'];
+    }
+
+    static get relationMappings () {
+        return {
+            user: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'auth.user_id',
+                    to:   'user.id'
+                },
+                filter:     {
+                    is_deleted: 0
+                },
+                modify:     function (qb) {
+                    qb.omit(['is_deleted']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = Auth;

+ 133 - 0
src/backend/models/token.js

@@ -0,0 +1,133 @@
+/**
+ NOTE: This is not a database table, this is a model of a Token object that can be created/loaded
+ and then has abilities after that.
+ */
+
+'use strict';
+
+const _      = require('lodash');
+const config = require('config');
+const jwt    = require('jsonwebtoken');
+const crypto = require('crypto');
+const error  = require('../lib/error');
+const ALGO   = 'RS256';
+
+module.exports = function () {
+    const public_key  = config.get('jwt.pub');
+    const private_key = config.get('jwt.key');
+
+    let token_data = {};
+
+    return {
+        /**
+         * @param {Object}  payload
+         * @param {Object}  [user_options]
+         * @param {Integer} [user_options.expires]
+         * @returns {Promise}
+         */
+        create: (payload, user_options) => {
+
+            user_options = user_options || {};
+
+            // sign with RSA SHA256
+            let options = {
+                algorithm: ALGO
+            };
+
+            if (typeof user_options.expires !== 'undefined' && user_options.expires) {
+                options.expiresIn = user_options.expires;
+            }
+
+            payload.jti = crypto.randomBytes(12)
+                .toString('base64')
+                .substr(-8);
+
+            return new Promise((resolve, reject) => {
+                jwt.sign(payload, private_key, options, (err, token) => {
+                    if (err) {
+                        reject(err);
+                    } else {
+                        token_data = payload;
+                        resolve({
+                            token:   token,
+                            payload: payload
+                        });
+                    }
+
+                });
+            });
+
+        },
+
+        /**
+         * @param {String} token
+         * @returns {Promise}
+         */
+        load: function (token) {
+            return new Promise((resolve, reject) => {
+                try {
+                    if (!token || token === null || token === 'null') {
+                        reject('Empty token');
+                    } else {
+                        jwt.verify(token, public_key, {ignoreExpiration: false, algorithms: [ALGO]}, (err, result) => {
+                            if (err) {
+
+                                if (err.name === 'TokenExpiredError') {
+                                    reject(new error.AuthError('Token has expired', err));
+                                } else {
+                                    reject(err);
+                                }
+
+                            } else {
+                                token_data = result;
+
+                                // Hack: some tokens out in the wild have a scope of 'all' instead of 'user'.
+                                // For 30 days at least, we need to replace 'all' with user.
+                                if ((typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'all') !== -1)) {
+                                    //console.log('Warning! Replacing "all" scope with "user"');
+
+                                    token_data.scope = ['user'];
+                                }
+
+                                resolve(token_data);
+                            }
+                        });
+                    }
+                } catch (err) {
+                    reject(err);
+                }
+            });
+
+        },
+
+        /**
+         * Does the token have the specified scope?
+         *
+         * @param   {String}  scope
+         * @returns {Boolean}
+         */
+        hasScope: function (scope) {
+            return typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, scope) !== -1;
+        },
+
+        /**
+         * @param  {String}  key
+         * @return {*}
+         */
+        get: function (key) {
+            if (typeof token_data[key] !== 'undefined') {
+                return token_data[key];
+            }
+
+            return null;
+        },
+
+        /**
+         * @param  {String}  key
+         * @param  {*}       value
+         */
+        set: function (key, value) {
+            token_data[key] = value;
+        }
+    };
+};

+ 35 - 0
src/backend/models/user.js

@@ -0,0 +1,35 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+
+Model.knex(db);
+
+class User extends Model {
+    $beforeInsert () {
+        this.created_on  = Model.raw('NOW()');
+        this.modified_on = Model.raw('NOW()');
+    }
+
+    $beforeUpdate () {
+        this.modified_on = Model.raw('NOW()');
+    }
+
+    static get name () {
+        return 'User';
+    }
+
+    static get tableName () {
+        return 'user';
+    }
+
+    static get jsonAttributes () {
+        return ['roles'];
+    }
+
+}
+
+module.exports = User;

+ 32 - 0
src/backend/routes/api/main.js

@@ -0,0 +1,32 @@
+'use strict';
+
+const express = require('express');
+const pjson   = require('../../../../package.json');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * Health Check
+ * GET /api
+ */
+router.get('/', (req, res/*, next*/) => {
+    let version = pjson.version.split('-').shift().split('.');
+
+    res.status(200).send({
+        status:  'OK',
+        version: {
+            major:    parseInt(version.shift(), 10),
+            minor:    parseInt(version.shift(), 10),
+            revision: parseInt(version.shift(), 10)
+        }
+    });
+});
+
+router.use('/tokens', require('./tokens'));
+router.use('/users', require('./users'));
+
+module.exports = router;

+ 56 - 0
src/backend/routes/api/tokens.js

@@ -0,0 +1,56 @@
+'use strict';
+
+const express       = require('express');
+const jwtdecode     = require('../../lib/express/jwt-decode');
+const internalToken = require('../../internal/token');
+const apiValidator  = require('../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+
+    /**
+     * GET /tokens
+     *
+     * Get a new Token, given they already have a token they want to refresh
+     * We also piggy back on to this method, allowing admins to get tokens
+     * for services like Job board and Worker.
+     */
+    .get(jwtdecode(), (req, res, next) => {
+        internalToken.getFreshToken(res.locals.access, {
+            expiry: (typeof req.query.expiry !== 'undefined' ? req.query.expiry : null),
+            scope:  (typeof req.query.scope !== 'undefined' ? req.query.scope : null)
+        })
+            .then(data => {
+                res.status(200)
+                    .send(data);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /tokens
+     *
+     * Create a new Token
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/tokens#/links/0/schema'}, req.body)
+            .then(payload => {
+                return internalToken.getTokenFromEmail(payload);
+            })
+            .then(data => {
+                res.status(200)
+                    .send(data);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 256 - 0
src/backend/routes/api/users.js

@@ -0,0 +1,256 @@
+'use strict';
+
+const express      = require('express');
+const validator    = require('../../lib/validator');
+const jwtdecode    = require('../../lib/express/jwt-decode');
+const pagination   = require('../../lib/express/pagination');
+const userIdFromMe = require('../../lib/express/user-id-from-me');
+const internalUser = require('../../internal/user');
+const apiValidator = require('../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/users
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/users
+     *
+     * Retrieve all users
+     */
+    .get(pagination('name', 0, 50, 300), (req, res, next) => {
+        validator({
+            additionalProperties: false,
+            required:             ['sort'],
+            properties:           {
+                sort:   {
+                    $ref: 'definitions#/definitions/sort'
+                },
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            sort:   req.query.sort,
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then((data) => {
+                return Promise.all([
+                    internalUser.getCount(res.locals.access, data.query),
+                    internalUser.getAll(res.locals.access, req.query.offset, req.query.limit, data.sort, data.expand, data.query)
+                ]);
+            })
+            .then((data) => {
+                res.setHeader('X-Dataset-Total', data.shift());
+                res.setHeader('X-Dataset-Offset', req.query.offset);
+                res.setHeader('X-Dataset-Limit', req.query.limit);
+                return data.shift();
+            })
+            .then((users) => {
+                res.status(200)
+                    .send(users);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/users
+     *
+     * Create a new User
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/users#/links/1/schema'}, req.body)
+            .then((payload) => {
+                return internalUser.create(res.locals.access, payload);
+            })
+            .then((result) => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific user
+ *
+ * /api/users/123
+ */
+router
+    .route('/:user_id')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+    .all(userIdFromMe)
+
+    /**
+     * GET /users/123 or /users/me
+     *
+     * Retrieve a specific user
+     */
+    .get((req, res, next) => {
+        validator({
+            required:             ['user_id'],
+            additionalProperties: false,
+            properties:           {
+                user_id: {
+                    $ref: 'definitions#/definitions/id'
+                },
+                expand:  {
+                    $ref: 'definitions#/definitions/expand'
+                }
+            }
+        }, {
+            user_id: req.params.user_id,
+            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+        })
+            .then((data) => {
+                return internalUser.get(res.locals.access, {
+                    id:     data.user_id,
+                    expand: data.expand,
+                    omit:   internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id)
+                });
+            })
+            .then((user) => {
+                res.status(200)
+                    .send(user);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/users/123
+     *
+     * Update and existing user
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/users#/links/2/schema'}, req.body)
+            .then((payload) => {
+                payload.id = req.params.user_id;
+                return internalUser.update(res.locals.access, payload);
+            })
+            .then((result) => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/users/123
+     *
+     * Update and existing user
+     */
+    .delete((req, res, next) => {
+        internalUser.delete(res.locals.access, {id: req.params.user_id})
+            .then((result) => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific user auth
+ *
+ * /api/users/123/auth
+ */
+router
+    .route('/:user_id/auth')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+    .all(userIdFromMe)
+
+    /**
+     * PUT /api/users/123/auth
+     *
+     * Update password for a user
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/users#/links/4/schema'}, req.body)
+            .then(payload => {
+                payload.id = req.params.user_id;
+                return internalUser.setPassword(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific user service settings
+ *
+ * /api/users/123/services
+ */
+router
+    .route('/:user_id/services')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+    .all(userIdFromMe)
+
+    /**
+     * POST /api/users/123/services
+     *
+     * Sets Service Settings for a user
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/users#/links/5/schema'}, req.body)
+            .then((payload) => {
+                payload.id = req.params.user_id;
+                return internalUser.setServiceSettings(res.locals.access, payload);
+            })
+            .then((result) => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific user login as
+ *
+ * /api/users/123/login
+ */
+router
+    .route('/:user_id/login')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * POST /api/users/123/login
+     *
+     * Log in as a user
+     */
+    .post((req, res, next) => {
+        internalUser.loginAs(res.locals.access, {id: parseInt(req.params.user_id, 10)})
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 44 - 0
src/backend/routes/main.js

@@ -0,0 +1,44 @@
+'use strict';
+
+const express = require('express');
+const fs      = require('fs');
+const PACKAGE = require('../../../package.json');
+
+const router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * GET /login
+ */
+router.get('/login', function (req, res, next) {
+    res.render('login', {
+        version: PACKAGE.version
+    });
+});
+
+/**
+ * GET .*
+ */
+router.get(/(.*)/, function (req, res, next) {
+    req.params.page = req.params['0'];
+    if (req.params.page === '/') {
+        res.render('index', {
+            version: PACKAGE.version
+        });
+    } else {
+        fs.readFile('dist' + req.params.page, 'utf8', function (err, data) {
+            if (err) {
+                res.render('index', {
+                    version: PACKAGE.version
+                });
+            } else {
+                res.contentType('text/html').end(data);
+            }
+        });
+    }
+});
+
+module.exports = router;

+ 139 - 0
src/backend/schema/definitions.json

@@ -0,0 +1,139 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "definitions",
+  "definitions": {
+    "id": {
+      "description": "Unique identifier",
+      "example": 123456,
+      "readOnly": true,
+      "type": "integer",
+      "minimum": 1
+    },
+    "token": {
+      "type": "string",
+      "minLength": 10
+    },
+    "expand": {
+      "anyOf": [
+        {
+          "type": "null"
+        },
+        {
+          "type": "array",
+          "minItems": 1,
+          "items": {
+            "type": "string"
+          }
+        }
+      ]
+    },
+    "sort": {
+      "type": "array",
+      "minItems": 1,
+      "items": {
+        "type": "object",
+        "required": [
+          "field",
+          "dir"
+        ],
+        "additionalProperties": false,
+        "properties": {
+          "field": {
+            "type": "string"
+          },
+          "dir": {
+            "type": "string",
+            "pattern": "^(asc|desc)$"
+          }
+        }
+      }
+    },
+    "query": {
+      "anyOf": [
+        {
+          "type": "null"
+        },
+        {
+          "type": "string",
+          "minLength": 1,
+          "maxLength": 255
+        }
+      ]
+    },
+    "criteria": {
+      "anyOf": [
+        {
+          "type": "null"
+        },
+        {
+          "type": "object"
+        }
+      ]
+    },
+    "fields": {
+      "anyOf": [
+        {
+          "type": "null"
+        },
+        {
+          "type": "array",
+          "minItems": 1,
+          "items": {
+            "type": "string"
+          }
+        }
+      ]
+    },
+    "omit": {
+      "anyOf": [
+        {
+          "type": "null"
+        },
+        {
+          "type": "array",
+          "minItems": 1,
+          "items": {
+            "type": "string"
+          }
+        }
+      ]
+    },
+    "created_on": {
+      "description": "Date and time of creation",
+      "format": "date-time",
+      "readOnly": true,
+      "type": "string"
+    },
+    "modified_on": {
+      "description": "Date and time of last update",
+      "format": "date-time",
+      "readOnly": true,
+      "type": "string"
+    },
+    "user_id": {
+      "description": "User ID",
+      "example": 1234,
+      "type": "integer",
+      "minimum": 1
+    },
+    "name": {
+      "type": "string",
+      "minLength": 1,
+      "maxLength": 255
+    },
+    "email": {
+      "description": "Email Address",
+      "example": "[email protected]",
+      "format": "email",
+      "type": "string",
+      "minLength": 8,
+      "maxLength": 100
+    },
+    "password": {
+      "description": "Password",
+      "type": "string",
+      "minLength": 8,
+      "maxLength": 255
+    }
+  }
+}

+ 100 - 0
src/backend/schema/endpoints/tokens.json

@@ -0,0 +1,100 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/tokens",
+  "title": "Token",
+  "description": "Tokens are required to authenticate against the API",
+  "stability": "stable",
+  "type": "object",
+  "definitions": {
+    "identity": {
+      "description": "Email Address or other 3rd party providers identifier",
+      "example": "[email protected]",
+      "type": "string"
+    },
+    "secret": {
+      "description": "A password or key",
+      "example": "correct horse battery staple",
+      "type": "string"
+    },
+    "token": {
+      "description": "JWT",
+      "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk",
+      "type": "string"
+    },
+    "expires": {
+      "description": "Token expiry time",
+      "format": "date-time",
+      "type": "string"
+    },
+    "scope": {
+      "description": "Scope of the Token, defaults to 'user'",
+      "example": "user",
+      "type": "string"
+    }
+  },
+  "links": [
+    {
+      "title": "Create",
+      "description": "Creates a new token.",
+      "href": "/tokens",
+      "access": "public",
+      "method": "POST",
+      "rel": "create",
+      "schema": {
+        "type": "object",
+        "required": [
+          "identity",
+          "secret"
+        ],
+        "properties": {
+          "identity": {
+            "$ref": "#/definitions/identity"
+          },
+          "secret": {
+            "$ref": "#/definitions/secret"
+          },
+          "scope": {
+            "$ref": "#/definitions/scope"
+          }
+        }
+      },
+      "targetSchema": {
+        "type": "object",
+        "properties": {
+          "token": {
+            "$ref": "#/definitions/token"
+          },
+          "expires": {
+            "$ref": "#/definitions/expires"
+          }
+        }
+      }
+    },
+    {
+      "title": "Refresh",
+      "description": "Returns a new token.",
+      "href": "/tokens",
+      "access": "private",
+      "method": "GET",
+      "rel": "self",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {},
+      "targetSchema": {
+        "type": "object",
+        "properties": {
+          "token": {
+            "$ref": "#/definitions/token"
+          },
+          "expires": {
+            "$ref": "#/definitions/expires"
+          },
+          "scope": {
+            "$ref": "#/definitions/scope"
+          }
+        }
+      }
+    }
+  ]
+}

+ 240 - 0
src/backend/schema/endpoints/users.json

@@ -0,0 +1,240 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/users",
+  "title": "Users",
+  "description": "Endpoints relating to Users",
+  "stability": "stable",
+  "type": "object",
+  "definitions": {
+    "id": {
+      "$ref": "../definitions.json#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "../definitions.json#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "../definitions.json#/definitions/modified_on"
+    },
+    "name": {
+      "description": "Name",
+      "example": "Jamie Curnow",
+      "type": "string",
+      "minLength": 2,
+      "maxLength": 100
+    },
+    "nickname": {
+      "description": "Nickname",
+      "example": "Jamie",
+      "type": "string",
+      "minLength": 2,
+      "maxLength": 50
+    },
+    "email": {
+      "$ref": "../definitions.json#/definitions/email"
+    },
+    "avatar": {
+      "description": "Avatar",
+      "example": "http://somewhere.jpg",
+      "type": "string",
+      "minLength": 2,
+      "maxLength": 150,
+      "readOnly": true
+    },
+    "roles": {
+      "description": "Roles",
+      "example": [
+        "admin"
+      ],
+      "type": "array"
+    },
+    "is_disabled": {
+      "description": "Is Disabled",
+      "example": false,
+      "type": "boolean"
+    }
+  },
+  "links": [
+    {
+      "title": "List",
+      "description": "Returns a list of Users",
+      "href": "/users",
+      "access": "private",
+      "method": "GET",
+      "rel": "self",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "targetSchema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Create",
+      "description": "Creates a new User",
+      "href": "/users",
+      "access": "private",
+      "method": "POST",
+      "rel": "create",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {
+        "type": "object",
+        "required": [
+          "name",
+          "nickname",
+          "email"
+        ],
+        "properties": {
+          "name": {
+            "$ref": "#/definitions/name"
+          },
+          "nickname": {
+            "$ref": "#/definitions/nickname"
+          },
+          "email": {
+            "$ref": "#/definitions/email"
+          },
+          "roles": {
+            "$ref": "#/definitions/roles"
+          },
+          "is_disabled": {
+            "$ref": "#/definitions/is_disabled"
+          },
+          "auth": {
+            "type": "object",
+            "description": "Auth Credentials",
+            "example": {
+              "type": "password",
+              "secret": "bigredhorsebanana"
+            }
+          }
+        }
+      },
+      "targetSchema": {
+        "properties": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Update",
+      "description": "Updates a existing User",
+      "href": "/users/{definitions.identity.example}",
+      "access": "private",
+      "method": "PUT",
+      "rel": "update",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "$ref": "#/definitions/name"
+          },
+          "nickname": {
+            "$ref": "#/definitions/nickname"
+          },
+          "email": {
+            "$ref": "#/definitions/email"
+          },
+          "roles": {
+            "$ref": "#/definitions/roles"
+          },
+          "is_disabled": {
+            "$ref": "#/definitions/is_disabled"
+          }
+        }
+      },
+      "targetSchema": {
+        "properties": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Delete",
+      "description": "Deletes a existing User",
+      "href": "/users/{definitions.identity.example}",
+      "access": "private",
+      "method": "DELETE",
+      "rel": "delete",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "targetSchema": {
+        "type": "boolean"
+      }
+    },
+    {
+      "title": "Set Password",
+      "description": "Sets a password for an existing User",
+      "href": "/users/{definitions.identity.example}/auth",
+      "access": "private",
+      "method": "PUT",
+      "rel": "update",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {
+        "type": "object",
+        "required": [
+          "type",
+          "secret"
+        ],
+        "properties": {
+          "type": {
+            "type": "string",
+            "pattern": "^password$"
+          },
+          "current": {
+            "type": "string",
+            "minLength": 1,
+            "maxLength": 64
+          },
+          "secret": {
+            "type": "string",
+            "minLength": 8,
+            "maxLength": 64
+          }
+        }
+      },
+      "targetSchema": {
+        "type": "boolean"
+      }
+    }
+  ],
+  "properties": {
+    "id": {
+      "$ref": "#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "#/definitions/modified_on"
+    },
+    "name": {
+      "$ref": "#/definitions/name"
+    },
+    "nickname": {
+      "$ref": "#/definitions/nickname"
+    },
+    "email": {
+      "$ref": "#/definitions/email"
+    },
+    "avatar": {
+      "$ref": "#/definitions/avatar"
+    },
+    "roles": {
+      "$ref": "#/definitions/roles"
+    },
+    "is_disabled": {
+      "$ref": "#/definitions/is_disabled"
+    }
+  }
+}

+ 23 - 0
src/backend/schema/examples.json

@@ -0,0 +1,23 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "examples",
+  "type": "object",
+  "definitions": {
+    "name": {
+      "description": "Name",
+      "example": "John Smith",
+      "type": "string",
+      "minLength": 1,
+      "maxLength": 255
+    },
+    "auth_header": {
+      "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk",
+      "X-API-Version": "next"
+    },
+    "token": {
+      "type": "string",
+      "description": "JWT",
+      "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.O_frfYM8RzmRsUNigHtu0_jZ_utSejyr1axMGa8rlsk"
+    }
+  }
+}

+ 21 - 0
src/backend/schema/index.json

@@ -0,0 +1,21 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "root",
+  "title": "Nginx Proxy Manager REST API",
+  "description": "This is the Nginx Proxy Manager REST API",
+  "version": "2.0.0",
+  "links": [
+    {
+      "href": "http://npm.example.com/api",
+      "rel": "self"
+    }
+  ],
+  "properties": {
+    "tokens": {
+      "$ref": "endpoints/tokens.json"
+    },
+    "users": {
+      "$ref": "endpoints/users.json"
+    }
+  }
+}

+ 87 - 0
src/backend/setup.js

@@ -0,0 +1,87 @@
+'use strict';
+
+const fs        = require('fs');
+const NodeRSA   = require('node-rsa');
+const config    = require('config');
+const logger    = require('./logger').global;
+const userModel = require('./models/user');
+const authModel = require('./models/auth');
+
+module.exports = function () {
+    return new Promise((resolve, reject) => {
+        // Now go and check if the jwt gpg keys have been created and if not, create them
+        if (!config.has('jwt') || !config.has('jwt.key') || !config.has('jwt.pub')) {
+            logger.info('Creating a new JWT key pair...');
+
+            // jwt keys are not configured properly
+            const filename  = config.util.getEnv('NODE_CONFIG_DIR') + '/' + (config.util.getEnv('NODE_ENV') || 'default') + '.json';
+            let config_data = {};
+
+            try {
+                config_data = require(filename);
+            } catch (err) {
+                // do nothing
+            }
+
+            // Now create the keys and save them in the config.
+            let key = new NodeRSA({b: 2048});
+            key.generateKeyPair();
+
+            config_data.jwt = {
+                key: key.exportKey('private').toString(),
+                pub: key.exportKey('public').toString()
+            };
+
+            // Write config
+            fs.writeFile(filename, JSON.stringify(config_data, null, 2), (err) => {
+                if (err) {
+                    logger.error('Could not write JWT key pair to config file: ' + filename);
+                    reject(err);
+                } else {
+                    logger.info('Wrote JWT key pair to config file: ' + filename);
+                    config.util.loadFileConfigs();
+                    resolve();
+                }
+            });
+        } else {
+            // JWT key pair exists
+            resolve();
+        }
+    })
+        .then(() => {
+            return userModel
+                .query()
+                .select(userModel.raw('COUNT(`id`) as `count`'))
+                .where('is_deleted', 0)
+                .first('count')
+                .then((row) => {
+                    if (!row.count) {
+                        // Create a new user and set password
+                        logger.info('Creating a new user: [email protected] with password: changeme');
+
+                        let data = {
+                            is_deleted: 0,
+                            email:      '[email protected]',
+                            name:       'Administrator',
+                            nickname:   'Admin',
+                            avatar:     '',
+                            roles:      ['admin']
+                        };
+
+                        return userModel
+                            .query()
+                            .insertAndFetch(data)
+                            .then(user => {
+                                return authModel
+                                    .query()
+                                    .insert({
+                                        user_id: user.id,
+                                        type:    'password',
+                                        secret:  'changeme',
+                                        meta:    {}
+                                    });
+                            });
+                    }
+                });
+        });
+};

+ 9 - 0
src/backend/views/index.ejs

@@ -0,0 +1,9 @@
+<% var title = 'Nginx Proxy Manager' %>
+<%- include partials/header.ejs %>
+
+<div id="app">
+    <span class="loader"></span>
+</div>
+
+<script type="text/javascript" src="/js/main.js?v=<%= version %>"></script>
+<%- include partials/footer.ejs %>

+ 9 - 0
src/backend/views/login.ejs

@@ -0,0 +1,9 @@
+<% var title = 'Login &ndash; Nginx Proxy Manager' %>
+<%- include partials/header.ejs %>
+
+<div class="page" id="login" data-version="<%= version %>">
+    <span class="loader"></span>
+</div>
+
+<script type="text/javascript" src="/js/login.js?v=<%= version %>"></script>
+<%- include partials/footer.ejs %>

+ 2 - 0
src/backend/views/partials/footer.ejs

@@ -0,0 +1,2 @@
+    </body>
+</html>

+ 36 - 0
src/backend/views/partials/header.ejs

@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en" dir="ltr">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+        <meta http-equiv="X-UA-Compatible" content="ie=edge">
+        <meta http-equiv="Content-Language" content="en">
+        <meta name="msapplication-TileColor" content="#2d89ef">
+        <meta name="theme-color" content="#4188c9">
+        <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+        <meta name="apple-mobile-web-app-capable" content="yes">
+        <meta name="mobile-web-app-capable" content="yes">
+        <meta name="HandheldFriendly" content="True">
+        <meta name="MobileOptimized" content="320">
+        <title><%- title %></title>
+        <link rel="apple-touch-icon" sizes="180x180" href="/images/favicons/apple-touch-icon.png?v=<%= version %>">
+        <link rel="icon" type="image/png" sizes="32x32" href="/images/favicons/favicon-32x32.png?v=<%= version %>">
+        <link rel="icon" type="image/png" sizes="16x16" href="/images/favicons/favicon-16x16.png?v=<%= version %>">
+        <link rel="manifest" href="/images/favicons/site.webmanifest?v=<%= version %>">
+        <link rel="mask-icon" href="/images/favicons/safari-pinned-tab.svg?v=<%= version %>" color="#5bbad5">
+        <link rel="shortcut icon" href="/images/favicons/favicon.ico?v=<%= version %>">
+        <meta name="msapplication-TileColor" content="#f5f5f5">
+        <meta name="msapplication-config" content="/images/favicons/browserconfig.xml?v=<%= version %>">
+        <meta name="theme-color" content="#ffffff">
+        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,500,500i,600,600i,700,700i&amp;subset=latin-ext">
+        <link href="/css/main.css?v=<%= version %>" rel="stylesheet">
+    </head>
+    <body>
+
+    <noscript>
+        <div class="container no-js-warning">
+            <div class="alert alert-warning text-center">
+                <strong>Warning!</strong> This application requires Javascript and your browser doesn't support it.
+            </div>
+        </div>
+    </noscript>