Jamie Curnow 7 жил өмнө
parent
commit
54d220a191
72 өөрчлөгдсөн 3656 нэмэгдсэн , 113 устгасан
  1. 74 0
      src/backend/internal/dead-host.js
  2. 279 0
      src/backend/internal/proxy-host.js
  3. 74 0
      src/backend/internal/redirection-host.js
  4. 24 7
      src/backend/internal/report.js
  5. 74 0
      src/backend/internal/stream.js
  6. 75 19
      src/backend/lib/access.js
  7. 23 0
      src/backend/lib/access/dead_hosts-list.json
  8. 15 0
      src/backend/lib/access/permissions.json
  9. 23 0
      src/backend/lib/access/proxy_hosts-list.json
  10. 23 0
      src/backend/lib/access/redirection_hosts-list.json
  11. 23 0
      src/backend/lib/access/streams-list.json
  12. 2 1
      src/backend/logger.js
  13. 0 4
      src/backend/migrations/20180618015850_initial.js
  14. 48 0
      src/backend/models/dead_host.js
  15. 48 0
      src/backend/models/proxy_host.js
  16. 48 0
      src/backend/models/redirection_host.js
  17. 48 0
      src/backend/models/stream.js
  18. 4 0
      src/backend/routes/api/main.js
  19. 150 0
      src/backend/routes/api/nginx/dead_hosts.js
  20. 150 0
      src/backend/routes/api/nginx/proxy_hosts.js
  21. 150 0
      src/backend/routes/api/nginx/redirection_hosts.js
  22. 150 0
      src/backend/routes/api/nginx/streams.js
  23. 0 1
      src/backend/routes/api/users.js
  24. 203 0
      src/backend/schema/endpoints/dead-hosts.json
  25. 203 0
      src/backend/schema/endpoints/proxy-hosts.json
  26. 203 0
      src/backend/schema/endpoints/redirection-hosts.json
  27. 203 0
      src/backend/schema/endpoints/streams.json
  28. 12 0
      src/backend/schema/index.json
  29. 80 12
      src/frontend/js/app/api.js
  30. 37 27
      src/frontend/js/app/controller.js
  31. 16 6
      src/frontend/js/app/dashboard/main.ejs
  32. 34 1
      src/frontend/js/app/dashboard/main.js
  33. 11 0
      src/frontend/js/app/empty/main.ejs
  34. 30 0
      src/frontend/js/app/empty/main.js
  35. 7 0
      src/frontend/js/app/error/main.ejs
  36. 29 0
      src/frontend/js/app/error/main.js
  37. 0 1
      src/frontend/js/app/nginx/404/main.ejs
  38. 0 9
      src/frontend/js/app/nginx/404/main.js
  39. 32 0
      src/frontend/js/app/nginx/dead/list/item.ejs
  40. 72 0
      src/frontend/js/app/nginx/dead/list/item.js
  41. 10 0
      src/frontend/js/app/nginx/dead/list/main.ejs
  42. 29 0
      src/frontend/js/app/nginx/dead/list/main.js
  43. 16 0
      src/frontend/js/app/nginx/dead/main.ejs
  44. 70 0
      src/frontend/js/app/nginx/dead/main.js
  45. 32 0
      src/frontend/js/app/nginx/proxy/list/item.ejs
  46. 72 0
      src/frontend/js/app/nginx/proxy/list/item.js
  47. 10 0
      src/frontend/js/app/nginx/proxy/list/main.ejs
  48. 29 0
      src/frontend/js/app/nginx/proxy/list/main.js
  49. 16 1
      src/frontend/js/app/nginx/proxy/main.ejs
  50. 64 3
      src/frontend/js/app/nginx/proxy/main.js
  51. 32 0
      src/frontend/js/app/nginx/redirection/list/item.ejs
  52. 72 0
      src/frontend/js/app/nginx/redirection/list/item.js
  53. 10 0
      src/frontend/js/app/nginx/redirection/list/main.ejs
  54. 29 0
      src/frontend/js/app/nginx/redirection/list/main.js
  55. 16 1
      src/frontend/js/app/nginx/redirection/main.ejs
  56. 64 3
      src/frontend/js/app/nginx/redirection/main.js
  57. 32 0
      src/frontend/js/app/nginx/stream/list/item.ejs
  58. 72 0
      src/frontend/js/app/nginx/stream/list/item.js
  59. 10 0
      src/frontend/js/app/nginx/stream/list/main.ejs
  60. 29 0
      src/frontend/js/app/nginx/stream/list/main.js
  61. 16 1
      src/frontend/js/app/nginx/stream/main.ejs
  62. 64 3
      src/frontend/js/app/nginx/stream/main.js
  63. 1 1
      src/frontend/js/app/router.js
  64. 4 0
      src/frontend/js/app/ui/header/main.js
  65. 13 0
      src/frontend/js/app/ui/menu/main.ejs
  66. 13 2
      src/frontend/js/app/ui/menu/main.js
  67. 14 10
      src/frontend/js/app/users/main.js
  68. 26 0
      src/frontend/js/models/dead-host.js
  69. 32 0
      src/frontend/js/models/proxy-host.js
  70. 29 0
      src/frontend/js/models/redirection-host.js
  71. 28 0
      src/frontend/js/models/stream.js
  72. 25 0
      src/frontend/js/models/user.js

+ 74 - 0
src/backend/internal/dead-host.js

@@ -0,0 +1,74 @@
+'use strict';
+
+const _             = require('lodash');
+const error         = require('../lib/error');
+const deadHostModel = require('../models/dead_host');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalDeadHost = {
+
+    /**
+     * All Hosts
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('dead_hosts:list')
+            .then(access_data => {
+                let query = deadHostModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .omit(['is_deleted'])
+                    .orderBy('domain_name', 'ASC');
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('domain_name', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    },
+
+    /**
+     * Report use
+     *
+     * @param   {Integer} user_id
+     * @param   {String}  visibility
+     * @returns {Promise}
+     */
+    getCount: (user_id, visibility) => {
+        let query = deadHostModel
+            .query()
+            .count('id as count')
+            .where('is_deleted', 0);
+
+        if (visibility !== 'all') {
+            query.andWhere('owner_user_id', user_id);
+        }
+
+        return query.first()
+            .then(row => {
+                return parseInt(row.count, 10);
+            });
+    }
+};
+
+module.exports = internalDeadHost;

+ 279 - 0
src/backend/internal/proxy-host.js

@@ -0,0 +1,279 @@
+'use strict';
+
+const _              = require('lodash');
+const error          = require('../lib/error');
+const proxyHostModel = require('../models/proxy_host');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalProxyHost = {
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        let auth = data.auth || null;
+        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('proxy_hosts:create', data)
+            .then(() => {
+                data.avatar = gravatar.url(data.email, {default: 'mm'});
+
+                return userModel
+                    .query()
+                    .omit(omissions())
+                    .insertAndFetch(data);
+            })
+            .then(user => {
+                if (auth) {
+                    return authModel
+                        .query()
+                        .insert({
+                            user_id: user.id,
+                            type:    auth.type,
+                            secret:  auth.secret,
+                            meta:    {}
+                        })
+                        .then(() => {
+                            return user;
+                        });
+                } else {
+                    return user;
+                }
+            })
+            .then(user => {
+                // Create permissions row as well
+                let is_admin = data.roles.indexOf('admin') !== -1;
+
+                return userPermissionModel
+                    .query()
+                    .insert({
+                        user_id:           user.id,
+                        visibility:        is_admin ? 'all' : 'user',
+                        proxy_hosts:       'manage',
+                        redirection_hosts: 'manage',
+                        dead_hosts:        'manage',
+                        streams:           'manage',
+                        access_lists:      'manage'
+                    })
+                    .then(() => {
+                        return internalProxyHost.get(access, {id: user.id, expand: ['permissions']});
+                    });
+            });
+    },
+
+    /**
+     * @param  {Access}  access
+     * @param  {Object}  data
+     * @param  {Integer} data.id
+     * @param  {String}  [data.email]
+     * @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('proxy_hosts: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 internalProxyHost.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 internalProxyHost.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 internalProxyHost.get(access, {id: data.id});
+            });
+    },
+
+    /**
+     * @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('proxy_hosts:get', data.id)
+            .then(() => {
+                let query = userModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .andWhere('id', data.id)
+                    .allowEager('[permissions]')
+                    .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);
+                }
+            });
+    },
+
+    /**
+     * @param {Access}  access
+     * @param {Object}  data
+     * @param {Integer} data.id
+     * @param {String}  [data.reason]
+     * @returns {Promise}
+     */
+    delete: (access, data) => {
+        return access.can('proxy_hosts:delete', data.id)
+            .then(() => {
+                return internalProxyHost.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;
+            });
+    },
+
+    /**
+     * All Hosts
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('proxy_hosts:list')
+            .then(access_data => {
+                let query = proxyHostModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .omit(['is_deleted'])
+                    .orderBy('domain_name', 'ASC');
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('domain_name', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    },
+
+    /**
+     * Report use
+     *
+     * @param   {Integer} user_id
+     * @param   {String}  visibility
+     * @returns {Promise}
+     */
+    getCount: (user_id, visibility) => {
+        let query = proxyHostModel
+            .query()
+            .count('id as count')
+            .where('is_deleted', 0);
+
+        if (visibility !== 'all') {
+            query.andWhere('owner_user_id', user_id);
+        }
+
+        return query.first()
+            .then(row => {
+                return parseInt(row.count, 10);
+            });
+    }
+};
+
+module.exports = internalProxyHost;

