Răsfoiți Sursa

Access lists shell, redirections work

Jamie Curnow 7 ani în urmă
părinte
comite
2a0c83433e
43 a modificat fișierele cu 2096 adăugiri și 180 ștergeri
  1. 183 0
      src/backend/internal/access-list.js
  2. 1 1
      src/backend/internal/proxy-host.js
  3. 208 2
      src/backend/internal/redirection-host.js
  4. 23 0
      src/backend/lib/access/access_lists-create.json
  5. 23 0
      src/backend/lib/access/access_lists-delete.json
  6. 23 0
      src/backend/lib/access/access_lists-get.json
  7. 23 0
      src/backend/lib/access/access_lists-list.json
  8. 23 0
      src/backend/lib/access/access_lists-update.json
  9. 23 0
      src/backend/lib/access/redirection_hosts-create.json
  10. 23 0
      src/backend/lib/access/redirection_hosts-delete.json
  11. 23 0
      src/backend/lib/access/redirection_hosts-get.json
  12. 23 0
      src/backend/lib/access/redirection_hosts-update.json
  13. 1 0
      src/backend/routes/api/main.js
  14. 150 0
      src/backend/routes/api/nginx/access_lists.js
  15. 37 3
      src/backend/routes/api/nginx/redirection_hosts.js
  16. 22 0
      src/backend/schema/definitions.json
  17. 36 0
      src/backend/schema/endpoints/proxy-hosts.json
  18. 118 104
      src/backend/schema/endpoints/redirection-hosts.json
  19. 36 36
      src/frontend/js/app/api.js
  20. 1 0
      src/frontend/js/app/audit-log/main.ejs
  21. 39 0
      src/frontend/js/app/controller.js
  22. 19 0
      src/frontend/js/app/nginx/access/delete.ejs
  23. 36 0
      src/frontend/js/app/nginx/access/delete.js
  24. 122 0
      src/frontend/js/app/nginx/access/form.ejs
  25. 195 0
      src/frontend/js/app/nginx/access/form.js
  26. 40 0
      src/frontend/js/app/nginx/access/list/item.ejs
  27. 35 0
      src/frontend/js/app/nginx/access/list/item.js
  28. 13 0
      src/frontend/js/app/nginx/access/list/main.ejs
  29. 34 0
      src/frontend/js/app/nginx/access/list/main.js
  30. 19 1
      src/frontend/js/app/nginx/access/main.ejs
  31. 71 3
      src/frontend/js/app/nginx/access/main.js
  32. 1 2
      src/frontend/js/app/nginx/proxy/delete.ejs
  33. 26 0
      src/frontend/js/app/nginx/proxy/form.ejs
  34. 23 0
      src/frontend/js/app/nginx/redirection/delete.ejs
  35. 36 0
      src/frontend/js/app/nginx/redirection/delete.js
  36. 134 0
      src/frontend/js/app/nginx/redirection/form.ejs
  37. 187 0
      src/frontend/js/app/nginx/redirection/form.js
  38. 16 17
      src/frontend/js/app/nginx/redirection/main.js
  39. 1 0
      src/frontend/js/app/users/main.ejs
  40. 18 6
      src/frontend/js/i18n/messages.json
  41. 24 0
      src/frontend/js/models/access-list.js
  42. 1 1
      src/frontend/js/models/proxy-host.js
  43. 6 4
      src/frontend/js/models/redirection-host.js

+ 183 - 0
src/backend/internal/access-list.js