+ 74 - 0
src/backend/internal/redirection-host.js

@@ -0,0 +1,74 @@
+'use strict';
+
+const _                    = require('lodash');
+const error                = require('../lib/error');
+const redirectionHostModel = require('../models/redirection_host');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalProxyHost = {
+
+    /**
+     * All Hosts
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('redirection_hosts:list')
+            .then(access_data => {
+                let query = redirectionHostModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .omit(['is_deleted'])
+                    .orderBy('domain_name', 'ASC');
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('domain_name', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    },
+
+    /**
+     * Report use
+     *
+     * @param   {Integer} user_id
+     * @param   {String}  visibility
+     * @returns {Promise}
+     */
+    getCount: (user_id, visibility) => {
+        let query = redirectionHostModel
+            .query()
+            .count('id as count')
+            .where('is_deleted', 0);
+
+        if (visibility !== 'all') {
+            query.andWhere('owner_user_id', user_id);
+        }
+
+        return query.first()
+            .then(row => {
+                return parseInt(row.count, 10);
+            });
+    }
+};
+
+module.exports = internalProxyHost;

+ 24 - 7
src/backend/internal/report.js

@@ -1,7 +1,11 @@
 'use strict';
 
-const _     = require('lodash');
-const error = require('../lib/error');
+const _                       = require('lodash');
+const error                   = require('../lib/error');
+const internalProxyHost       = require('./proxy-host');
+const internalRedirectionHost = require('./redirection-host');
+const internalDeadHost        = require('./dead-host');
+const internalStream          = require('./stream');
 
 const internalReport = {
 
@@ -11,14 +15,27 @@ const internalReport = {
      */
     getHostsReport: access => {
         return access.can('reports:hosts', 1)
-            .then(() => {
+            .then(access_data => {
+                let user_id = access.token.get('attrs').id;
+
+                let promises = [
+                    internalProxyHost.getCount(user_id, access_data.visibility),
+                    internalRedirectionHost.getCount(user_id, access_data.visibility),
+                    internalStream.getCount(user_id, access_data.visibility),
+                    internalDeadHost.getCount(user_id, access_data.visibility)
+                ];
+
+                return Promise.all(promises);
+            })
+            .then(counts => {
                 return {
-                    proxy:       12,
-                    redirection: 2,
-                    stream:      1,
-                    '404':       0
+                    proxy:       counts.shift(),
+                    redirection: counts.shift(),
+                    stream:      counts.shift(),
+                    dead:        counts.shift()
                 };
             });
+
     }
 };
 

+ 74 - 0
src/backend/internal/stream.js

@@ -0,0 +1,74 @@
+'use strict';
+
+const _           = require('lodash');
+const error       = require('../lib/error');
+const streamModel = require('../models/stream');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalStream = {
+
+    /**
+     * All Hosts
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('streams:list')
+            .then(access_data => {
+                let query = streamModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .omit(['is_deleted'])
+                    .orderBy('incoming_port', 'ASC');
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    query.where(function () {
+                        this.where('incoming_port', 'like', '%' + search_query + '%');
+                    });
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    },
+
+    /**
+     * Report use
+     *
+     * @param   {Integer} user_id
+     * @param   {String}  visibility
+     * @returns {Promise}
+     */
+    getCount: (user_id, visibility) => {
+        let query = streamModel
+            .query()
+            .count('id as count')
+            .where('is_deleted', 0);
+
+        if (visibility !== 'all') {
+            query.andWhere('owner_user_id', user_id);
+        }
+
+        return query.first()
+            .then(row => {
+                return parseInt(row.count, 10);
+            });
+    }
+};
+
+module.exports = internalStream;

+ 75 - 19
src/backend/lib/access.js

@@ -1,11 +1,24 @@
 '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');
+/**
+ * Some Notes: This is a friggin complicated piece of code.
+ *
+ * "scope" in this file means "where did this token come from and what is using it", so 99% of the time
+ * the "scope" is going to be "user" because it would be a user token. This is not to be confused with
+ * the "role" which could be "user" or "admin". The scope in fact, could be "worker" or anything else.
+ *
+ *
+ */
+
+const _              = require('lodash');
+const logger         = require('../logger').access;
+const validator      = require('ajv');
+const error          = require('./error');
+const userModel      = require('../models/user');
+const proxyHostModel = require('../models/proxy_host');
+const TokenModel     = require('../models/token');
+const roleSchema     = require('./access/roles.json');
+const permsSchema    = require('./access/permissions.json');
 
 module.exports = function (token_string) {
     let Token                 = new TokenModel();
@@ -14,6 +27,7 @@ module.exports = function (token_string) {
     let object_cache          = {};
     let allow_internal_access = false;
     let user_roles            = [];
+    let permissions           = {};
 
     /**
      * Loads the Token object from the token string
@@ -28,7 +42,7 @@ module.exports = function (token_string) {
                 reject(new error.PermissionError('Permission Denied'));
             } else {
                 resolve(Token.load(token_string)
-                    .then((data) => {
+                    .then(data => {
                         token_data = data;
 
                         // At this point we need to load the user from the DB and make sure they:
@@ -43,8 +57,10 @@ module.exports = function (token_string) {
                                 .where('id', token_data.attrs.id)
                                 .andWhere('is_deleted', 0)
                                 .andWhere('is_disabled', 0)
-                                .first('id')
-                                .then((user) => {
+                                .allowEager('[permissions]')
+                                .eager('[permissions]')
+                                .first()
+                                .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.
@@ -62,7 +78,9 @@ module.exports = function (token_string) {
                                         } else {
                                             initialised = true;
                                             user_roles  = user.roles;
+                                            permissions = user.permissions;
                                         }
+
                                     } else {
                                         throw new error.AuthError('User cannot be loaded for Token');
                                     }
@@ -99,6 +117,34 @@ module.exports = function (token_string) {
                                 resolve(token_user_id ? [token_user_id] : []);
                                 break;
 
+                            // Proxy Hosts
+                            case 'proxy_hosts':
+                                let query = proxyHostModel
+                                    .query()
+                                    .select('id')
+                                    .andWhere('is_deleted', 0);
+
+                                if (permissions.visibility === 'user') {
+                                    query.andWhere('owner_user_id', token_user_id);
+                                }
+
+                                resolve(query
+                                    .then(rows => {
+                                        let result = [];
+                                        _.forEach(rows, (rule_row) => {
+                                            result.push(rule_row.id);
+                                        });
+
+                                        // enum should not have less than 1 item
+                                        if (!result.length) {
+                                            result.push(0);
+                                        }
+
+                                        return result;
+                                    })
+                                );
+                                break;
+
                             // DEFAULT: null
                             default:
                                 resolve(null);
@@ -121,7 +167,7 @@ module.exports = function (token_string) {
     /**
      * Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema
      *
-     * @param {String} permission_label
+     * @param   {String} permission_label
      * @returns {Object}
      */
     this.getObjectSchema = permission_label => {
@@ -207,9 +253,15 @@ module.exports = function (token_string) {
                             .then(objectSchema => {
                                 let data_schema = {
                                     [permission]: {
-                                        data:  data,
-                                        scope: Token.get('scope'),
-                                        roles: user_roles
+                                        data:                         data,
+                                        scope:                        Token.get('scope'),
+                                        roles:                        user_roles,
+                                        permission_visibility:        permissions.visibility,
+                                        permission_proxy_hosts:       permissions.proxy_hosts,
+                                        permission_redirection_hosts: permissions.redirection_hosts,
+                                        permission_dead_hosts:        permissions.dead_hosts,
+                                        permission_streams:           permissions.streams,
+                                        permission_access_lists:      permissions.access_lists
                                     }
                                 };
 
@@ -223,9 +275,9 @@ module.exports = function (token_string) {
 
                                 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));
+                                //logger.debug('objectSchema:', JSON.stringify(objectSchema, null, 2));
+                                //logger.debug('permissionSchema:', JSON.stringify(permissionSchema, null, 2));
+                                //logger.debug('data_schema:', JSON.stringify(data_schema, null, 2));
 
                                 let ajv = validator({
                                     verbose:      true,
@@ -236,17 +288,21 @@ module.exports = function (token_string) {
                                     coerceTypes:  true,
                                     schemas:      [
                                         roleSchema,
+                                        permsSchema,
                                         objectSchema,
                                         permissionSchema
                                     ]
                                 });
 
-                                return ajv.validate('permissions', data_schema);
+                                return ajv.validate('permissions', data_schema)
+                                    .then(() => {
+                                        return data_schema[permission];
+                                    });
                             });
                     })
                     .catch(err => {
-                        //console.log(err.message);
-                        //console.log(err.errors);
+                        //logger.error(err.message);
+                        //logger.error(err.errors);
 
                         throw new error.PermissionError('Permission Denied', err);
                     });

+ 23 - 0
src/backend/lib/access/dead_hosts-list.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_dead_hosts", "roles"],
+      "properties": {
+        "permission_dead_hosts": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 15 - 0
src/backend/lib/access/permissions.json

@@ -0,0 +1,15 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "perms",
+  "definitions": {
+    "view": {
+      "type": "string",
+      "pattern": "^(view|manage)$"
+    },
+    "manage": {
+      "type": "string",
+      "pattern": "^(manage)$"
+    }
+  }
+}
+

+ 23 - 0
src/backend/lib/access/proxy_hosts-list.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_proxy_hosts", "roles"],
+      "properties": {
+        "permission_proxy_hosts": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/redirection_hosts-list.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_redirection_hosts", "roles"],
+      "properties": {
+        "permission_redirection_hosts": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/streams-list.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_streams", "roles"],
+      "properties": {
+        "permission_streams": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 2 - 1
src/backend/logger.js

@@ -3,5 +3,6 @@ const {Signale} = require('signale');
 module.exports = {
     global:  new Signale({scope: 'Global    '}),
     migrate: new Signale({scope: 'Migrate   '}),
-    express: new Signale({scope: 'Express   '})
+    express: new Signale({scope: 'Express   '}),
+    access:  new Signale({scope: 'Access    '})
 };

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

@@ -77,7 +77,6 @@ exports.up = function (knex/*, Promise*/) {
                 table.integer('caching_enabled').notNull().unsigned().defaultTo(0);
                 table.integer('block_exploits').notNull().unsigned().defaultTo(0);
                 table.json('meta').notNull();
-                table.unique(['domain_name', 'is_deleted']);
             });
         })
         .then(() => {
@@ -96,7 +95,6 @@ exports.up = function (knex/*, Promise*/) {
                 table.string('ssl_provider').notNull().defaultTo('');
                 table.integer('block_exploits').notNull().unsigned().defaultTo(0);
                 table.json('meta').notNull();
-                table.unique(['domain_name', 'is_deleted']);
             });
         })
         .then(() => {
@@ -112,7 +110,6 @@ exports.up = function (knex/*, Promise*/) {
                     table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
                     table.string('ssl_provider').notNull().defaultTo('');
                     table.json('meta').notNull();
-                    table.unique(['domain_name', 'is_deleted']);
                 });
         })
         .then(() => {
@@ -130,7 +127,6 @@ exports.up = function (knex/*, Promise*/) {
                 table.integer('tcp_forwarding').notNull().unsigned().defaultTo(0);
                 table.integer('udp_forwarding').notNull().unsigned().defaultTo(0);
                 table.json('meta').notNull();
-                table.unique(['incoming_port', 'is_deleted']);
             });
         })
         .then(() => {

+ 48 - 0
src/backend/models/dead_host.js

@@ -0,0 +1,48 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+const User  = require('./user');
+
+Model.knex(db);
+
+class DeadHost 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 'DeadHost';
+    }
+
+    static get tableName () {
+        return 'dead_host';
+    }
+
+    static get relationMappings () {
+        return {
+            owner: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'dead_host.owner_user_id',
+                    to:   'user.id'
+                },
+                modify:     function (qb) {
+                    qb.where('user.is_deleted', 0);
+                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = DeadHost;

+ 48 - 0
src/backend/models/proxy_host.js

@@ -0,0 +1,48 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+const User  = require('./user');
+
+Model.knex(db);
+
+class ProxyHost 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 'ProxyHost';
+    }
+
+    static get tableName () {
+        return 'proxy_host';
+    }
+
+    static get relationMappings () {
+        return {
+            owner: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'proxy_host.owner_user_id',
+                    to:   'user.id'
+                },
+                modify:     function (qb) {
+                    qb.where('user.is_deleted', 0);
+                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = ProxyHost;

+ 48 - 0
src/backend/models/redirection_host.js

@@ -0,0 +1,48 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+const User  = require('./user');
+
+Model.knex(db);
+
+class RedirectionHost 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 'RedirectionHost';
+    }
+
+    static get tableName () {
+        return 'redirection_host';
+    }
+
+    static get relationMappings () {
+        return {
+            owner: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'redirection_host.owner_user_id',
+                    to:   'user.id'
+                },
+                modify:     function (qb) {
+                    qb.where('user.is_deleted', 0);
+                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = RedirectionHost;

+ 48 - 0
src/backend/models/stream.js

@@ -0,0 +1,48 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+const User  = require('./user');
+
+Model.knex(db);
+
+class Stream 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 'Stream';
+    }
+
+    static get tableName () {
+        return 'stream';
+    }
+
+    static get relationMappings () {
+        return {
+            owner: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'stream.owner_user_id',
+                    to:   'user.id'
+                },
+                modify:     function (qb) {
+                    qb.where('user.is_deleted', 0);
+                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = Stream;

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

@@ -30,6 +30,10 @@ router.get('/', (req, res/*, next*/) => {
 router.use('/tokens', require('./tokens'));
 router.use('/users', require('./users'));
 router.use('/reports', require('./reports'));
+router.use('/nginx/proxy-hosts', require('./nginx/proxy_hosts'));
+router.use('/nginx/redirection-hosts', require('./nginx/redirection_hosts'));
+router.use('/nginx/dead-hosts', require('./nginx/dead_hosts'));
+router.use('/nginx/streams', require('./nginx/streams'));
 
 /**
  * API 404 for all other routes

+ 150 - 0
src/backend/routes/api/nginx/dead_hosts.js

@@ -0,0 +1,150 @@
+'use strict';
+
+const express          = require('express');
+const validator        = require('../../../lib/validator');
+const jwtdecode        = require('../../../lib/express/jwt-decode');
+const internalDeadHost = require('../../../internal/dead-host');
+const apiValidator     = require('../../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/nginx/dead-hosts
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/dead-hosts
+     *
+     * Retrieve all dead-hosts
+     */
+    .get((req, res, next) => {
+        validator({
+            additionalProperties: false,
+            properties:           {
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then(data => {
+                return internalDeadHost.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/nginx/dead-hosts
+     *
+     * Create a new dead-host
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/dead-hosts#/links/1/schema'}, req.body)
+            .then(payload => {
+                return internalDeadHost.create(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific dead-host
+ *
+ * /api/nginx/dead-hosts/123
+ */
+router
+    .route('/:host_id')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/dead-hosts/123
+     *
+     * Retrieve a specific dead-host
+     */
+    .get((req, res, next) => {
+        validator({
+            required:             ['host_id'],
+            additionalProperties: false,
+            properties:           {
+                host_id: {
+                    $ref: 'definitions#/definitions/id'
+                },
+                expand:  {
+                    $ref: 'definitions#/definitions/expand'
+                }
+            }
+        }, {
+            host_id: req.params.host_id,
+            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+        })
+            .then(data => {
+                return internalDeadHost.get(res.locals.access, {
+                    id:     data.host_id,
+                    expand: data.expand
+                });
+            })
+            .then(row => {
+                res.status(200)
+                    .send(row);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/nginx/dead-hosts/123
+     *
+     * Update and existing dead-host
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/dead-hosts#/links/2/schema'}, req.body)
+            .then(payload => {
+                payload.id = req.params.host_id;
+                return internalDeadHost.update(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/nginx/dead-hosts/123
+     *
+     * Update and existing dead-host
+     */
+    .delete((req, res, next) => {
+        internalDeadHost.delete(res.locals.access, {id: req.params.host_id})
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 150 - 0
src/backend/routes/api/nginx/proxy_hosts.js

@@ -0,0 +1,150 @@
+'use strict';
+
+const express           = require('express');
+const validator         = require('../../../lib/validator');
+const jwtdecode         = require('../../../lib/express/jwt-decode');
+const internalProxyHost = require('../../../internal/proxy-host');
+const apiValidator      = require('../../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/nginx/proxy-hosts
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/proxy-hosts
+     *
+     * Retrieve all proxy-hosts
+     */
+    .get((req, res, next) => {
+        validator({
+            additionalProperties: false,
+            properties:           {
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then(data => {
+                return internalProxyHost.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/nginx/proxy-hosts
+     *
+     * Create a new proxy-host
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/proxy-hosts#/links/1/schema'}, req.body)
+            .then(payload => {
+                return internalProxyHost.create(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific proxy-host
+ *
+ * /api/nginx/proxy-hosts/123
+ */
+router
+    .route('/:host_id')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/proxy-hosts/123
+     *
+     * Retrieve a specific proxy-host
+     */
+    .get((req, res, next) => {
+        validator({
+            required:             ['host_id'],
+            additionalProperties: false,
+            properties:           {
+                host_id: {
+                    $ref: 'definitions#/definitions/id'
+                },
+                expand:  {
+                    $ref: 'definitions#/definitions/expand'
+                }
+            }
+        }, {
+            host_id: req.params.host_id,
+            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+        })
+            .then(data => {
+                return internalProxyHost.get(res.locals.access, {
+                    id:     data.host_id,
+                    expand: data.expand
+                });
+            })
+            .then(row => {
+                res.status(200)
+                    .send(row);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/nginx/proxy-hosts/123
+     *
+     * Update and existing proxy-host
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/proxy-hosts#/links/2/schema'}, req.body)
+            .then(payload => {
+                payload.id = req.params.host_id;
+                return internalProxyHost.update(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/nginx/proxy-hosts/123
+     *
+     * Update and existing proxy-host
+     */
+    .delete((req, res, next) => {
+        internalProxyHost.delete(res.locals.access, {id: req.params.host_id})
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 150 - 0
src/backend/routes/api/nginx/redirection_hosts.js

@@ -0,0 +1,150 @@
+'use strict';
+
+const express                 = require('express');
+const validator               = require('../../../lib/validator');
+const jwtdecode               = require('../../../lib/express/jwt-decode');
+const internalRedirectionHost = require('../../../internal/redirection-host');
+const apiValidator            = require('../../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/nginx/redirection-hosts
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/redirection-hosts
+     *
+     * Retrieve all redirection-hosts
+     */
+    .get((req, res, next) => {
+        validator({
+            additionalProperties: false,
+            properties:           {
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then(data => {
+                return internalRedirectionHost.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/nginx/redirection-hosts
+     *
+     * Create a new redirection-host
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/redirection-hosts#/links/1/schema'}, req.body)
+            .then(payload => {
+                return internalRedirectionHost.create(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific redirection-host
+ *
+ * /api/nginx/redirection-hosts/123
+ */
+router
+    .route('/:host_id')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/redirection-hosts/123
+     *
+     * Retrieve a specific redirection-host
+     */
+    .get((req, res, next) => {
+        validator({
+            required:             ['host_id'],
+            additionalProperties: false,
+            properties:           {
+                host_id: {
+                    $ref: 'definitions#/definitions/id'
+                },
+                expand:  {
+                    $ref: 'definitions#/definitions/expand'
+                }
+            }
+        }, {
+            host_id: req.params.host_id,
+            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+        })
+            .then(data => {
+                return internalRedirectionHost.get(res.locals.access, {
+                    id:     data.host_id,
+                    expand: data.expand
+                });
+            })
+            .then(row => {
+                res.status(200)
+                    .send(row);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/nginx/redirection-hosts/123
+     *
+     * Update and existing redirection-host
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/redirection-hosts#/links/2/schema'}, req.body)
+            .then(payload => {
+                payload.id = req.params.host_id;
+                return internalRedirectionHost.update(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/nginx/redirection-hosts/123
+     *
+     * Update and existing redirection-host
+     */
+    .delete((req, res, next) => {
+        internalRedirectionHost.delete(res.locals.access, {id: req.params.host_id})
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 150 - 0
src/backend/routes/api/nginx/streams.js

@@ -0,0 +1,150 @@
+'use strict';
+
+const express        = require('express');
+const validator      = require('../../../lib/validator');
+const jwtdecode      = require('../../../lib/express/jwt-decode');
+const internalStream = require('../../../internal/stream');
+const apiValidator   = require('../../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/nginx/streams
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/streams
+     *
+     * Retrieve all streams
+     */
+    .get((req, res, next) => {
+        validator({
+            additionalProperties: false,
+            properties:           {
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then(data => {
+                return internalStream.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/nginx/streams
+     *
+     * Create a new stream
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/streams#/links/1/schema'}, req.body)
+            .then(payload => {
+                return internalStream.create(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific stream
+ *
+ * /api/nginx/streams/123
+ */
+router
+    .route('/:stream_id')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/streams/123
+     *
+     * Retrieve a specific stream
+     */
+    .get((req, res, next) => {
+        validator({
+            required:             ['stream_id'],
+            additionalProperties: false,
+            properties:           {
+                stream_id: {
+                    $ref: 'definitions#/definitions/id'
+                },
+                expand:  {
+                    $ref: 'definitions#/definitions/expand'
+                }
+            }
+        }, {
+            stream_id: req.params.stream_id,
+            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+        })
+            .then(data => {
+                return internalStream.get(res.locals.access, {
+                    id:     data.stream_id,
+                    expand: data.expand
+                });
+            })
+            .then(row => {
+                res.status(200)
+                    .send(row);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/nginx/streams/123
+     *
+     * Update and existing stream
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/streams#/links/2/schema'}, req.body)
+            .then(payload => {
+                payload.id = req.params.stream_id;
+                return internalStream.update(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/nginx/streams/123
+     *
+     * Update and existing stream
+     */
+    .delete((req, res, next) => {
+        internalStream.delete(res.locals.access, {id: req.params.stream_id})
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

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

@@ -3,7 +3,6 @@
 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');

+ 203 - 0
src/backend/schema/endpoints/dead-hosts.json

@@ -0,0 +1,203 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/dead-hosts",
+  "title": "Users",
+  "description": "Endpoints relating to Dead Hosts",
+  "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"
+      }
+    }
+  ],
+  "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"
+    }
+  }
+}

+ 203 - 0
src/backend/schema/endpoints/proxy-hosts.json

@@ -0,0 +1,203 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/proxy-hosts",
+  "title": "Users",
+  "description": "Endpoints relating to Proxy Hosts",
+  "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"
+      }
+    }
+  ],
+  "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"
+    }
+  }
+}

+ 203 - 0
src/backend/schema/endpoints/redirection-hosts.json

@@ -0,0 +1,203 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/redirection-hosts",
+  "title": "Users",
+  "description": "Endpoints relating to Redirection Hosts",
+  "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"
+      }
+    }
+  ],
+  "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"
+    }
+  }
+}

+ 203 - 0
src/backend/schema/endpoints/streams.json

@@ -0,0 +1,203 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/streams",
+  "title": "Users",
+  "description": "Endpoints relating to Streams",
+  "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"
+      }
+    }
+  ],
+  "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"
+    }
+  }
+}

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

@@ -16,6 +16,18 @@
     },
     "users": {
       "$ref": "endpoints/users.json"
+    },
+    "proxy-hosts": {
+      "$ref": "endpoints/proxy-hosts.json"
+    },
+    "redirection-hosts": {
+      "$ref": "endpoints/redirection-hosts.json"
+    },
+    "dead-hosts": {
+      "$ref": "endpoints/dead-hosts.json"
+    },
+    "streams": {
+      "$ref": "endpoints/streams.json"
     }
   }
 }

+ 80 - 12
src/frontend/js/app/api.js

@@ -103,6 +103,26 @@ function makeExpansionString (expand) {
     return items.join(',');
 }
 
+/**
+ * @param   {String}   path
+ * @param   {Array}    [expand]
+ * @param   {String}   [query]
+ * @returns {Promise}
+ */
+function getAllObjects (path, expand, query) {
+    let params = [];
+
+    if (typeof expand === 'object' && expand !== null && expand.length) {
+        params.push('expand=' + makeExpansionString(expand));
+    }
+
+    if (typeof query === 'string') {
+        params.push('query=' + query);
+    }
+
+    return fetch('get', path + (params.length ? '?' + params.join('&') : ''));
+}
+
 module.exports = {
     status: function () {
         return fetch('get', '');
@@ -168,17 +188,7 @@ module.exports = {
          * @returns {Promise}
          */
         getAll: function (expand, query) {
-            let params = [];
-
-            if (typeof expand === 'object' && expand !== null && expand.length) {
-                params.push('expand=' + makeExpansionString(expand));
-            }
-
-            if (typeof query === 'string') {
-                params.push('query=' + query);
-            }
-
-            return fetch('get', 'users' + (params.length ? '?' + params.join('&') : ''));
+            return getAllObjects('users', expand, query);
         },
 
         /**
@@ -237,6 +247,64 @@ module.exports = {
         }
     },
 
+    Nginx: {
+
+        ProxyHosts: {
+            /**
+             * @param   {Array}    [expand]
+             * @param   {String}   [query]
+             * @returns {Promise}
+             */
+            getAll: function (expand, query) {
+                return getAllObjects('nginx/proxy-hosts', expand, query);
+            }
+        },
+
+        RedirectionHosts: {
+            /**
+             * @param   {Array}    [expand]
+             * @param   {String}   [query]
+             * @returns {Promise}
+             */
+            getAll: function (expand, query) {
+                return getAllObjects('nginx/redirection-hosts', expand, query);
+            }
+        },
+
+        Streams: {
+            /**
+             * @param   {Array}    [expand]
+             * @param   {String}   [query]
+             * @returns {Promise}
+             */
+            getAll: function (expand, query) {
+                return getAllObjects('nginx/streams', expand, query);
+            }
+        },
+
+        DeadHosts: {
+            /**
+             * @param   {Array}    [expand]
+             * @param   {String}   [query]
+             * @returns {Promise}
+             */
+            getAll: function (expand, query) {
+                return getAllObjects('nginx/dead-hosts', expand, query);
+            }
+        }
+    },
+
+    AccessLists: {
+        /**
+         * @param   {Array}    [expand]
+         * @param   {String}   [query]
+         * @returns {Promise}
+         */
+        getAll: function (expand, query) {
+            return getAllObjects('access-lists', expand, query);
+        }
+    },
+
     Reports: {
 
         /**
@@ -244,6 +312,6 @@ module.exports = {
          */
         getHostStats: function () {
             return fetch('get', 'reports/hosts');
-        },
+        }
     }
 };

+ 37 - 27
src/frontend/js/app/controller.js

@@ -124,60 +124,70 @@ module.exports = {
      * Nginx Proxy Hosts
      */
     showNginxProxy: function () {
-        let controller = this;
+        if (Cache.User.isAdmin() || Cache.User.canView('proxy_hosts')) {
+            let controller = this;
 
-        require(['./main', './nginx/proxy/main'], (App, View) => {
-            controller.navigate('/nginx/proxy');
-            App.UI.showAppContent(new View());
-        });
+            require(['./main', './nginx/proxy/main'], (App, View) => {
+                controller.navigate('/nginx/proxy');
+                App.UI.showAppContent(new View());
+            });
+        }
     },
 
     /**
      * Nginx Redirection Hosts
      */
     showNginxRedirection: function () {
-        let controller = this;
+        if (Cache.User.isAdmin() || Cache.User.canView('redirection_hosts')) {
+            let controller = this;
 
-        require(['./main', './nginx/redirection/main'], (App, View) => {
-            controller.navigate('/nginx/redirection');
-            App.UI.showAppContent(new View());
-        });
+            require(['./main', './nginx/redirection/main'], (App, View) => {
+                controller.navigate('/nginx/redirection');
+                App.UI.showAppContent(new View());
+            });
+        }
     },
 
     /**
      * Nginx Stream Hosts
      */
     showNginxStream: function () {
-        let controller = this;
+        if (Cache.User.isAdmin() || Cache.User.canView('streams')) {
+            let controller = this;
 
-        require(['./main', './nginx/stream/main'], (App, View) => {
-            controller.navigate('/nginx/stream');
-            App.UI.showAppContent(new View());
-        });
+            require(['./main', './nginx/stream/main'], (App, View) => {
+                controller.navigate('/nginx/stream');
+                App.UI.showAppContent(new View());
+            });
+        }
     },
 
     /**
-     * Nginx 404 Hosts
+     * Nginx Dead Hosts
      */
-    showNginx404: function () {
-        let controller = this;
+    showNginxDead: function () {
+        if (Cache.User.isAdmin() || Cache.User.canView('dead_hosts')) {
+            let controller = this;
 
-        require(['./main', './nginx/404/main'], (App, View) => {
-            controller.navigate('/nginx/404');
-            App.UI.showAppContent(new View());
-        });
+            require(['./main', './nginx/dead/main'], (App, View) => {
+                controller.navigate('/nginx/404');
+                App.UI.showAppContent(new View());
+            });
+        }
     },
 
     /**
      * Nginx Access
      */
     showNginxAccess: function () {
-        let controller = this;
+        if (Cache.User.isAdmin() || Cache.User.canView('access_lists')) {
+            let controller = this;
 
-        require(['./main', './nginx/access/main'], (App, View) => {
-            controller.navigate('/nginx/access');
-            App.UI.showAppContent(new View());
-        });
+            require(['./main', './nginx/access/main'], (App, View) => {
+                controller.navigate('/nginx/access');
+                App.UI.showAppContent(new View());
+            });
+        }
     },
 
     /**

+ 16 - 6
src/frontend/js/app/dashboard/main.ejs

@@ -2,8 +2,10 @@
     <h1 class="page-title">Hi <%- getUserName() %></h1>
 </div>
 
+<% if (columns) { %>
 <div class="row">
-    <div class="col-sm-6 col-lg-3">
+    <% if (canShow('proxy_hosts')) { %>
+    <div class="col-sm-<%- 24 / columns %> col-lg-<%- 12 / columns %>">
         <div class="card p-3">
             <div class="d-flex align-items-center">
                     <span class="stamp stamp-md bg-green mr-3">
@@ -15,8 +17,10 @@
             </div>
         </div>
     </div>
+    <% } %>
 
-    <div class="col-sm-6 col-lg-3">
+    <% if (canShow('redirection_hosts')) { %>
+    <div class="col-sm-<%- 24 / columns %> col-lg-<%- 12 / columns %>">
         <div class="card p-3">
             <div class="d-flex align-items-center">
                     <span class="stamp stamp-md bg-yellow mr-3">
@@ -28,8 +32,10 @@
             </div>
         </div>
     </div>
+    <% } %>
 
-    <div class="col-sm-6 col-lg-3">
+    <% if (canShow('streams')) { %>
+    <div class="col-sm-<%- 24 / columns %> col-lg-<%- 12 / columns %>">
         <div class="card p-3">
             <div class="d-flex align-items-center">
                     <span class="stamp stamp-md bg-blue mr-3">
@@ -41,17 +47,21 @@
             </div>
         </div>
     </div>
+    <% } %>
 
-    <div class="col-sm-6 col-lg-3">
+    <% if (canShow('dead_hosts')) { %>
+    <div class="col-sm-<%- 24 / columns %> col-lg-<%- 12 / columns %>">
         <div class="card p-3">
             <div class="d-flex align-items-center">
                     <span class="stamp stamp-md bg-red mr-3">
                       <i class="fe fe-zap-off"></i>
                     </span>
                 <div>
-                    <h4 class="m-0"><a href="/nginx/404"><%- getHostStat('404') %> <small>404 Hosts</small></a></h4>
+                    <h4 class="m-0"><a href="/nginx/404"><%- getHostStat('dead') %> <small>404 Hosts</small></a></h4>
                 </div>
             </div>
         </div>
     </div>
-</div>
+    <% } %>
+</div>
+<% } %>

+ 34 - 1
src/frontend/js/app/dashboard/main.js

@@ -10,6 +10,7 @@ const template   = require('./main.ejs');
 module.exports = Mn.View.extend({
     template: template,
     id:       'dashboard',
+    columns: 0,
 
     stats: {},
 
@@ -38,7 +39,13 @@ module.exports = Mn.View.extend({
                 }
 
                 return '-';
-            }
+            },
+
+            canShow: function (perm) {
+                return Cache.User.isAdmin() || Cache.User.canView(perm);
+            },
+
+            columns: view.columns
         }
     },
 
@@ -57,5 +64,31 @@ module.exports = Mn.View.extend({
                     console.log(err);
                 });
         }
+    },
+
+    /**
+     * @param {Object}  [model]
+     */
+    preRender: function (model) {
+        this.columns = 0;
+
+        // calculate the available columns based on permissions for the objects
+        // and store as a variable
+        //let view = this;
+        let perms = ['proxy_hosts', 'redirection_hosts', 'streams', 'dead_hosts'];
+
+        perms.map(perm => {
+            this.columns += Cache.User.isAdmin() || Cache.User.canView(perm) ? 1 : 0;
+        });
+
+        // Prevent double rendering on initial calls
+        if (typeof model !== 'undefined') {
+            this.render();
+        }
+    },
+
+    initialize: function () {
+        this.preRender();
+        this.listenTo(Cache.User, 'change', this.preRender);
     }
 });

+ 11 - 0
src/frontend/js/app/empty/main.ejs

@@ -0,0 +1,11 @@
+<% if (title) { %>
+    <h1 class="h2 mb-3"><%- title %></h1>
+<% }
+
+if (subtitle) { %>
+    <p class="h4 text-muted font-weight-normal mb-7"><%- subtitle %></p>
+<% }
+
+if (link) { %>
+    <a class="btn btn-teal" href="#"><%- link %></a>
+<% } %>

+ 30 - 0
src/frontend/js/app/empty/main.js

@@ -0,0 +1,30 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const template = require('./main.ejs');
+
+module.exports = Mn.View.extend({
+    className: 'text-center m-7',
+    template:  template,
+
+    ui: {
+        action: 'a'
+    },
+
+    events: {
+        'click @ui.action': function (e) {
+            e.preventDefault();
+            this.getOption('action')();
+        }
+    },
+
+    templateContext: function () {
+        return {
+            title:    this.getOption('title'),
+            subtitle: this.getOption('subtitle'),
+            link:     this.getOption('link'),
+            action:   typeof this.getOption('action') === 'function'
+        };
+    }
+
+});

+ 7 - 0
src/frontend/js/app/error/main.ejs

@@ -0,0 +1,7 @@
+<i class="fe fe-alert-triangle mr-2" aria-hidden="true"></i>
+<%= code ? '<strong>' + code + '</strong> &mdash; ' : '' %>
+<%- message %>
+
+<% if (retry) { %>
+    <br><br><a href="#" class="btn btn-sm btn-warning retry">Try again</a>
+<% } %>

+ 29 - 0
src/frontend/js/app/error/main.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const template = require('./main.ejs');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'alert alert-icon alert-warning m-5',
+
+    ui: {
+        retry: 'a.retry'
+    },
+
+    events: {
+        'click @ui.retry': function (e) {
+            e.preventDefault();
+            this.getOption('retry')();
+        }
+    },
+
+    templateContext: function () {
+        return {
+            message: this.getOption('message'),
+            code:    this.getOption('code'),
+            retry:   typeof this.getOption('retry') === 'function'
+        };
+    }
+
+});

+ 0 - 1
src/frontend/js/app/nginx/404/main.ejs

@@ -1 +0,0 @@
-404

+ 0 - 9
src/frontend/js/app/nginx/404/main.js

@@ -1,9 +0,0 @@
-'use strict';
-
-const Mn       = require('backbone.marionette');
-const template = require('./main.ejs');
-
-module.exports = Mn.View.extend({
-    template: template,
-    id:       'nginx-404'
-});

+ 32 - 0
src/frontend/js/app/nginx/dead/list/item.ejs

@@ -0,0 +1,32 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)">
+        <span class="avatar-status <%- is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div><%- name %></div>
+    <div class="small text-muted">
+        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+    </div>
+</td>
+<td>
+    <div><%- email %></div>
+</td>
+<td>
+    <div><%- roles.join(', ') %></div>
+</td>
+<td class="text-center">
+    <div class="item-action dropdown">
+        <a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
+        <div class="dropdown-menu dropdown-menu-right">
+            <a href="#" class="edit-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> Edit Details</a>
+            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> Edit Permissions</a>
+            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> Set Password</a>
+            <% if (!isSelf()) { %>
+            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <div class="dropdown-divider"></div>
+            <a href="#" class="delete-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> Delete User</a>
+            <% } %>
+        </div>
+    </div>
+</td>

+ 72 - 0
src/frontend/js/app/nginx/dead/list/item.js

@@ -0,0 +1,72 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const Controller = require('../../../controller');
+const Api        = require('../../../api');
+const Cache      = require('../../../cache');
+const Tokens     = require('../../../tokens');
+const template   = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:        'a.edit-user',
+        permissions: 'a.edit-permissions',
+        password:    'a.set-password',
+        login:       'a.login',
+        delete:      'a.delete-user'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            Controller.showUserForm(this.model);
+        },
+
+        'click @ui.permissions': function (e) {
+            e.preventDefault();
+            Controller.showUserPermissions(this.model);
+        },
+
+        'click @ui.password': function (e) {
+            e.preventDefault();
+            Controller.showUserPasswordForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            Controller.showUserDeleteConfirm(this.model);
+        },
+
+        'click @ui.login': function (e) {
+            e.preventDefault();
+
+            if (Cache.User.get('id') !== this.model.get('id')) {
+                this.ui.login.prop('disabled', true).addClass('btn-disabled');
+
+                Api.Users.loginAs(this.model.get('id'))
+                    .then(res => {
+                        Tokens.addToken(res.token, res.user.nickname || res.user.name);
+                        window.location = '/';
+                        window.location.reload();
+                    })
+                    .catch(err => {
+                        alert(err.message);
+                        this.ui.login.prop('disabled', false).removeClass('btn-disabled');
+                    });
+            }
+        }
+    },
+
+    templateContext: {
+        isSelf: function () {
+            return Cache.User.get('id') === this.id;
+        }
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 10 - 0
src/frontend/js/app/nginx/dead/list/main.ejs

@@ -0,0 +1,10 @@
+<thead>
+    <th width="30">&nbsp;</th>
+    <th>Name</th>
+    <th>Email</th>
+    <th>Roles</th>
+    <th>&nbsp;</th>
+</thead>
+<tbody>
+    <!-- items -->
+</tbody>

+ 29 - 0
src/frontend/js/app/nginx/dead/list/main.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const ItemView   = require('./item');
+const template   = require('./main.ejs');
+
+const TableBody = Mn.CollectionView.extend({
+    tagName:   'tbody',
+    childView: ItemView
+});
+
+module.exports = Mn.View.extend({
+    tagName:   'table',
+    className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
+    template:  template,
+
+    regions: {
+        body: {
+            el:             'tbody',
+            replaceElement: true
+        }
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 16 - 0
src/frontend/js/app/nginx/dead/main.ejs

@@ -0,0 +1,16 @@
+<div class="card">
+    <div class="card-header">
+        <h3 class="card-title">404 Hosts</h3>
+        <div class="card-options">
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item">Add 404 Host</a>
+        </div>
+    </div>
+    <div class="card-body no-padding min-100">
+        <div class="dimmer active">
+            <div class="loader"></div>
+            <div class="dimmer-content list-region">
+                <!-- List Region -->
+            </div>
+        </div>
+    </div>
+</div>

+ 70 - 0
src/frontend/js/app/nginx/dead/main.js

@@ -0,0 +1,70 @@
+'use strict';
+
+const Mn            = require('backbone.marionette');
+const DeadHostModel = require('../../../models/dead-host');
+const Api           = require('../../api');
+const Controller    = require('../../controller');
+const ListView      = require('./list/main');
+const ErrorView     = require('../../error/main');
+const template      = require('./main.ejs');
+const EmptyView     = require('../../empty/main');
+
+module.exports = Mn.View.extend({
+    id:       'nginx-dead',
+    template: template,
+
+    ui: {
+        list_region: '.list-region',
+        add:         '.add-item',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    events: {
+        'click @ui.add': function (e) {
+            e.preventDefault();
+            Controller.showNginxDeadForm();
+        }
+    },
+
+    onRender: function () {
+        let view = this;
+
+        Api.Nginx.DeadHosts.getAll()
+            .then(response => {
+                if (!view.isDestroyed()) {
+                    if (response && response.length) {
+                        view.showChildView('list_region', new ListView({
+                            collection: new DeadHostModel.Collection(response)
+                        }));
+                    } else {
+                        view.showChildView('list_region', new EmptyView({
+                            title:    'There are no 404 Hosts',
+                            subtitle: 'Why don\'t you create one?',
+                            link:     'Add 404 Host',
+                            action:   function () {
+                                Controller.showNginxDeadForm();
+                            }
+                        }));
+                    }
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        Controller.showNginxDead();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
+});

+ 32 - 0
src/frontend/js/app/nginx/proxy/list/item.ejs

@@ -0,0 +1,32 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)">
+        <span class="avatar-status <%- is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div><%- name %></div>
+    <div class="small text-muted">
+        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+    </div>
+</td>
+<td>
+    <div><%- email %></div>
+</td>
+<td>
+    <div><%- roles.join(', ') %></div>
+</td>
+<td class="text-center">
+    <div class="item-action dropdown">
+        <a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
+        <div class="dropdown-menu dropdown-menu-right">
+            <a href="#" class="edit-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> Edit Details</a>
+            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> Edit Permissions</a>
+            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> Set Password</a>
+            <% if (!isSelf()) { %>
+            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <div class="dropdown-divider"></div>
+            <a href="#" class="delete-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> Delete User</a>
+            <% } %>
+        </div>
+    </div>
+</td>

+ 72 - 0
src/frontend/js/app/nginx/proxy/list/item.js

@@ -0,0 +1,72 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const Controller = require('../../../controller');
+const Api        = require('../../../api');
+const Cache      = require('../../../cache');
+const Tokens     = require('../../../tokens');
+const template   = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:        'a.edit-user',
+        permissions: 'a.edit-permissions',
+        password:    'a.set-password',
+        login:       'a.login',
+        delete:      'a.delete-user'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            Controller.showUserForm(this.model);
+        },
+
+        'click @ui.permissions': function (e) {
+            e.preventDefault();
+            Controller.showUserPermissions(this.model);
+        },
+
+        'click @ui.password': function (e) {
+            e.preventDefault();
+            Controller.showUserPasswordForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            Controller.showUserDeleteConfirm(this.model);
+        },
+
+        'click @ui.login': function (e) {
+            e.preventDefault();
+
+            if (Cache.User.get('id') !== this.model.get('id')) {
+                this.ui.login.prop('disabled', true).addClass('btn-disabled');
+
+                Api.Users.loginAs(this.model.get('id'))
+                    .then(res => {
+                        Tokens.addToken(res.token, res.user.nickname || res.user.name);
+                        window.location = '/';
+                        window.location.reload();
+                    })
+                    .catch(err => {
+                        alert(err.message);
+                        this.ui.login.prop('disabled', false).removeClass('btn-disabled');
+                    });
+            }
+        }
+    },
+
+    templateContext: {
+        isSelf: function () {
+            return Cache.User.get('id') === this.id;
+        }
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 10 - 0
src/frontend/js/app/nginx/proxy/list/main.ejs

@@ -0,0 +1,10 @@
+<thead>
+    <th width="30">&nbsp;</th>
+    <th>Name</th>
+    <th>Email</th>
+    <th>Roles</th>
+    <th>&nbsp;</th>
+</thead>
+<tbody>
+    <!-- items -->
+</tbody>

+ 29 - 0
src/frontend/js/app/nginx/proxy/list/main.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const ItemView   = require('./item');
+const template   = require('./main.ejs');
+
+const TableBody = Mn.CollectionView.extend({
+    tagName:   'tbody',
+    childView: ItemView
+});
+
+module.exports = Mn.View.extend({
+    tagName:   'table',
+    className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
+    template:  template,
+
+    regions: {
+        body: {
+            el:             'tbody',
+            replaceElement: true
+        }
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 16 - 1
src/frontend/js/app/nginx/proxy/main.ejs

@@ -1 +1,16 @@
-proxy
+<div class="card">
+    <div class="card-header">
+        <h3 class="card-title">Proxy Hosts</h3>
+        <div class="card-options">
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item">Add Proxy Host</a>
+        </div>
+    </div>
+    <div class="card-body no-padding min-100">
+        <div class="dimmer active">
+            <div class="loader"></div>
+            <div class="dimmer-content list-region">
+                <!-- List Region -->
+            </div>
+        </div>
+    </div>
+</div>

+ 64 - 3
src/frontend/js/app/nginx/proxy/main.js

@@ -1,9 +1,70 @@
 'use strict';
 
-const Mn       = require('backbone.marionette');
-const template = require('./main.ejs');
+const Mn             = require('backbone.marionette');
+const ProxyHostModel = require('../../../models/proxy-host');
+const Api            = require('../../api');
+const Controller     = require('../../controller');
+const ListView       = require('./list/main');
+const ErrorView      = require('../../error/main');
+const template       = require('./main.ejs');
+const EmptyView      = require('../../empty/main');
 
 module.exports = Mn.View.extend({
+    id:       'nginx-proxy',
     template: template,
-    id:       'nginx-proxy'
+
+    ui: {
+        list_region: '.list-region',
+        add:         '.add-item',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    events: {
+        'click @ui.add': function (e) {
+            e.preventDefault();
+            Controller.showNginxProxyForm();
+        }
+    },
+
+    onRender: function () {
+        let view = this;
+
+        Api.Nginx.ProxyHosts.getAll()
+            .then(response => {
+                if (!view.isDestroyed()) {
+                    if (response && response.length) {
+                        view.showChildView('list_region', new ListView({
+                            collection: new ProxyHostModel.Collection(response)
+                        }));
+                    } else {
+                        view.showChildView('list_region', new EmptyView({
+                            title:    'There are no Proxy Hosts',
+                            subtitle: 'Why don\'t you create one?',
+                            link:     'Add Proxy Host',
+                            action:   function () {
+                                Controller.showNginxProxyForm();
+                            }
+                        }));
+                    }
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        Controller.showNginxProxy();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
 });

+ 32 - 0
src/frontend/js/app/nginx/redirection/list/item.ejs

@@ -0,0 +1,32 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)">
+        <span class="avatar-status <%- is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div><%- name %></div>
+    <div class="small text-muted">
+        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+    </div>
+</td>
+<td>
+    <div><%- email %></div>
+</td>
+<td>
+    <div><%- roles.join(', ') %></div>
+</td>
+<td class="text-center">
+    <div class="item-action dropdown">
+        <a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
+        <div class="dropdown-menu dropdown-menu-right">
+            <a href="#" class="edit-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> Edit Details</a>
+            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> Edit Permissions</a>
+            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> Set Password</a>
+            <% if (!isSelf()) { %>
+            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <div class="dropdown-divider"></div>
+            <a href="#" class="delete-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> Delete User</a>
+            <% } %>
+        </div>
+    </div>
+</td>

+ 72 - 0
src/frontend/js/app/nginx/redirection/list/item.js

@@ -0,0 +1,72 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const Controller = require('../../../controller');
+const Api        = require('../../../api');
+const Cache      = require('../../../cache');
+const Tokens     = require('../../../tokens');
+const template   = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:        'a.edit-user',
+        permissions: 'a.edit-permissions',
+        password:    'a.set-password',
+        login:       'a.login',
+        delete:      'a.delete-user'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            Controller.showUserForm(this.model);
+        },
+
+        'click @ui.permissions': function (e) {
+            e.preventDefault();
+            Controller.showUserPermissions(this.model);
+        },
+
+        'click @ui.password': function (e) {
+            e.preventDefault();
+            Controller.showUserPasswordForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            Controller.showUserDeleteConfirm(this.model);
+        },
+
+        'click @ui.login': function (e) {
+            e.preventDefault();
+
+            if (Cache.User.get('id') !== this.model.get('id')) {
+                this.ui.login.prop('disabled', true).addClass('btn-disabled');
+
+                Api.Users.loginAs(this.model.get('id'))
+                    .then(res => {
+                        Tokens.addToken(res.token, res.user.nickname || res.user.name);
+                        window.location = '/';
+                        window.location.reload();
+                    })
+                    .catch(err => {
+                        alert(err.message);
+                        this.ui.login.prop('disabled', false).removeClass('btn-disabled');
+                    });
+            }
+        }
+    },
+
+    templateContext: {
+        isSelf: function () {
+            return Cache.User.get('id') === this.id;
+        }
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 10 - 0
src/frontend/js/app/nginx/redirection/list/main.ejs

@@ -0,0 +1,10 @@
+<thead>
+<th width="30">&nbsp;</th>
+<th>Name</th>
+<th>Email</th>
+<th>Roles</th>
+<th>&nbsp;</th>
+</thead>
+<tbody>
+<!-- items -->
+</tbody>

+ 29 - 0
src/frontend/js/app/nginx/redirection/list/main.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const ItemView   = require('./item');
+const template   = require('./main.ejs');
+
+const TableBody = Mn.CollectionView.extend({
+    tagName:   'tbody',
+    childView: ItemView
+});
+
+module.exports = Mn.View.extend({
+    tagName:   'table',
+    className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
+    template:  template,
+
+    regions: {
+        body: {
+            el:             'tbody',
+            replaceElement: true
+        }
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 16 - 1
src/frontend/js/app/nginx/redirection/main.ejs

@@ -1 +1,16 @@
-redirection
+<div class="card">
+    <div class="card-header">
+        <h3 class="card-title">Redirection Hosts</h3>
+        <div class="card-options">
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item">Add Redirection Host</a>
+        </div>
+    </div>
+    <div class="card-body no-padding min-100">
+        <div class="dimmer active">
+            <div class="loader"></div>
+            <div class="dimmer-content list-region">
+                <!-- List Region -->
+            </div>
+        </div>
+    </div>
+</div>

+ 64 - 3
src/frontend/js/app/nginx/redirection/main.js

@@ -1,9 +1,70 @@
 'use strict';
 
-const Mn       = require('backbone.marionette');
-const template = require('./main.ejs');
+const Mn                   = require('backbone.marionette');
+const RedirectionHostModel = require('../../../models/redirection-host');
+const Api                  = require('../../api');
+const Controller           = require('../../controller');
+const ListView             = require('./list/main');
+const ErrorView            = require('../../error/main');
+const template             = require('./main.ejs');
+const EmptyView            = require('../../empty/main');
 
 module.exports = Mn.View.extend({
+    id:       'nginx-redirections',
     template: template,
-    id:       'nginx-redirection'
+
+    ui: {
+        list_region: '.list-region',
+        add:         '.add-item',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    events: {
+        'click @ui.add': function (e) {
+            e.preventDefault();
+            Controller.showNginxRedirectionForm();
+        }
+    },
+
+    onRender: function () {
+        let view = this;
+
+        Api.Nginx.RedirectionHosts.getAll()
+            .then(response => {
+                if (!view.isDestroyed()) {
+                    if (response && response.length) {
+                        view.showChildView('list_region', new ListView({
+                            collection: new RedirectionHostModel.Collection(response)
+                        }));
+                    } else {
+                        view.showChildView('list_region', new EmptyView({
+                            title:    'There are no Redirection Hosts',
+                            subtitle: 'Why don\'t you create one?',
+                            link:     'Add Redirection Host',
+                            action:   function () {
+                                Controller.showNginxRedirectionForm();
+                            }
+                        }));
+                    }
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        Controller.showNginxRedirection();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
 });

+ 32 - 0
src/frontend/js/app/nginx/stream/list/item.ejs

@@ -0,0 +1,32 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)">
+        <span class="avatar-status <%- is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div><%- name %></div>
+    <div class="small text-muted">
+        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+    </div>
+</td>
+<td>
+    <div><%- email %></div>
+</td>
+<td>
+    <div><%- roles.join(', ') %></div>
+</td>
+<td class="text-center">
+    <div class="item-action dropdown">
+        <a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
+        <div class="dropdown-menu dropdown-menu-right">
+            <a href="#" class="edit-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> Edit Details</a>
+            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> Edit Permissions</a>
+            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> Set Password</a>
+            <% if (!isSelf()) { %>
+            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <div class="dropdown-divider"></div>
+            <a href="#" class="delete-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> Delete User</a>
+            <% } %>
+        </div>
+    </div>
+</td>

+ 72 - 0
src/frontend/js/app/nginx/stream/list/item.js

@@ -0,0 +1,72 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const Controller = require('../../../controller');
+const Api        = require('../../../api');
+const Cache      = require('../../../cache');
+const Tokens     = require('../../../tokens');
+const template   = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:        'a.edit-user',
+        permissions: 'a.edit-permissions',
+        password:    'a.set-password',
+        login:       'a.login',
+        delete:      'a.delete-user'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            Controller.showUserForm(this.model);
+        },
+
+        'click @ui.permissions': function (e) {
+            e.preventDefault();
+            Controller.showUserPermissions(this.model);
+        },
+
+        'click @ui.password': function (e) {
+            e.preventDefault();
+            Controller.showUserPasswordForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            Controller.showUserDeleteConfirm(this.model);
+        },
+
+        'click @ui.login': function (e) {
+            e.preventDefault();
+
+            if (Cache.User.get('id') !== this.model.get('id')) {
+                this.ui.login.prop('disabled', true).addClass('btn-disabled');
+
+                Api.Users.loginAs(this.model.get('id'))
+                    .then(res => {
+                        Tokens.addToken(res.token, res.user.nickname || res.user.name);
+                        window.location = '/';
+                        window.location.reload();
+                    })
+                    .catch(err => {
+                        alert(err.message);
+                        this.ui.login.prop('disabled', false).removeClass('btn-disabled');
+                    });
+            }
+        }
+    },
+
+    templateContext: {
+        isSelf: function () {
+            return Cache.User.get('id') === this.id;
+        }
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 10 - 0
src/frontend/js/app/nginx/stream/list/main.ejs

@@ -0,0 +1,10 @@
+<thead>
+    <th width="30">&nbsp;</th>
+    <th>Name</th>
+    <th>Email</th>
+    <th>Roles</th>
+    <th>&nbsp;</th>
+</thead>
+<tbody>
+    <!-- items -->
+</tbody>

+ 29 - 0
src/frontend/js/app/nginx/stream/list/main.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const ItemView   = require('./item');
+const template   = require('./main.ejs');
+
+const TableBody = Mn.CollectionView.extend({
+    tagName:   'tbody',
+    childView: ItemView
+});
+
+module.exports = Mn.View.extend({
+    tagName:   'table',
+    className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
+    template:  template,
+
+    regions: {
+        body: {
+            el:             'tbody',
+            replaceElement: true
+        }
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 16 - 1
src/frontend/js/app/nginx/stream/main.ejs

@@ -1 +1,16 @@
-stream
+<div class="card">
+    <div class="card-header">
+        <h3 class="card-title">Streams</h3>
+        <div class="card-options">
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item">Add Stream</a>
+        </div>
+    </div>
+    <div class="card-body no-padding min-100">
+        <div class="dimmer active">
+            <div class="loader"></div>
+            <div class="dimmer-content list-region">
+                <!-- List Region -->
+            </div>
+        </div>
+    </div>
+</div>

+ 64 - 3
src/frontend/js/app/nginx/stream/main.js

@@ -1,9 +1,70 @@
 'use strict';
 
-const Mn       = require('backbone.marionette');
-const template = require('./main.ejs');
+const Mn          = require('backbone.marionette');
+const StreamModel = require('../../../models/stream');
+const Api         = require('../../api');
+const Controller  = require('../../controller');
+const ListView    = require('./list/main');
+const ErrorView   = require('../../error/main');
+const template    = require('./main.ejs');
+const EmptyView   = require('../../empty/main');
 
 module.exports = Mn.View.extend({
+    id:       'nginx-streams',
     template: template,
-    id:       'nginx-stream'
+
+    ui: {
+        list_region: '.list-region',
+        add:         '.add-item',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    events: {
+        'click @ui.add': function (e) {
+            e.preventDefault();
+            Controller.showNginxStreamForm();
+        }
+    },
+
+    onRender: function () {
+        let view = this;
+
+        Api.Nginx.RedirectionHosts.getAll()
+            .then(response => {
+                if (!view.isDestroyed()) {
+                    if (response && response.length) {
+                        view.showChildView('list_region', new ListView({
+                            collection: new StreamModel.Collection(response)
+                        }));
+                    } else {
+                        view.showChildView('list_region', new EmptyView({
+                            title:    'There are no Streams',
+                            subtitle: 'Why don\'t you create one?',
+                            link:     'Add Stream',
+                            action:   function () {
+                                Controller.showNginxStreamForm();
+                            }
+                        }));
+                    }
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        Controller.showNginxStream();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
 });

+ 1 - 1
src/frontend/js/app/router.js

@@ -10,7 +10,7 @@ module.exports = Mn.AppRouter.extend({
         logout:              'logout',
         'nginx/proxy':       'showNginxProxy',
         'nginx/redirection': 'showNginxRedirection',
-        'nginx/404':         'showNginx404',
+        'nginx/404':         'showNginxDead',
         'nginx/stream':      'showNginxStream',
         'nginx/access':      'showNginxAccess',
         '*default':          'showDashboard'

+ 4 - 0
src/frontend/js/app/ui/header/main.js

@@ -51,5 +51,9 @@ module.exports = Mn.View.extend({
 
             return 'Sign out';
         }
+    },
+
+    initialize: function () {
+        this.listenTo(Cache.User, 'change', this.render);
     }
 });

+ 13 - 0
src/frontend/js/app/ui/menu/main.ejs

@@ -8,15 +8,28 @@
                 <li class="nav-item dropdown">
                     <a href="#" class="nav-link" data-toggle="dropdown"><i class="fe fe-monitor"></i> Hosts</a>
                     <div class="dropdown-menu dropdown-menu-arrow">
+                        <% if (canShow('proxy_hosts')) { %>
                         <a href="/nginx/proxy" class="dropdown-item ">Proxy Hosts</a>
+                        <% } %>
+
+                        <% if (canShow('redirection_hosts')) { %>
                         <a href="/nginx/redirection" class="dropdown-item ">Redirections</a>
+                        <% } %>
+
+                        <% if (canShow('streams')) { %>
                         <a href="/nginx/stream" class="dropdown-item ">Streams</a>
+                        <% } %>
+
+                        <% if (canShow('dead_hosts')) { %>
                         <a href="/nginx/404" class="dropdown-item ">404 Hosts</a>
+                        <% } %>
                     </div>
                 </li>
+                <% if (canShow('access_lists')) { %>
                 <li class="nav-item">
                     <a href="/nginx/access" class="nav-link"><i class="fe fe-lock"></i> Access Lists</a>
                 </li>
+                <% } %>
                 <% if (showUsers()) { %>
                 <li class="nav-item">
                     <a href="/users" class="nav-link"><i class="fe fe-users"></i> Users</a>

+ 13 - 2
src/frontend/js/app/ui/menu/main.js

@@ -17,14 +17,25 @@ module.exports = Mn.View.extend({
 
     events: {
         'click @ui.links': function (e) {
-            e.preventDefault();
-            Controller.navigate($(e.currentTarget).attr('href'), true);
+            let href = $(e.currentTarget).attr('href');
+            if (href !== '#') {
+                e.preventDefault();
+                Controller.navigate(href, true);
+            }
         }
     },
 
     templateContext: {
         showUsers: function () {
             return Cache.User.isAdmin();
+        },
+
+        canShow: function (perm) {
+            return Cache.User.isAdmin() || Cache.User.canView(perm);
         }
+    },
+
+    initialize: function () {
+        this.listenTo(Cache.User, 'change', this.render);
     }
 });

+ 14 - 10
src/frontend/js/app/users/main.js

@@ -8,12 +8,12 @@ const ListView   = require('./list/main');
 const template   = require('./main.ejs');
 
 module.exports = Mn.View.extend({
-    id:        'users',
-    template:  template,
+    id:       'users',
+    template: template,
 
     ui: {
         list_region: '.list-region',
-        add_user:    '.add-user',
+        add:         '.add-item',
         dimmer:      '.dimmer'
     },
 
@@ -22,7 +22,7 @@ module.exports = Mn.View.extend({
     },
 
     events: {
-        'click @ui.add_user': function (e) {
+        'click @ui.add': function (e) {
             e.preventDefault();
             Controller.showUserForm(new UserModel.Model());
         }
@@ -37,15 +37,19 @@ module.exports = Mn.View.extend({
                     view.showChildView('list_region', new ListView({
                         collection: new UserModel.Collection(response)
                     }));
-
-                    // Remove loader
-                    view.ui.dimmer.removeClass('active');
                 }
             })
             .catch(err => {
-                console.log(err);
-                //Controller.showError(err, 'Could not fetch Users');
-                //view.trigger('loaded');
+                view.showChildView('list_region', new ErrorView({
+                    code:      err.code,
+                    message:   err.message,
+                    retry:     function () { Controller.showUsers(); }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
             });
     }
 });

+ 26 - 0
src/frontend/js/models/dead-host.js

@@ -0,0 +1,26 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            created_on:          null,
+            modified_on:         null,
+            owner:               null,
+            domain_name:         '',
+            ssl_enabled:         false,
+            ssl_provider:        false,
+            meta:                []
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 32 - 0
src/frontend/js/models/proxy-host.js

@@ -0,0 +1,32 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            created_on:      null,
+            modified_on:     null,
+            owner:           null,
+            domain_name:     '',
+            forward_ip:      '',
+            forward_port:    0,
+            access_list_id:  0,
+            ssl_enabled:     false,
+            ssl_provider:    false,
+            ssl_forced:      false,
+            caching_enabled: false,
+            block_exploits:  false,
+            meta:            []
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 29 - 0
src/frontend/js/models/redirection-host.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            created_on:          null,
+            modified_on:         null,
+            owner:               null,
+            domain_name:         '',
+            forward_domain_name: '',
+            preserve_path:       false,
+            ssl_enabled:         false,
+            ssl_provider:        false,
+            block_exploits:      false,
+            meta:                []
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 28 - 0
src/frontend/js/models/stream.js

@@ -0,0 +1,28 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            created_on:      null,
+            modified_on:     null,
+            owner:           null,
+            incoming_port:   0,
+            forward_ip:      '',
+            forwarding_port: 0,
+            tcp_forwarding:  true,
+            udp_forwarding:  false,
+            meta:            []
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 25 - 0
src/frontend/js/models/user.js

@@ -17,8 +17,33 @@ const model = Backbone.Model.extend({
         };
     },
 
+    /**
+     * @returns {Boolean}
+     */
     isAdmin: function () {
         return _.indexOf(this.get('roles'), 'admin') !== -1;
+    },
+
+    /**
+     * Checks if the perm has either `view` or `manage` value
+     *
+     * @param   {String}  item
+     * @returns {Boolean}
+     */
+    canView: function (item) {
+        let permissions = this.get('permissions');
+        return permissions !== null && typeof permissions[item] !== 'undefined' && ['view', 'manage'].indexOf(permissions[item]) !== -1;
+    },
+
+    /**
+     * Checks if the perm has `manage` value
+     *
+     * @param   {String}  item
+     * @returns {Boolean}
+     */
+    canManage: function (item) {
+        let permissions = this.get('permissions');
+        return permissions !== null && typeof permissions[item] !== 'undefined' && permissions[item] === 'manage';
     }
 });