@@ -0,0 +1,183 @@
+'use strict';
+
+const _               = require('lodash');
+const error           = require('../lib/error');
+const accessListModel = require('../models/access_list');
+
+function omissions () {
+    return ['is_deleted'];
+}
+
+const internalAccessList = {
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        return access.can('access_lists:create', data)
+            .then(access_data => {
+                // TODO
+                return {};
+            });
+    },
+
+    /**
+     * @param  {Access}  access
+     * @param  {Object}  data
+     * @param  {Integer} data.id
+     * @param  {String}  [data.email]
+     * @param  {String}  [data.name]
+     * @return {Promise}
+     */
+    update: (access, data) => {
+        return access.can('access_lists:update', data.id)
+            .then(access_data => {
+                // TODO
+                return {};
+            });
+    },
+
+    /**
+     * @param  {Access}   access
+     * @param  {Object}   data
+     * @param  {Integer}  data.id
+     * @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('access_lists:get', data.id)
+            .then(access_data => {
+                let query = accessListModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .andWhere('id', data.id)
+                    .allowEager('[owner]')
+                    .first();
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // 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('access_lists:delete', data.id)
+            .then(() => {
+                return internalAccessList.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (!row) {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+
+                return accessListModel
+                    .query()
+                    .where('id', row.id)
+                    .patch({
+                        is_deleted: 1
+                    });
+            })
+            .then(() => {
+                return true;
+            });
+    },
+
+    /**
+     * All Lists
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('access_lists:list')
+            .then(access_data => {
+                let query = accessListModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .groupBy('id')
+                    .omit(['is_deleted'])
+                    .allowEager('[owner]')
+                    .orderBy('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('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 = accessListModel
+            .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 = internalAccessList;

+ 1 - 1
src/backend/internal/proxy-host.js

@@ -125,7 +125,7 @@ const internalProxyHost = {
                     .query()
                     .where('is_deleted', 0)
                     .andWhere('id', data.id)
-                    .allowEager('[permissions]')
+                    .allowEager('[owner,access_list]')
                     .first();
 
                 if (access_data.permission_visibility !== 'all') {

+ 208 - 2
src/backend/internal/redirection-host.js

@@ -3,12 +3,210 @@
 const _                    = require('lodash');
 const error                = require('../lib/error');
 const redirectionHostModel = require('../models/redirection_host');
+const internalHost         = require('./host');
 
 function omissions () {
     return ['is_deleted'];
 }
 
-const internalProxyHost = {
+const internalRedirectionHost = {
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        return access.can('redirection_hosts:create', data)
+            .then(access_data => {
+                // Get a list of the domain names and check each of them against existing records
+                let domain_name_check_promises = [];
+
+                data.domain_names.map(function (domain_name) {
+                    domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name));
+                });
+
+                return Promise.all(domain_name_check_promises)
+                    .then(check_results => {
+                        check_results.map(function (result) {
+                            if (result.is_taken) {
+                                throw new error.ValidationError(result.hostname + ' is already in use');
+                            }
+                        });
+                    });
+            })
+            .then(() => {
+                // At this point the domains should have been checked
+                data.owner_user_id = access.token.get('attrs').id;
+
+                if (typeof data.meta === 'undefined') {
+                    data.meta = {};
+                }
+
+                return redirectionHostModel
+                    .query()
+                    .omit(omissions())
+                    .insertAndFetch(data);
+            })
+            .then(row => {
+                return _.omit(row, omissions());
+            });
+    },
+
+    /**
+     * @param  {Access}  access
+     * @param  {Object}  data
+     * @param  {Integer} data.id
+     * @param  {String}  [data.email]
+     * @param  {String}  [data.name]
+     * @return {Promise}
+     */
+    update: (access, data) => {
+        return access.can('redirection_hosts:update', data.id)
+            .then(access_data => {
+                // Get a list of the domain names and check each of them against existing records
+                let domain_name_check_promises = [];
+
+                if (typeof data.domain_names !== 'undefined') {
+                    data.domain_names.map(function (domain_name) {
+                        domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'redirection', data.id));
+                    });
+
+                    return Promise.all(domain_name_check_promises)
+                        .then(check_results => {
+                            check_results.map(function (result) {
+                                if (result.is_taken) {
+                                    throw new error.ValidationError(result.hostname + ' is already in use');
+                                }
+                            });
+                        });
+                }
+            })
+            .then(() => {
+                return internalRedirectionHost.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (row.id !== data.id) {
+                    // Sanity check that something crazy hasn't happened
+                    throw new error.InternalValidationError('Redirection Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
+                }
+
+                return redirectionHostModel
+                    .query()
+                    .omit(omissions())
+                    .patchAndFetchById(row.id, data)
+                    .then(saved_row => {
+                        saved_row.meta = internalHost.cleanMeta(saved_row.meta);
+                        return _.omit(saved_row, omissions());
+                    });
+            });
+    },
+
+    /**
+     * @param  {Access}   access
+     * @param  {Object}   data
+     * @param  {Integer}  data.id
+     * @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('redirection_hosts:get', data.id)
+            .then(access_data => {
+                let query = redirectionHostModel
+                    .query()
+                    .where('is_deleted', 0)
+                    .andWhere('id', data.id)
+                    .allowEager('[owner]')
+                    .first();
+
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
+                // 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) {
+                    row.meta = internalHost.cleanMeta(row.meta);
+                    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('redirection_hosts:delete', data.id)
+            .then(() => {
+                return internalRedirectionHost.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (!row) {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+
+                return redirectionHostModel
+                    .query()
+                    .where('id', row.id)
+                    .patch({
+                        is_deleted: 1
+                    });
+            })
+            .then(() => {
+                return true;
+            });
+    },
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @param   {Integer} data.id
+     * @param   {Object}  data.files
+     * @returns {Promise}
+     */
+    setCerts: (access, data) => {
+        return internalRedirectionHost.get(access, {id: data.id})
+            .then(row => {
+                _.map(data.files, (file, name) => {
+                    if (internalHost.allowed_ssl_files.indexOf(name) !== -1) {
+                        row.meta[name] = file.data.toString();
+                    }
+                });
+
+                return internalRedirectionHost.update(access, {
+                    id:   data.id,
+                    meta: row.meta
+                });
+            })
+            .then(row => {
+                return _.pick(row.meta, internalHost.allowed_ssl_files);
+            });
+    },
 
     /**
      * All Hosts
@@ -26,6 +224,7 @@ const internalProxyHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
+                    .allowEager('[owner]')
                     .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
@@ -44,6 +243,13 @@ const internalProxyHost = {
                 }
 
                 return query;
+            })
+            .then(rows => {
+                rows.map(row => {
+                    row.meta = internalHost.cleanMeta(row.meta);
+                });
+
+                return rows;
             });
     },
 
@@ -71,4 +277,4 @@ const internalProxyHost = {
     }
 };
 
-module.exports = internalProxyHost;
+module.exports = internalRedirectionHost;

+ 23 - 0
src/backend/lib/access/access_lists-create.json

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

+ 23 - 0
src/backend/lib/access/access_lists-delete.json

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

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

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

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

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

+ 23 - 0
src/backend/lib/access/access_lists-update.json

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

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

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

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

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

+ 23 - 0
src/backend/lib/access/redirection_hosts-get.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/redirection_hosts-update.json

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

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

@@ -35,6 +35,7 @@ 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'));
+router.use('/nginx/access-lists', require('./nginx/access_lists'));
 
 /**
  * API 404 for all other routes

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

@@ -0,0 +1,150 @@
+'use strict';
+
+const express            = require('express');
+const validator          = require('../../../lib/validator');
+const jwtdecode          = require('../../../lib/express/jwt-decode');
+const internalAccessList = require('../../../internal/access-list');
+const apiValidator       = require('../../../lib/validator/api');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/nginx/access-lists
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * GET /api/nginx/access-lists
+     *
+     * Retrieve all access-lists
+     */
+    .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 internalAccessList.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    })
+
+    /**
+     * POST /api/nginx/access-lists
+     *
+     * Create a new access-list
+     */
+    .post((req, res, next) => {
+        apiValidator({$ref: 'endpoints/access-lists#/links/1/schema'}, req.body)
+            .then(payload => {
+                return internalAccessList.create(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(201)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+/**
+ * Specific access-list
+ *
+ * /api/nginx/access-lists/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/access-lists/123
+     *
+     * Retrieve a specific access-list
+     */
+    .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 internalAccessList.get(res.locals.access, {
+                    id:     parseInt(data.host_id, 10),
+                    expand: data.expand
+                });
+            })
+            .then(row => {
+                res.status(200)
+                    .send(row);
+            })
+            .catch(next);
+    })
+
+    /**
+     * PUT /api/nginx/access-lists/123
+     *
+     * Update and existing access-list
+     */
+    .put((req, res, next) => {
+        apiValidator({$ref: 'endpoints/access-lists#/links/2/schema'}, req.body)
+            .then(payload => {
+                payload.id = parseInt(req.params.host_id, 10);
+                return internalAccessList.update(res.locals.access, payload);
+            })
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    })
+
+    /**
+     * DELETE /api/nginx/access-lists/123
+     *
+     * Update and existing access-list
+     */
+    .delete((req, res, next) => {
+        internalAccessList.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)})
+            .then(result => {
+                res.status(200)
+                    .send(result);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 37 - 3
src/backend/routes/api/nginx/redirection_hosts.js

@@ -104,7 +104,7 @@ router
         })
             .then(data => {
                 return internalRedirectionHost.get(res.locals.access, {
-                    id:     data.host_id,
+                    id:     parseInt(data.host_id, 10),
                     expand: data.expand
                 });
             })
@@ -123,7 +123,7 @@ router
     .put((req, res, next) => {
         apiValidator({$ref: 'endpoints/redirection-hosts#/links/2/schema'}, req.body)
             .then(payload => {
-                payload.id = req.params.host_id;
+                payload.id = parseInt(req.params.host_id, 10);
                 return internalRedirectionHost.update(res.locals.access, payload);
             })
             .then(result => {
@@ -139,7 +139,7 @@ router
      * Update and existing redirection-host
      */
     .delete((req, res, next) => {
-        internalRedirectionHost.delete(res.locals.access, {id: req.params.host_id})
+        internalRedirectionHost.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)})
             .then(result => {
                 res.status(200)
                     .send(result);
@@ -147,4 +147,38 @@ router
             .catch(next);
     });
 
+/**
+ * Specific redirection-host Certificates
+ *
+ * /api/nginx/redirection-hosts/123/certificates
+ */
+router
+    .route('/:host_id/certificates')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * POST /api/nginx/redirection-hosts/123/certificates
+     *
+     * Upload certifications
+     */
+    .post((req, res, next) => {
+        if (!req.files) {
+            res.status(400)
+                .send({error: 'No files were uploaded'});
+        } else {
+            internalRedirectionHost.setCerts(res.locals.access, {
+                id:    parseInt(req.params.host_id, 10),
+                files: req.files
+            })
+                .then(result => {
+                    res.status(200)
+                        .send(result);
+                })
+                .catch(next);
+        }
+    });
+
 module.exports = router;

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

@@ -116,6 +116,12 @@
       "type": "integer",
       "minimum": 1
     },
+    "access_list_id": {
+      "description": "Access List ID",
+      "example": 1234,
+      "type": "integer",
+      "minimum": 0
+    },
     "name": {
       "type": "string",
       "minLength": 1,
@@ -135,6 +141,12 @@
       "minLength": 8,
       "maxLength": 255
     },
+    "domain_name": {
+      "description": "Domain Name",
+      "example": "jc21.com",
+      "type": "string",
+      "pattern": "^(?:[^.*]+\\.?)+[^.]$"
+    },
     "domain_names": {
       "description": "Domain Names separated by a comma",
       "example": "*.jc21.com,blog.jc21.com",
@@ -159,6 +171,16 @@
     "ssl_provider": {
       "type": "string",
       "pattern": "^(letsencrypt|other)$"
+    },
+    "block_exploits": {
+      "description": "Should we block common exploits",
+      "example": true,
+      "type": "boolean"
+    },
+    "caching_enabled": {
+      "description": "Should we cache assets",
+      "example": true,
+      "type": "boolean"
     }
   }
 }

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

@@ -36,6 +36,15 @@
     "ssl_provider": {
       "$ref": "../definitions.json#/definitions/ssl_provider"
     },
+    "block_exploits": {
+      "$ref": "../definitions.json#/definitions/block_exploits"
+    },
+    "caching_enabled": {
+      "$ref": "../definitions.json#/definitions/caching_enabled"
+    },
+    "access_list_id": {
+      "$ref": "../definitions.json#/definitions/access_list_id"
+    },
     "meta": {
       "type": "object",
       "additionalProperties": false,
@@ -78,6 +87,15 @@
     "ssl_provider": {
       "$ref": "#/definitions/ssl_provider"
     },
+    "block_exploits": {
+      "$ref": "#/definitions/block_exploits"
+    },
+    "caching_enabled": {
+      "$ref": "#/definitions/caching_enabled"
+    },
+    "access_list_id": {
+      "$ref": "#/definitions/access_list_id"
+    },
     "meta": {
       "$ref": "#/definitions/meta"
     }
@@ -136,6 +154,15 @@
           "ssl_provider": {
             "$ref": "#/definitions/ssl_provider"
           },
+          "block_exploits": {
+            "$ref": "#/definitions/block_exploits"
+          },
+          "caching_enabled": {
+            "$ref": "#/definitions/caching_enabled"
+          },
+          "access_list_id": {
+            "$ref": "#/definitions/access_list_id"
+          },
           "meta": {
             "$ref": "#/definitions/meta"
           }
@@ -178,6 +205,15 @@
           "ssl_provider": {
             "$ref": "#/definitions/ssl_provider"
           },
+          "block_exploits": {
+            "$ref": "#/definitions/block_exploits"
+          },
+          "caching_enabled": {
+            "$ref": "#/definitions/caching_enabled"
+          },
+          "access_list_id": {
+            "$ref": "#/definitions/access_list_id"
+          },
           "meta": {
             "$ref": "#/definitions/meta"
           }

+ 118 - 104
src/backend/schema/endpoints/redirection-hosts.json

@@ -1,7 +1,7 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
   "$id": "endpoints/redirection-hosts",
-  "title": "Users",
+  "title": "Redirection Hosts",
   "description": "Endpoints relating to Redirection Hosts",
   "stability": "stable",
   "type": "object",
@@ -15,49 +15,83 @@
     "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,
+    "domain_names": {
+      "$ref": "../definitions.json#/definitions/domain_names"
+    },
+    "forward_domain_name": {
+      "$ref": "../definitions.json#/definitions/domain_name"
+    },
+    "preserve_path": {
+      "description": "Should the path be preserved",
+      "example": true,
       "type": "boolean"
+    },
+    "ssl_enabled": {
+      "$ref": "../definitions.json#/definitions/ssl_enabled"
+    },
+    "ssl_forced": {
+      "$ref": "../definitions.json#/definitions/ssl_forced"
+    },
+    "ssl_provider": {
+      "$ref": "../definitions.json#/definitions/ssl_provider"
+    },
+    "block_exploits": {
+      "$ref": "../definitions.json#/definitions/block_exploits"
+    },
+    "meta": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "letsencrypt_email": {
+          "type": "string",
+          "format": "email"
+        },
+        "letsencrypt_agree": {
+          "type": "boolean"
+        }
+      }
+    }
+  },
+  "properties": {
+    "id": {
+      "$ref": "#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "#/definitions/modified_on"
+    },
+    "domain_names": {
+      "$ref": "#/definitions/domain_names"
+    },
+    "forward_domain_name": {
+      "$ref": "#/definitions/forward_domain_name"
+    },
+    "preserve_path": {
+      "$ref": "#/definitions/preserve_path"
+    },
+    "ssl_enabled": {
+      "$ref": "#/definitions/ssl_enabled"
+    },
+    "ssl_forced": {
+      "$ref": "#/definitions/ssl_forced"
+    },
+    "ssl_provider": {
+      "$ref": "#/definitions/ssl_provider"
+    },
+    "block_exploits": {
+      "$ref": "#/definitions/block_exploits"
+    },
+    "meta": {
+      "$ref": "#/definitions/meta"
     }
   },
   "links": [
     {
       "title": "List",
-      "description": "Returns a list of Users",
-      "href": "/users",
+      "description": "Returns a list of Redirection Hosts",
+      "href": "/nginx/redirection-hosts",
       "access": "private",
       "method": "GET",
       "rel": "self",
@@ -73,8 +107,8 @@
     },
     {
       "title": "Create",
-      "description": "Creates a new User",
-      "href": "/users",
+      "description": "Creates a new Redirection Host",
+      "href": "/nginx/redirection-hosts",
       "access": "private",
       "method": "POST",
       "rel": "create",
@@ -84,33 +118,33 @@
       "schema": {
         "type": "object",
         "required": [
-          "name",
-          "nickname",
-          "email"
+          "domain_names",
+          "forward_domain_name"
         ],
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "forward_domain_name": {
+            "$ref": "#/definitions/forward_domain_name"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "preserve_path": {
+            "$ref": "#/definitions/preserve_path"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "ssl_enabled": {
+            "$ref": "#/definitions/ssl_enabled"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "ssl_forced": {
+            "$ref": "#/definitions/ssl_forced"
           },
-          "auth": {
-            "type": "object",
-            "description": "Auth Credentials",
-            "example": {
-              "type": "password",
-              "secret": "bigredhorsebanana"
-            }
+          "ssl_provider": {
+            "$ref": "#/definitions/ssl_provider"
+          },
+          "block_exploits": {
+            "$ref": "#/definitions/block_exploits"
+          },
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -122,8 +156,8 @@
     },
     {
       "title": "Update",
-      "description": "Updates a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Updates a existing Redirection Host",
+      "href": "/nginx/redirection-hosts/{definitions.identity.example}",
       "access": "private",
       "method": "PUT",
       "rel": "update",
@@ -133,20 +167,29 @@
       "schema": {
         "type": "object",
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
+          },
+          "forward_domain_name": {
+            "$ref": "#/definitions/forward_domain_name"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "preserve_path": {
+            "$ref": "#/definitions/preserve_path"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "ssl_enabled": {
+            "$ref": "#/definitions/ssl_enabled"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "ssl_forced": {
+            "$ref": "#/definitions/ssl_forced"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "ssl_provider": {
+            "$ref": "#/definitions/ssl_provider"
+          },
+          "block_exploits": {
+            "$ref": "#/definitions/block_exploits"
+          },
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -158,8 +201,8 @@
     },
     {
       "title": "Delete",
-      "description": "Deletes a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Deletes a existing Redirection Host",
+      "href": "/nginx/redirection-hosts/{definitions.identity.example}",
       "access": "private",
       "method": "DELETE",
       "rel": "delete",
@@ -170,34 +213,5 @@
         "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"
-    }
-  }
+  ]
 }

+ 36 - 36
src/frontend/js/app/api.js

@@ -377,7 +377,7 @@ module.exports = {
              * @params {Promise}
              */
             setCerts: function (id, form_data) {
-                return upload('nginx/redirection-hosts/' + id + '/certificates', form_data);
+                return FileUpload('nginx/redirection-hosts/' + id + '/certificates', form_data);
             }
         },
 
@@ -460,46 +460,46 @@ module.exports = {
              * @params {Promise}
              */
             setCerts: function (id, form_data) {
-                return upload('nginx/dead-hosts/' + id + '/certificates', form_data);
+                return FileUpload('nginx/dead-hosts/' + id + '/certificates', form_data);
             }
-        }
-    },
-
-    AccessLists: {
-        /**
-         * @param   {Array}    [expand]
-         * @param   {String}   [query]
-         * @returns {Promise}
-         */
-        getAll: function (expand, query) {
-            return getAllObjects('access-lists', expand, query);
         },
 
-        /**
-         * @param {Object}  data
-         */
-        create: function (data) {
-            return fetch('post', 'access-lists', data);
-        },
+        AccessLists: {
+            /**
+             * @param   {Array}    [expand]
+             * @param   {String}   [query]
+             * @returns {Promise}
+             */
+            getAll: function (expand, query) {
+                return getAllObjects('nginx/access-lists', expand, query);
+            },
 
-        /**
-         * @param   {Object}   data
-         * @param   {Integer}  data.id
-         * @returns {Promise}
-         */
-        update: function (data) {
-            let id = data.id;
-            delete data.id;
-            return fetch('put', 'access-lists/' + id, data);
-        },
+            /**
+             * @param {Object}  data
+             */
+            create: function (data) {
+                return fetch('post', 'nginx/access-lists', data);
+            },
 
-        /**
-         * @param   {Integer}  id
-         * @returns {Promise}
-         */
-        delete: function (id) {
-            return fetch('delete', 'access-lists/' + id);
-        }
+            /**
+             * @param   {Object}   data
+             * @param   {Integer}  data.id
+             * @returns {Promise}
+             */
+            update: function (data) {
+                let id = data.id;
+                delete data.id;
+                return fetch('put', 'nginx/access-lists/' + id, data);
+            },
+
+            /**
+             * @param   {Integer}  id
+             * @returns {Promise}
+             */
+            delete: function (id) {
+                return fetch('delete', 'nginx/access-lists/' + id);
+            }
+        },
     },
 
     AuditLog: {

+ 1 - 0
src/frontend/js/app/audit-log/main.ejs

@@ -1,4 +1,5 @@
 <div class="card">
+    <div class="card-status bg-teal"></div>
     <div class="card-header">
         <h3 class="card-title"><%- i18n('audit-log', 'title') %></h3>
     </div>

+ 39 - 0
src/frontend/js/app/controller.js

@@ -174,6 +174,32 @@ module.exports = {
         }
     },
 
+    /**
+     * Nginx Redirection Host Form
+     *
+     * @param [model]
+     */
+    showNginxRedirectionForm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) {
+            require(['./main', './nginx/redirection/form'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
+    /**
+     * Proxy Redirection Delete Confirm
+     *
+     * @param model
+     */
+    showNginxRedirectionDeleteConfirm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('redirection_hosts')) {
+            require(['./main', './nginx/redirection/delete'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Nginx Stream Hosts
      */
@@ -216,6 +242,19 @@ module.exports = {
         }
     },
 
+    /**
+     * Nginx Access List Form
+     *
+     * @param [model]
+     */
+    showNginxAccessListForm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('access_lists')) {
+            require(['./main', './nginx/access/form'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Audit Log
      */

+ 19 - 0
src/frontend/js/app/nginx/access/delete.ejs

@@ -0,0 +1,19 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('access-lists', 'delete') %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body">
+        <form>
+            <div class="row">
+                <div class="col-sm-12 col-md-12">
+                    <%= i18n('access-lists', 'delete-confirm') %>
+                </div>
+            </div>
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-danger save"><%- i18n('str', 'sure') %></button>
+    </div>
+</div>

+ 36 - 0
src/frontend/js/app/nginx/access/delete.js

@@ -0,0 +1,36 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../main');
+const template = require('./delete.ejs');
+
+require('jquery-serializejson');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'modal-dialog',
+
+    ui: {
+        form:    'form',
+        buttons: '.modal-footer button',
+        cancel:  'button.cancel',
+        save:    'button.save'
+    },
+
+    events: {
+
+        'click @ui.save': function (e) {
+            e.preventDefault();
+
+            App.Api.Nginx.ProxyHosts.delete(this.model.get('id'))
+                .then(() => {
+                    App.Controller.showNginxAccess();
+                    App.UI.closeModal();
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    }
+});

+ 122 - 0
src/frontend/js/app/nginx/access/form.ejs

@@ -0,0 +1,122 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('proxy-hosts', 'form-title', {id: id}) %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body has-tabs">
+        <form>
+            <ul class="nav nav-tabs" role="tablist">
+                <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
+                <li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('str', 'ssl') %></a></li>
+            </ul>
+            <div class="tab-content">
+                <!-- Details -->
+                <div role="tabpanel" class="tab-pane active" id="details">
+                    <div class="row">
+
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
+                                <input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-8 col-md-8">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('proxy-hosts', 'forward-ip') %><span class="form-required">*</span></label>
+                                <input type="text" name="forward_ip" class="form-control text-monospace" placeholder="000.000.000.000" value="<%- forward_ip %>" autocomplete="off" maxlength="15" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-4 col-md-4">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('proxy-hosts', 'forward-port') %> <span class="form-required">*</span></label>
+                                <input name="forward_port" type="number" class="form-control text-monospace" placeholder="80" value="<%- forward_port %>" required>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- SSL -->
+                <div role="tabpanel" class="tab-pane" id="ssl-options">
+                    <div class="row">
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_enabled" value="1"<%- ssl_enabled ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'enable-ssl') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- ssl_enabled ? '' : ' disabled' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'force-ssl') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('all-hosts', 'cert-provider') %></label>
+                                <div class="selectgroup w-100">
+                                    <label class="selectgroup-item">
+                                        <input type="radio" name="ssl_provider" value="letsencrypt" class="selectgroup-input"<%- ssl_provider !== 'other' ? ' checked' : '' %>>
+                                        <span class="selectgroup-button"><%- i18n('ssl', 'letsencrypt') %></span>
+                                    </label>
+                                    <label class="selectgroup-item">
+                                        <input type="radio" name="ssl_provider" value="other" class="selectgroup-input"<%- ssl_provider === 'other' ? ' checked' : '' %>>
+                                        <span class="selectgroup-button"><%- i18n('ssl', 'other') %></span>
+                                    </label>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Lets encrypt -->
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label>
+                                <input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required<%- getLetsencryptAgree() ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span>
+                                </label>
+                            </div>
+                        </div>
+
+                        <!-- Other -->
+                        <div class="col-sm-12 col-md-12 other-ssl">
+                            <div class="form-group">
+                                <div class="form-label"><%- i18n('all-hosts', 'other-certificate') %></div>
+                                <div class="custom-file">
+                                    <input type="file" class="custom-file-input" name="meta[other_ssl_certificate]" id="other_ssl_certificate">
+                                    <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 other-ssl">
+                            <div class="form-group">
+                                <div class="form-label"><%- i18n('all-hosts', 'other-certificate-key') %></div>
+                                <div class="custom-file">
+                                    <input type="file" class="custom-file-input" name="meta[other_ssl_certificate_key]" id="other_ssl_certificate_key">
+                                    <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
+    </div>
+</div>

+ 195 - 0
src/frontend/js/app/nginx/access/form.js

@@ -0,0 +1,195 @@
+'use strict';
+
+const _              = require('underscore');
+const Mn             = require('backbone.marionette');
+const App            = require('../../main');
+const ProxyHostModel = require('../../../models/proxy-host');
+const template       = require('./form.ejs');
+
+require('jquery-serializejson');
+require('jquery-mask-plugin');
+require('selectize');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'modal-dialog',
+    max_file_size: 5120,
+
+    ui: {
+        form:                      'form',
+        domain_names:              'input[name="domain_names"]',
+        forward_ip:                'input[name="forward_ip"]',
+        buttons:                   '.modal-footer button',
+        cancel:                    'button.cancel',
+        save:                      'button.save',
+        ssl_enabled:               'input[name="ssl_enabled"]',
+        ssl_options:               '#ssl-options input',
+        ssl_provider:              'input[name="ssl_provider"]',
+        other_ssl_certificate:     '#other_ssl_certificate',
+        other_ssl_certificate_key: '#other_ssl_certificate_key',
+
+        // SSL hiding and showing
+        all_ssl:         '.letsencrypt-ssl, .other-ssl',
+        letsencrypt_ssl: '.letsencrypt-ssl',
+        other_ssl:       '.other-ssl'
+    },
+
+    events: {
+        'change @ui.ssl_enabled': function () {
+            let enabled = this.ui.ssl_enabled.prop('checked');
+            this.ui.ssl_options.not(this.ui.ssl_enabled).prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5);
+            this.ui.ssl_provider.trigger('change');
+        },
+
+        'change @ui.ssl_provider': function () {
+            let enabled  = this.ui.ssl_enabled.prop('checked');
+            let provider = this.ui.ssl_provider.filter(':checked').val();
+            this.ui.all_ssl.hide().find('input').prop('disabled', true);
+            this.ui[provider + '_ssl'].show().find('input').prop('disabled', !enabled);
+        },
+
+        'click @ui.save': function (e) {
+            e.preventDefault();
+
+            if (!this.ui.form[0].checkValidity()) {
+                $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
+                return;
+            }
+
+            let view = this;
+            let data = this.ui.form.serializeJSON();
+
+            // Manipulate
+            data.forward_port = parseInt(data.forward_port, 10);
+            _.map(data, function (item, idx) {
+                if (typeof item === 'string' && item === '1') {
+                    item = true;
+                } else if (typeof item === 'object' && item !== null) {
+                    _.map(item, function (item2, idx2) {
+                        if (typeof item2 === 'string' && item2 === '1') {
+                            item[idx2] = true;
+                        }
+                    });
+                }
+                data[idx] = item;
+            });
+
+            if (typeof data.domain_names === 'string' && data.domain_names) {
+                data.domain_names = data.domain_names.split(',');
+            }
+
+            let require_ssl_files = typeof data.ssl_enabled !== 'undefined' && data.ssl_enabled && typeof data.ssl_provider !== 'undefined' && data.ssl_provider === 'other';
+            let ssl_files         = [];
+            let method            = App.Api.Nginx.ProxyHosts.create;
+            let is_new            = true;
+
+            let must_require_ssl_files = require_ssl_files && !view.model.hasSslFiles('other');
+
+            if (this.model.get('id')) {
+                // edit
+                is_new  = false;
+                method  = App.Api.Nginx.ProxyHosts.update;
+                data.id = this.model.get('id');
+            }
+
+            // check files are attached
+            if (require_ssl_files) {
+                if (!this.ui.other_ssl_certificate[0].files.length || !this.ui.other_ssl_certificate[0].files[0].size) {
+                    if (must_require_ssl_files) {
+                        alert('certificate file is not attached');
+                        return;
+                    }
+                } else {
+                    if (this.ui.other_ssl_certificate[0].files[0].size > this.max_file_size) {
+                        alert('certificate file is too large (> 5kb)');
+                        return;
+                    }
+                    ssl_files.push({name: 'other_certificate', file: this.ui.other_ssl_certificate[0].files[0]});
+                }
+
+                if (!this.ui.other_ssl_certificate_key[0].files.length || !this.ui.other_ssl_certificate_key[0].files[0].size) {
+                    if (must_require_ssl_files) {
+                        alert('certificate key file is not attached');
+                        return;
+                    }
+                } else {
+                    if (this.ui.other_ssl_certificate_key[0].files[0].size > this.max_file_size) {
+                        alert('certificate key file is too large (> 5kb)');
+                        return;
+                    }
+                    ssl_files.push({name: 'other_certificate_key', file: this.ui.other_ssl_certificate_key[0].files[0]});
+                }
+            }
+
+            this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
+            method(data)
+                .then(result => {
+                    view.model.set(result);
+
+                    // Now upload the certs if we need to
+                    if (ssl_files.length) {
+                        let form_data = new FormData();
+
+                        ssl_files.map(function (file) {
+                            form_data.append(file.name, file.file);
+                        });
+
+                        return App.Api.Nginx.ProxyHosts.setCerts(view.model.get('id'), form_data)
+                            .then(result => {
+                                view.model.set('meta', _.assign({}, view.model.get('meta'), result));
+                            });
+                    }
+                })
+                .then(() => {
+                    App.UI.closeModal(function () {
+                        if (is_new) {
+                            App.Controller.showNginxProxy();
+                        }
+                    });
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    },
+
+    templateContext: {
+        getLetsencryptEmail: function () {
+            return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email');
+        },
+
+        getLetsencryptAgree: function () {
+            return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false;
+        }
+    },
+
+    onRender: function () {
+        this.ui.forward_ip.mask('099.099.099.099', {
+            clearIfNotMatch: true,
+            placeholder:     '000.000.000.000'
+        });
+
+        this.ui.ssl_enabled.trigger('change');
+        this.ui.ssl_provider.trigger('change');
+
+        this.ui.domain_names.selectize({
+            delimiter:    ',',
+            persist:      false,
+            maxOptions:   15,
+            create:       function (input) {
+                return {
+                    value: input,
+                    text:  input
+                };
+            },
+            createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
+        });
+    },
+
+    initialize: function (options) {
+        if (typeof options.model === 'undefined' || !options.model) {
+            this.model = new ProxyHostModel.Model();
+        }
+    }
+});

+ 40 - 0
src/frontend/js/app/nginx/access/list/item.ejs

@@ -0,0 +1,40 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- owner.avatar || '/images/default-avatar.jpg' %>)" title="Owned by <%- owner.name %>">
+        <span class="avatar-status <%- owner.is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div>
+        <% domain_names.map(function(host) {
+        %>
+        <span class="tag"><%- host %></span>
+        <%
+        });
+        %>
+    </div>
+    <div class="small text-muted">
+        <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %>
+    </div>
+</td>
+<td>
+    <div class="text-monospace"><%- forward_ip %>:<%- forward_port %></div>
+</td>
+<td>
+    <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
+</td>
+<td>
+    <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
+</td>
+<% if (canManage) { %>
+<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 dropdown-item"><i class="dropdown-icon fe fe-edit"></i> <%- i18n('str', 'edit') %></a>
+            <a href="#" class="logs dropdown-item"><i class="dropdown-icon fe fe-book"></i> <%- i18n('str', 'logs') %></a>
+            <div class="dropdown-divider"></div>
+            <a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
+        </div>
+    </div>
+</td>
+<% } %>

+ 35 - 0
src/frontend/js/app/nginx/access/list/item.js

@@ -0,0 +1,35 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../../main');
+const template = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:   'a.edit',
+        delete: 'a.delete'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            App.Controller.showNginxProxyForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            App.Controller.showNginxProxyDeleteConfirm(this.model);
+        }
+    },
+
+    templateContext: {
+        canManage: App.Cache.User.canManage('proxy_hosts')
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 13 - 0
src/frontend/js/app/nginx/access/list/main.ejs

@@ -0,0 +1,13 @@
+<thead>
+    <th width="30">&nbsp;</th>
+    <th><%- i18n('str', 'source') %></th>
+    <th><%- i18n('str', 'destination') %></th>
+    <th><%- i18n('str', 'ssl') %></th>
+    <th><%- i18n('str', 'access') %></th>
+    <% if (canManage) { %>
+    <th>&nbsp;</th>
+    <% } %>
+</thead>
+<tbody>
+    <!-- items -->
+</tbody>

+ 34 - 0
src/frontend/js/app/nginx/access/list/main.js

@@ -0,0 +1,34 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../../main');
+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
+        }
+    },
+
+    templateContext: {
+        canManage: App.Cache.User.canManage('access_lists')
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 19 - 1
src/frontend/js/app/nginx/access/main.ejs

@@ -1 +1,19 @@
-access
+<div class="card">
+    <div class="card-status bg-teal"></div>
+    <div class="card-header">
+        <h3 class="card-title"><%- i18n('access-lists', 'title') %></h3>
+        <div class="card-options">
+            <% if (showAddButton) { %>
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item"><%- i18n('access-lists', 'add') %></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>

+ 71 - 3
src/frontend/js/app/nginx/access/main.js

@@ -1,9 +1,77 @@
 'use strict';
 
-const Mn       = require('backbone.marionette');
-const template = require('./main.ejs');
+const Mn              = require('backbone.marionette');
+const App             = require('../../main');
+const AccessListModel = require('../../../models/access-list');
+const ListView        = require('./list/main');
+const ErrorView       = require('../../error/main');
+const EmptyView       = require('../../empty/main');
+const template        = require('./main.ejs');
 
 module.exports = Mn.View.extend({
+    id:       'nginx-access',
     template: template,
-    id:       'nginx-access'
+
+    ui: {
+        list_region: '.list-region',
+        add:         '.add-item',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    events: {
+        'click @ui.add': function (e) {
+            e.preventDefault();
+            App.Controller.showNginxAccessListForm();
+        }
+    },
+
+    templateContext: {
+        showAddButton: App.Cache.User.canManage('access_lists')
+    },
+
+    onRender: function () {
+        let view = this;
+
+        App.Api.Nginx.AccessLists.getAll(['owner'])
+            .then(response => {
+                if (!view.isDestroyed()) {
+                    if (response && response.length) {
+                        view.showChildView('list_region', new ListView({
+                            collection: new AccessListModel.Collection(response)
+                        }));
+                    } else {
+                        let manage = App.Cache.User.canManage('access_lists');
+
+                        view.showChildView('list_region', new EmptyView({
+                            title:      App.i18n('access-lists', 'empty'),
+                            subtitle:   App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
+                            link:       manage ? App.i18n('access-lists', 'add') : null,
+                            btn_color:  'teal',
+                            permission: 'access_lists',
+                            action:     function () {
+                                App.Controller.showNginxAccessListForm();
+                            }
+                        }));
+                    }
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        App.Controller.showNginxAccess();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
 });

+ 1 - 2
src/frontend/js/app/nginx/proxy/delete.ejs

@@ -8,10 +8,9 @@
             <div class="row">
                 <div class="col-sm-12 col-md-12">
                     <%= i18n('proxy-hosts', 'delete-confirm', {domains: domain_names.join(', ')}) %>
-                    Are you sure you want to delete the Proxy host for: <strong><%- domain_names.join(', ') %></strong>?
                     <% if (ssl_enabled) { %>
                         <br><br>
-                        <%- i18n('proxy-hosts', 'delete-ssl') %>
+                        <%- i18n('ssl', 'delete-ssl') %>
                     <% } %>
                 </div>
             </div>

+ 26 - 0
src/frontend/js/app/nginx/proxy/form.ejs

@@ -32,6 +32,32 @@
                                 <input name="forward_port" type="number" class="form-control text-monospace" placeholder="80" value="<%- forward_port %>" required>
                             </div>
                         </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="caching_enabled" value="1"<%- caching_enabled ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'caching-enabled') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="block_exploits" value="1"<%- block_exploits ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'block-exploits') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label">Access List</label>
+                                <select name="access_list_id" class="form-control custom-select">
+                                    <option value="0" selected="selected"><%- i18n('access-lists', 'public') %></option>
+                                </select>
+                            </div>
+                        </div>
                     </div>
                 </div>
 

+ 23 - 0
src/frontend/js/app/nginx/redirection/delete.ejs

@@ -0,0 +1,23 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('redirection-hosts', 'delete') %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body">
+        <form>
+            <div class="row">
+                <div class="col-sm-12 col-md-12">
+                    <%= i18n('redirection-hosts', 'delete-confirm', {domains: domain_names.join(', ')}) %>
+                    <% if (ssl_enabled) { %>
+                        <br><br>
+                        <%- i18n('ssl', 'delete-ssl') %>
+                    <% } %>
+                </div>
+            </div>
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-danger save"><%- i18n('str', 'sure') %></button>
+    </div>
+</div>

+ 36 - 0
src/frontend/js/app/nginx/redirection/delete.js

@@ -0,0 +1,36 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../main');
+const template = require('./delete.ejs');
+
+require('jquery-serializejson');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'modal-dialog',
+
+    ui: {
+        form:    'form',
+        buttons: '.modal-footer button',
+        cancel:  'button.cancel',
+        save:    'button.save'
+    },
+
+    events: {
+
+        'click @ui.save': function (e) {
+            e.preventDefault();
+
+            App.Api.Nginx.RedirectionHosts.delete(this.model.get('id'))
+                .then(() => {
+                    App.Controller.showNginxRedirection();
+                    App.UI.closeModal();
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    }
+});

+ 134 - 0
src/frontend/js/app/nginx/redirection/form.ejs

@@ -0,0 +1,134 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('redirection-hosts', 'form-title', {id: id}) %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body has-tabs">
+        <form>
+            <ul class="nav nav-tabs" role="tablist">
+                <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> <%- i18n('all-hosts', 'details') %></a></li>
+                <li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> <%- i18n('str', 'ssl') %></a></li>
+            </ul>
+            <div class="tab-content">
+                <!-- Details -->
+                <div role="tabpanel" class="tab-pane active" id="details">
+                    <div class="row">
+
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
+                                <input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('redirection-hosts', 'forward-domain') %><span class="form-required">*</span></label>
+                                <input type="text" name="forward_domain_name" class="form-control text-monospace" placeholder="" value="<%- forward_domain_name %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="preserve_path" value="1"<%- preserve_path ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('redirection-hosts', 'preserve-path') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="block_exploits" value="1"<%- block_exploits ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'block-exploits') %></span>
+                                </label>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- SSL -->
+                <div role="tabpanel" class="tab-pane" id="ssl-options">
+                    <div class="row">
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_enabled" value="1"<%- ssl_enabled ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'enable-ssl') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- ssl_enabled ? '' : ' disabled' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'force-ssl') %></span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('all-hosts', 'cert-provider') %></label>
+                                <div class="selectgroup w-100">
+                                    <label class="selectgroup-item">
+                                        <input type="radio" name="ssl_provider" value="letsencrypt" class="selectgroup-input"<%- ssl_provider !== 'other' ? ' checked' : '' %>>
+                                        <span class="selectgroup-button"><%- i18n('ssl', 'letsencrypt') %></span>
+                                    </label>
+                                    <label class="selectgroup-item">
+                                        <input type="radio" name="ssl_provider" value="other" class="selectgroup-input"<%- ssl_provider === 'other' ? ' checked' : '' %>>
+                                        <span class="selectgroup-button"><%- i18n('ssl', 'other') %></span>
+                                    </label>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Lets encrypt -->
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label>
+                                <input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required<%- getLetsencryptAgree() ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span>
+                                </label>
+                            </div>
+                        </div>
+
+                        <!-- Other -->
+                        <div class="col-sm-12 col-md-12 other-ssl">
+                            <div class="form-group">
+                                <div class="form-label"><%- i18n('all-hosts', 'other-certificate') %></div>
+                                <div class="custom-file">
+                                    <input type="file" class="custom-file-input" name="meta[other_ssl_certificate]" id="other_ssl_certificate">
+                                    <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 other-ssl">
+                            <div class="form-group">
+                                <div class="form-label"><%- i18n('all-hosts', 'other-certificate-key') %></div>
+                                <div class="custom-file">
+                                    <input type="file" class="custom-file-input" name="meta[other_ssl_certificate_key]" id="other_ssl_certificate_key">
+                                    <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
+    </div>
+</div>

+ 187 - 0
src/frontend/js/app/nginx/redirection/form.js

@@ -0,0 +1,187 @@
+'use strict';
+
+const _                    = require('underscore');
+const Mn                   = require('backbone.marionette');
+const App                  = require('../../main');
+const RedirectionHostModel = require('../../../models/redirection-host');
+const template             = require('./form.ejs');
+
+require('jquery-serializejson');
+require('selectize');
+
+module.exports = Mn.View.extend({
+    template:      template,
+    className:     'modal-dialog',
+    max_file_size: 5120,
+
+    ui: {
+        form:                      'form',
+        domain_names:              'input[name="domain_names"]',
+        buttons:                   '.modal-footer button',
+        cancel:                    'button.cancel',
+        save:                      'button.save',
+        ssl_enabled:               'input[name="ssl_enabled"]',
+        ssl_options:               '#ssl-options input',
+        ssl_provider:              'input[name="ssl_provider"]',
+        other_ssl_certificate:     '#other_ssl_certificate',
+        other_ssl_certificate_key: '#other_ssl_certificate_key',
+
+        // SSL hiding and showing
+        all_ssl:         '.letsencrypt-ssl, .other-ssl',
+        letsencrypt_ssl: '.letsencrypt-ssl',
+        other_ssl:       '.other-ssl'
+    },
+
+    events: {
+        'change @ui.ssl_enabled': function () {
+            let enabled = this.ui.ssl_enabled.prop('checked');
+            this.ui.ssl_options.not(this.ui.ssl_enabled).prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5);
+            this.ui.ssl_provider.trigger('change');
+        },
+
+        'change @ui.ssl_provider': function () {
+            let enabled  = this.ui.ssl_enabled.prop('checked');
+            let provider = this.ui.ssl_provider.filter(':checked').val();
+            this.ui.all_ssl.hide().find('input').prop('disabled', true);
+            this.ui[provider + '_ssl'].show().find('input').prop('disabled', !enabled);
+        },
+
+        'click @ui.save': function (e) {
+            e.preventDefault();
+
+            if (!this.ui.form[0].checkValidity()) {
+                $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
+                return;
+            }
+
+            let view = this;
+            let data = this.ui.form.serializeJSON();
+
+            // Manipulate
+            _.map(data, function (item, idx) {
+                if (typeof item === 'string' && item === '1') {
+                    item = true;
+                } else if (typeof item === 'object' && item !== null) {
+                    _.map(item, function (item2, idx2) {
+                        if (typeof item2 === 'string' && item2 === '1') {
+                            item[idx2] = true;
+                        }
+                    });
+                }
+                data[idx] = item;
+            });
+
+            if (typeof data.domain_names === 'string' && data.domain_names) {
+                data.domain_names = data.domain_names.split(',');
+            }
+
+            let require_ssl_files = typeof data.ssl_enabled !== 'undefined' && data.ssl_enabled && typeof data.ssl_provider !== 'undefined' && data.ssl_provider === 'other';
+            let ssl_files         = [];
+            let method            = App.Api.Nginx.RedirectionHosts.create;
+            let is_new            = true;
+
+            let must_require_ssl_files = require_ssl_files && !view.model.hasSslFiles('other');
+
+            if (this.model.get('id')) {
+                // edit
+                is_new  = false;
+                method  = App.Api.Nginx.RedirectionHosts.update;
+                data.id = this.model.get('id');
+            }
+
+            // check files are attached
+            if (require_ssl_files) {
+                if (!this.ui.other_ssl_certificate[0].files.length || !this.ui.other_ssl_certificate[0].files[0].size) {
+                    if (must_require_ssl_files) {
+                        alert('certificate file is not attached');
+                        return;
+                    }
+                } else {
+                    if (this.ui.other_ssl_certificate[0].files[0].size > this.max_file_size) {
+                        alert('certificate file is too large (> 5kb)');
+                        return;
+                    }
+                    ssl_files.push({name: 'other_certificate', file: this.ui.other_ssl_certificate[0].files[0]});
+                }
+
+                if (!this.ui.other_ssl_certificate_key[0].files.length || !this.ui.other_ssl_certificate_key[0].files[0].size) {
+                    if (must_require_ssl_files) {
+                        alert('certificate key file is not attached');
+                        return;
+                    }
+                } else {
+                    if (this.ui.other_ssl_certificate_key[0].files[0].size > this.max_file_size) {
+                        alert('certificate key file is too large (> 5kb)');
+                        return;
+                    }
+                    ssl_files.push({name: 'other_certificate_key', file: this.ui.other_ssl_certificate_key[0].files[0]});
+                }
+            }
+
+            this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
+            method(data)
+                .then(result => {
+                    view.model.set(result);
+
+                    // Now upload the certs if we need to
+                    if (ssl_files.length) {
+                        let form_data = new FormData();
+
+                        ssl_files.map(function (file) {
+                            form_data.append(file.name, file.file);
+                        });
+
+                        return App.Api.Nginx.RedirectionHosts.setCerts(view.model.get('id'), form_data)
+                            .then(result => {
+                                view.model.set('meta', _.assign({}, view.model.get('meta'), result));
+                            });
+                    }
+                })
+                .then(() => {
+                    App.UI.closeModal(function () {
+                        if (is_new) {
+                            App.Controller.showNginxRedirection();
+                        }
+                    });
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    },
+
+    templateContext: {
+        getLetsencryptEmail: function () {
+            return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email');
+        },
+
+        getLetsencryptAgree: function () {
+            return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false;
+        }
+    },
+
+    onRender: function () {
+        this.ui.ssl_enabled.trigger('change');
+        this.ui.ssl_provider.trigger('change');
+
+        this.ui.domain_names.selectize({
+            delimiter:    ',',
+            persist:      false,
+            maxOptions:   15,
+            create:       function (input) {
+                return {
+                    value: input,
+                    text:  input
+                };
+            },
+            createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
+        });
+    },
+
+    initialize: function (options) {
+        if (typeof options.model === 'undefined' || !options.model) {
+            this.model = new RedirectionHostModel.Model();
+        }
+    }
+});

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

@@ -1,17 +1,15 @@
 'use strict';
 
 const Mn                   = require('backbone.marionette');
+const App                  = require('../../main');
 const RedirectionHostModel = require('../../../models/redirection-host');
-const Api                  = require('../../api');
-const Cache                = require('../../cache');
-const Controller           = require('../../controller');
 const ListView             = require('./list/main');
 const ErrorView            = require('../../error/main');
-const template             = require('./main.ejs');
 const EmptyView            = require('../../empty/main');
+const template             = require('./main.ejs');
 
 module.exports = Mn.View.extend({
-    id:       'nginx-redirections',
+    id:       'nginx-redirection',
     template: template,
 
     ui: {
@@ -27,34 +25,35 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.add': function (e) {
             e.preventDefault();
-            Controller.showNginxRedirectionForm();
+            App.Controller.showNginxProxyForm();
         }
     },
 
     templateContext: {
-        showAddButton: Cache.User.canManage('redirection_hosts')
+        showAddButton: App.Cache.User.canManage('proxy_hosts')
     },
 
     onRender: function () {
         let view = this;
 
-        Api.Nginx.RedirectionHosts.getAll()
+        App.Api.Nginx.ProxyHosts.getAll(['owner', 'access_list'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {
                         view.showChildView('list_region', new ListView({
-                            collection: new RedirectionHostModel.Collection(response)
+                            collection: new ProxyHostModel.Collection(response)
                         }));
                     } else {
-                        let manage = Cache.User.canManage('redirection_hosts');
+                        let manage = App.Cache.User.canManage('proxy_hosts');
 
                         view.showChildView('list_region', new EmptyView({
-                            title:     'There are no Redirection Hosts',
-                            subtitle:  manage ? 'Why don\'t you create one?' : 'And you don\'t have permission to create one.',
-                            link:      manage ? 'Add Redirection Host' : null,
-                            btn_color: 'yellow',
-                            action:    function () {
-                                Controller.showNginxRedirectionForm();
+                            title:      App.i18n('proxy-hosts', 'empty'),
+                            subtitle:   App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
+                            link:       manage ? App.i18n('proxy-hosts', 'add') : null,
+                            btn_color:  'success',
+                            permission: 'proxy_hosts',
+                            action:     function () {
+                                App.Controller.showNginxProxyForm();
                             }
                         }));
                     }
@@ -65,7 +64,7 @@ module.exports = Mn.View.extend({
                     code:    err.code,
                     message: err.message,
                     retry:   function () {
-                        Controller.showNginxRedirection();
+                        App.Controller.showNginxProxy();
                     }
                 }));
 

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

@@ -1,4 +1,5 @@
 <div class="card">
+    <div class="card-status bg-teal"></div>
     <div class="card-header">
         <h3 class="card-title"><%- i18n('users', 'title') %></h3>
         <div class="card-options">

+ 18 - 6
src/frontend/js/i18n/messages.json

@@ -61,14 +61,17 @@
       "domain-names": "Domain Names",
       "cert-provider": "Certificate Provider",
       "other-certificate": "Certificate",
-      "other-certificate-key": "Certificate Key"
+      "other-certificate-key": "Certificate Key",
+      "block-exploits": "Block Common Exploits",
+      "caching-enabled": "Cache Assets"
     },
     "ssl": {
       "letsencrypt": "Let's Encrypt",
       "other": "Other",
       "none": "HTTP only",
       "letsencrypt-email": "Email Address for Let's Encrypt",
-      "letsencrypt-agree": "I Agree to the <a href=\"{url}\" target=\"_blank\">Let's Encrypt Terms of Service</a>"
+      "letsencrypt-agree": "I Agree to the <a href=\"{url}\" target=\"_blank\">Let's Encrypt Terms of Service</a>",
+      "delete-ssl": "The SSL certificates attached will be removed, this action cannot be recovered."
     },
     "proxy-hosts": {
       "title": "Proxy Hosts",
@@ -78,11 +81,15 @@
       "forward-ip": "Forward IP",
       "forward-port": "Forward Port",
       "delete": "Delete Proxy Host",
-      "delete-confirm": "Are you sure you want to delete the Proxy host for: <strong>{domains}</strong>?",
-      "delete-ssl": "The SSL certificates attached will be removed, this action cannot be recovered."
+      "delete-confirm": "Are you sure you want to delete the Proxy host for: <strong>{domains}</strong>?"
     },
     "redirection-hosts": {
-      "title": "Redirection Hosts"
+      "title": "Redirection Hosts",
+      "form-title": "{id, select, undefined{New} other{Edit}} Redirection Host",
+      "forward-domain": "Forward Domain",
+      "preserve-path": "Preserve Path",
+      "delete": "Delete Proxy Host",
+      "delete-confirm": "Are you sure you want to delete the Redirection host for: <strong>{domains}</strong>?"
     },
     "dead-hosts": {
       "title": "404 Hosts"
@@ -91,7 +98,12 @@
       "title": "Streams"
     },
     "access-lists": {
-      "title": "Access Lists"
+      "title": "Access Lists",
+      "empty": "There are no Access Lists",
+      "add": "Add Access List",
+      "delete": "Delete Access List",
+      "delete-confirm": "Are you sure you want to delete this access list? Any hosts using it will need to be updated later.",
+      "public": "Publicly Accessible"
     },
     "users": {
       "title": "Users",

+ 24 - 0
src/frontend/js/models/access-list.js

@@ -0,0 +1,24 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            id:              0,
+            created_on:      null,
+            modified_on:     null,
+            // The following are expansions:
+            owner:           null
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 1 - 1
src/frontend/js/models/proxy-host.js

@@ -13,7 +13,7 @@ const model = Backbone.Model.extend({
             domain_names:    [],
             forward_ip:      '',
             forward_port:    null,
-            access_list_id:  null,
+            access_list_id:  0,
             ssl_enabled:     false,
             ssl_provider:    false,
             ssl_forced:      false,

+ 6 - 4
src/frontend/js/models/redirection-host.js

@@ -10,14 +10,16 @@ const model = Backbone.Model.extend({
             id:                  0,
             created_on:          null,
             modified_on:         null,
-            owner:               null,
-            domain_name:         '',
+            domain_names:        [],
             forward_domain_name: '',
-            preserve_path:       false,
+            preserve_path:       true,
             ssl_enabled:         false,
             ssl_provider:        false,
+            ssl_forced:          false,
             block_exploits:      false,
-            meta:                []
+            meta:                {},
+            // The following are expansions:
+            owner:               null
         };
     }
 });