Przeglądaj źródła

404 hosts section and other fixes

Jamie Curnow 7 lat temu
rodzic
commit
5107d1529f
41 zmienionych plików z 1264 dodań i 383 usunięć
  1. 202 0
      src/backend/internal/dead-host.js
  2. 0 4
      src/backend/internal/proxy-host.js
  3. 0 4
      src/backend/internal/redirection-host.js
  4. 132 1
      src/backend/internal/stream.js
  5. 23 0
      src/backend/lib/access/dead_hosts-create.json
  6. 23 0
      src/backend/lib/access/dead_hosts-delete.json
  7. 23 0
      src/backend/lib/access/dead_hosts-get.json
  8. 23 0
      src/backend/lib/access/dead_hosts-update.json
  9. 23 0
      src/backend/lib/access/streams-create.json
  10. 23 0
      src/backend/lib/access/streams-delete.json
  11. 23 0
      src/backend/lib/access/streams-get.json
  12. 23 0
      src/backend/lib/access/streams-update.json
  13. 2 0
      src/backend/migrations/20180618015850_initial.js
  14. 37 3
      src/backend/routes/api/nginx/dead_hosts.js
  15. 5 5
      src/backend/routes/api/nginx/streams.js
  16. 84 107
      src/backend/schema/endpoints/dead-hosts.json
  17. 2 0
      src/backend/schema/endpoints/proxy-hosts.json
  18. 2 0
      src/backend/schema/endpoints/redirection-hosts.json
  19. 86 100
      src/backend/schema/endpoints/streams.json
  20. 41 2
      src/frontend/js/app/controller.js
  21. 23 0
      src/frontend/js/app/nginx/dead/delete.ejs
  22. 36 0
      src/frontend/js/app/nginx/dead/delete.js
  23. 110 0
      src/frontend/js/app/nginx/dead/form.ejs
  24. 181 0
      src/frontend/js/app/nginx/dead/form.js
  25. 17 15
      src/frontend/js/app/nginx/dead/list/item.ejs
  26. 8 45
      src/frontend/js/app/nginx/dead/list/item.js
  27. 4 3
      src/frontend/js/app/nginx/dead/list/main.ejs
  28. 8 3
      src/frontend/js/app/nginx/dead/list/main.js
  29. 2 2
      src/frontend/js/app/nginx/dead/main.ejs
  30. 14 15
      src/frontend/js/app/nginx/dead/main.js
  31. 11 15
      src/frontend/js/app/nginx/proxy/form.js
  32. 8 12
      src/frontend/js/app/nginx/redirection/form.js
  33. 6 3
      src/frontend/js/app/nginx/stream/form.ejs
  34. 17 16
      src/frontend/js/app/nginx/stream/form.js
  35. 11 13
      src/frontend/js/app/nginx/stream/list/item.ejs
  36. 3 3
      src/frontend/js/app/nginx/stream/list/item.js
  37. 2 3
      src/frontend/js/app/nginx/stream/list/main.ejs
  38. 1 1
      src/frontend/js/app/nginx/stream/list/main.js
  39. 17 2
      src/frontend/js/i18n/messages.json
  40. 5 3
      src/frontend/js/models/dead-host.js
  41. 3 3
      src/frontend/js/models/stream.js

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

@@ -3,6 +3,7 @@
 const _             = require('lodash');
 const error         = require('../lib/error');
 const deadHostModel = require('../models/dead_host');
+const internalHost  = require('./host');
 
 function omissions () {
     return ['is_deleted'];
@@ -10,6 +11,199 @@ function omissions () {
 
 const internalDeadHost = {
 
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        return access.can('dead_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 deadHostModel
+                    .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('dead_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, 'dead', 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 internalDeadHost.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (row.id !== data.id) {
+                    // Sanity check that something crazy hasn't happened
+                    throw new error.InternalValidationError('404 Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
+                }
+
+                return deadHostModel
+                    .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 = {};
+        }
+
+        return access.can('dead_hosts:get', data.id)
+            .then(access_data => {
+                let query = deadHostModel
+                    .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('dead_hosts:delete', data.id)
+            .then(() => {
+                return internalDeadHost.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (!row) {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+
+                return deadHostModel
+                    .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 internalDeadHost.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 internalDeadHost.update(access, {
+                    id:   data.id,
+                    meta: row.meta
+                });
+            })
+            .then(row => {
+                return _.pick(row.meta, internalHost.allowed_ssl_files);
+            });
+    },
+
     /**
      * All Hosts
      *
@@ -26,6 +220,7 @@ const internalDeadHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
+                    .allowEager('[owner]')
                     .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
@@ -44,6 +239,13 @@ const internalDeadHost = {
                 }
 
                 return query;
+            })
+            .then(rows => {
+                rows.map(row => {
+                    row.meta = internalHost.cleanMeta(row.meta);
+                });
+
+                return rows;
             });
     },
 

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

@@ -115,10 +115,6 @@ const internalProxyHost = {
             data = {};
         }
 
-        if (typeof data.id === 'undefined' || !data.id) {
-            data.id = access.token.get('attrs').id;
-        }
-
         return access.can('proxy_hosts:get', data.id)
             .then(access_data => {
                 let query = proxyHostModel

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

@@ -115,10 +115,6 @@ const internalRedirectionHost = {
             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

+ 132 - 1
src/backend/internal/stream.js

@@ -11,7 +11,137 @@ function omissions () {
 const internalStream = {
 
     /**
-     * All Hosts
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: (access, data) => {
+        return access.can('streams:create', data)
+            .then(access_data => {
+                // TODO: At this point the existing ports should have been checked
+                data.owner_user_id = access.token.get('attrs').id;
+
+                if (typeof data.meta === 'undefined') {
+                    data.meta = {};
+                }
+
+                return streamModel
+                    .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('streams:update', data.id)
+            .then(access_data => {
+                // TODO: at this point the existing streams should have been checked
+                return internalStream.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (row.id !== data.id) {
+                    // Sanity check that something crazy hasn't happened
+                    throw new error.InternalValidationError('Stream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
+                }
+
+                return streamModel
+                    .query()
+                    .omit(omissions())
+                    .patchAndFetchById(row.id, data)
+                    .then(saved_row => {
+                        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 = {};
+        }
+
+        return access.can('streams:get', data.id)
+            .then(access_data => {
+                let query = streamModel
+                    .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('streams:delete', data.id)
+            .then(() => {
+                return internalStream.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (!row) {
+                    throw new error.ItemNotFoundError(data.id);
+                }
+
+                return streamModel
+                    .query()
+                    .where('id', row.id)
+                    .patch({
+                        is_deleted: 1
+                    });
+            })
+            .then(() => {
+                return true;
+            });
+    },
+
+    /**
+     * All Streams
      *
      * @param   {Access}  access
      * @param   {Array}   [expand]
@@ -26,6 +156,7 @@ const internalStream = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
+                    .allowEager('[owner]')
                     .orderBy('incoming_port', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -93,6 +93,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.integer('preserve_path').notNull().unsigned().defaultTo(0);
                 table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
                 table.string('ssl_provider').notNull().defaultTo('');
+                table.integer('ssl_forced').notNull().unsigned().defaultTo(0);
                 table.integer('block_exploits').notNull().unsigned().defaultTo(0);
                 table.json('meta').notNull();
             });
@@ -109,6 +110,7 @@ exports.up = function (knex/*, Promise*/) {
                     table.json('domain_names').notNull();
                     table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
                     table.string('ssl_provider').notNull().defaultTo('');
+                    table.integer('ssl_forced').notNull().unsigned().defaultTo(0);
                     table.json('meta').notNull();
                 });
         })

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

@@ -104,7 +104,7 @@ router
         })
             .then(data => {
                 return internalDeadHost.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/dead-hosts#/links/2/schema'}, req.body)
             .then(payload => {
-                payload.id = req.params.host_id;
+                payload.id = parseInt(req.params.host_id, 10);
                 return internalDeadHost.update(res.locals.access, payload);
             })
             .then(result => {
@@ -139,7 +139,7 @@ router
      * Update and existing dead-host
      */
     .delete((req, res, next) => {
-        internalDeadHost.delete(res.locals.access, {id: req.params.host_id})
+        internalDeadHost.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 dead-host Certificates
+ *
+ * /api/nginx/dead-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/dead-hosts/123/certificates
+     *
+     * Upload certifications
+     */
+    .post((req, res, next) => {
+        if (!req.files) {
+            res.status(400)
+                .send({error: 'No files were uploaded'});
+        } else {
+            internalDeadHost.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;

+ 5 - 5
src/backend/routes/api/nginx/streams.js

@@ -94,17 +94,17 @@ router
                 stream_id: {
                     $ref: 'definitions#/definitions/id'
                 },
-                expand:  {
+                expand:    {
                     $ref: 'definitions#/definitions/expand'
                 }
             }
         }, {
             stream_id: req.params.stream_id,
-            expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
+            expand:    (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
         })
             .then(data => {
                 return internalStream.get(res.locals.access, {
-                    id:     data.stream_id,
+                    id:     parseInt(data.stream_id, 10),
                     expand: data.expand
                 });
             })
@@ -123,7 +123,7 @@ router
     .put((req, res, next) => {
         apiValidator({$ref: 'endpoints/streams#/links/2/schema'}, req.body)
             .then(payload => {
-                payload.id = req.params.stream_id;
+                payload.id = parseInt(req.params.stream_id, 10);
                 return internalStream.update(res.locals.access, payload);
             })
             .then(result => {
@@ -139,7 +139,7 @@ router
      * Update and existing stream
      */
     .delete((req, res, next) => {
-        internalStream.delete(res.locals.access, {id: req.params.stream_id})
+        internalStream.delete(res.locals.access, {id: parseInt(req.params.stream_id, 10)})
             .then(result => {
                 res.status(200)
                     .send(result);

+ 84 - 107
src/backend/schema/endpoints/dead-hosts.json

@@ -1,8 +1,8 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
   "$id": "endpoints/dead-hosts",
-  "title": "Users",
-  "description": "Endpoints relating to Dead Hosts",
+  "title": "404 Hosts",
+  "description": "Endpoints relating to 404 Hosts",
   "stability": "stable",
   "type": "object",
   "definitions": {
@@ -15,49 +15,63 @@
     "modified_on": {
       "$ref": "../definitions.json#/definitions/modified_on"
     },
-    "name": {
-      "description": "Name",
-      "example": "Jamie Curnow",
-      "type": "string",
-      "minLength": 2,
-      "maxLength": 100
-    },
-    "nickname": {
-      "description": "Nickname",
-      "example": "Jamie",
-      "type": "string",
-      "minLength": 2,
-      "maxLength": 50
-    },
-    "email": {
-      "$ref": "../definitions.json#/definitions/email"
-    },
-    "avatar": {
-      "description": "Avatar",
-      "example": "http://somewhere.jpg",
-      "type": "string",
-      "minLength": 2,
-      "maxLength": 150,
-      "readOnly": true
-    },
-    "roles": {
-      "description": "Roles",
-      "example": [
-        "admin"
-      ],
-      "type": "array"
-    },
-    "is_disabled": {
-      "description": "Is Disabled",
-      "example": false,
-      "type": "boolean"
+    "domain_names": {
+      "$ref": "../definitions.json#/definitions/domain_names"
+    },
+    "ssl_enabled": {
+      "$ref": "../definitions.json#/definitions/ssl_enabled"
+    },
+    "ssl_forced": {
+      "$ref": "../definitions.json#/definitions/ssl_forced"
+    },
+    "ssl_provider": {
+      "$ref": "../definitions.json#/definitions/ssl_provider"
+    },
+    "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"
+    },
+    "ssl_enabled": {
+      "$ref": "#/definitions/ssl_enabled"
+    },
+    "ssl_forced": {
+      "$ref": "#/definitions/ssl_forced"
+    },
+    "ssl_provider": {
+      "$ref": "#/definitions/ssl_provider"
+    },
+    "meta": {
+      "$ref": "#/definitions/meta"
     }
   },
   "links": [
     {
       "title": "List",
-      "description": "Returns a list of Users",
-      "href": "/users",
+      "description": "Returns a list of 404 Hosts",
+      "href": "/nginx/dead-hosts",
       "access": "private",
       "method": "GET",
       "rel": "self",
@@ -73,8 +87,8 @@
     },
     {
       "title": "Create",
-      "description": "Creates a new User",
-      "href": "/users",
+      "description": "Creates a new 404 Host",
+      "href": "/nginx/dead-hosts",
       "access": "private",
       "method": "POST",
       "rel": "create",
@@ -83,34 +97,25 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "required": [
-          "name",
-          "nickname",
-          "email"
+          "domain_names"
         ],
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
-          },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
           },
-          "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"
           },
-          "auth": {
-            "type": "object",
-            "description": "Auth Credentials",
-            "example": {
-              "type": "password",
-              "secret": "bigredhorsebanana"
-            }
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -122,8 +127,8 @@
     },
     {
       "title": "Update",
-      "description": "Updates a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Updates a existing 404 Host",
+      "href": "/nginx/dead-hosts/{definitions.identity.example}",
       "access": "private",
       "method": "PUT",
       "rel": "update",
@@ -132,21 +137,22 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "ssl_enabled": {
+            "$ref": "#/definitions/ssl_enabled"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "ssl_forced": {
+            "$ref": "#/definitions/ssl_forced"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "ssl_provider": {
+            "$ref": "#/definitions/ssl_provider"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -158,8 +164,8 @@
     },
     {
       "title": "Delete",
-      "description": "Deletes a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Deletes a existing 404 Host",
+      "href": "/nginx/dead-hosts/{definitions.identity.example}",
       "access": "private",
       "method": "DELETE",
       "rel": "delete",
@@ -170,34 +176,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"
-    }
-  }
+  ]
 }

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

@@ -130,6 +130,7 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "required": [
           "domain_names",
           "forward_ip",
@@ -186,6 +187,7 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "properties": {
           "domain_names": {
             "$ref": "#/definitions/domain_names"

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

@@ -117,6 +117,7 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "required": [
           "domain_names",
           "forward_domain_name"
@@ -166,6 +167,7 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "properties": {
           "domain_names": {
             "$ref": "#/definitions/domain_names"

+ 86 - 100
src/backend/schema/endpoints/streams.json

@@ -1,7 +1,7 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
   "$id": "endpoints/streams",
-  "title": "Users",
+  "title": "Streams",
   "description": "Endpoints relating to Streams",
   "stability": "stable",
   "type": "object",
@@ -15,49 +15,64 @@
     "modified_on": {
       "$ref": "../definitions.json#/definitions/modified_on"
     },
-    "name": {
-      "description": "Name",
-      "example": "Jamie Curnow",
-      "type": "string",
-      "minLength": 2,
-      "maxLength": 100
+    "incoming_port": {
+      "type": "integer",
+      "minimum": 1,
+      "maximum": 65535
     },
-    "nickname": {
-      "description": "Nickname",
-      "example": "Jamie",
+    "forward_ip": {
       "type": "string",
-      "minLength": 2,
-      "maxLength": 50
+      "format": "ipv4"
     },
-    "email": {
-      "$ref": "../definitions.json#/definitions/email"
+    "forwarding_port": {
+      "type": "integer",
+      "minimum": 1,
+      "maximum": 65535
     },
-    "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,
+    "tcp_forwarding": {
       "type": "boolean"
+    },
+    "udp_forwarding": {
+      "type": "boolean"
+    },
+    "meta": {
+      "type": "object"
+    }
+  },
+  "properties": {
+    "id": {
+      "$ref": "#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "#/definitions/modified_on"
+    },
+    "incoming_port": {
+      "$ref": "#/definitions/incoming_port"
+    },
+    "forward_ip": {
+      "$ref": "#/definitions/forward_ip"
+    },
+    "forwarding_port": {
+      "$ref": "#/definitions/forwarding_port"
+    },
+    "tcp_forwarding": {
+      "$ref": "#/definitions/tcp_forwarding"
+    },
+    "udp_forwarding": {
+      "$ref": "#/definitions/udp_forwarding"
+    },
+    "meta": {
+      "$ref": "#/definitions/meta"
     }
   },
   "links": [
     {
       "title": "List",
-      "description": "Returns a list of Users",
-      "href": "/users",
+      "description": "Returns a list of Steams",
+      "href": "/nginx/streams",
       "access": "private",
       "method": "GET",
       "rel": "self",
@@ -73,8 +88,8 @@
     },
     {
       "title": "Create",
-      "description": "Creates a new User",
-      "href": "/users",
+      "description": "Creates a new Stream",
+      "href": "/nginx/streams",
       "access": "private",
       "method": "POST",
       "rel": "create",
@@ -83,34 +98,30 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "required": [
-          "name",
-          "nickname",
-          "email"
+          "incoming_port",
+          "forward_ip",
+          "forwarding_port"
         ],
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "incoming_port": {
+            "$ref": "#/definitions/incoming_port"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "forward_ip": {
+            "$ref": "#/definitions/forward_ip"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "forwarding_port": {
+            "$ref": "#/definitions/forwarding_port"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "tcp_forwarding": {
+            "$ref": "#/definitions/tcp_forwarding"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "udp_forwarding": {
+            "$ref": "#/definitions/udp_forwarding"
           },
-          "auth": {
-            "type": "object",
-            "description": "Auth Credentials",
-            "example": {
-              "type": "password",
-              "secret": "bigredhorsebanana"
-            }
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -122,8 +133,8 @@
     },
     {
       "title": "Update",
-      "description": "Updates a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Updates a existing Stream",
+      "href": "/nginx/streams/{definitions.identity.example}",
       "access": "private",
       "method": "PUT",
       "rel": "update",
@@ -132,21 +143,25 @@
       },
       "schema": {
         "type": "object",
+        "additionalProperties": false,
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "incoming_port": {
+            "$ref": "#/definitions/incoming_port"
+          },
+          "forward_ip": {
+            "$ref": "#/definitions/forward_ip"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "forwarding_port": {
+            "$ref": "#/definitions/forwarding_port"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "tcp_forwarding": {
+            "$ref": "#/definitions/tcp_forwarding"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "udp_forwarding": {
+            "$ref": "#/definitions/udp_forwarding"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -158,8 +173,8 @@
     },
     {
       "title": "Delete",
-      "description": "Deletes a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Deletes a existing Stream",
+      "href": "/nginx/streams/{definitions.identity.example}",
       "access": "private",
       "method": "DELETE",
       "rel": "delete",
@@ -170,34 +185,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"
-    }
-  }
+  ]
 }

+ 41 - 2
src/frontend/js/app/controller.js

@@ -215,18 +215,31 @@ module.exports = {
     },
 
     /**
-     * Nginx Stream Form
+     * Stream Form
      *
      * @param [model]
      */
     showNginxStreamForm: function (model) {
-        if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('streams')) {
             require(['./main', './nginx/stream/form'], function (App, View) {
                 App.UI.showModalDialog(new View({model: model}));
             });
         }
     },
 
+    /**
+     * Stream Delete Confirm
+     *
+     * @param model
+     */
+    showNginxStreamDeleteConfirm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('streams')) {
+            require(['./main', './nginx/stream/delete'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Nginx Dead Hosts
      */
@@ -241,6 +254,32 @@ module.exports = {
         }
     },
 
+    /**
+     * Dead Host Form
+     *
+     * @param [model]
+     */
+    showNginxDeadForm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) {
+            require(['./main', './nginx/dead/form'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
+    /**
+     * Dead Host Delete Confirm
+     *
+     * @param model
+     */
+    showNginxDeadDeleteConfirm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('dead_hosts')) {
+            require(['./main', './nginx/dead/delete'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Nginx Access
      */

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

@@ -0,0 +1,23 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('dead-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('dead-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/dead/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.DeadHosts.delete(this.model.get('id'))
+                .then(() => {
+                    App.Controller.showNginxDead();
+                    App.UI.closeModal();
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    }
+});

+ 110 - 0
src/frontend/js/app/nginx/dead/form.ejs

@@ -0,0 +1,110 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('dead-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>
+                </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>

+ 181 - 0
src/frontend/js/app/nginx/dead/form.js

@@ -0,0 +1,181 @@
+'use strict';
+
+const _             = require('underscore');
+const Mn            = require('backbone.marionette');
+const App           = require('../../main');
+const DeadHostModel = require('../../../models/dead-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
+            data.ssl_enabled = !!data.ssl_enabled;
+            data.ssl_forced  = !!data.ssl_forced;
+
+            if (typeof data.meta !== 'undefined' && typeof data.meta.letsencrypt_agree !== 'undefined') {
+                data.meta.letsencrypt_agree = !!data.meta.letsencrypt_agree;
+            }
+
+            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.DeadHosts.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.DeadHosts.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.DeadHosts.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.showNginxDead();
+                        }
+                    });
+                })
+                .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 DeadHostModel.Model();
+        }
+    }
+});

+ 17 - 15
src/frontend/js/app/nginx/dead/list/item.ejs

@@ -1,32 +1,34 @@
 <td class="text-center">
-    <div class="avatar d-block" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)">
-        <span class="avatar-status <%- is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    <div 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><%- name %></div>
+    <div>
+        <% domain_names.map(function(host) {
+            %>
+            <span class="tag"><%- host %></span>
+            <%
+        });
+        %>
+    </div>
     <div class="small text-muted">
-        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+        <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %>
     </div>
 </td>
 <td>
-    <div><%- email %></div>
-</td>
-<td>
-    <div><%- roles.join(', ') %></div>
+    <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></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-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> Edit Details</a>
-            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> Edit Permissions</a>
-            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> Set Password</a>
-            <% if (!isSelf()) { %>
-            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <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-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> Delete User</a>
-            <% } %>
+            <a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
         </div>
     </div>
 </td>
+<% } %>

+ 8 - 45
src/frontend/js/app/nginx/dead/list/item.js

@@ -1,69 +1,32 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const Controller = require('../../../controller');
-const Api        = require('../../../api');
-const Cache      = require('../../../cache');
-const Tokens     = require('../../../tokens');
-const template   = require('./item.ejs');
+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-user',
-        permissions: 'a.edit-permissions',
-        password:    'a.set-password',
-        login:       'a.login',
-        delete:      'a.delete-user'
+        edit:   'a.edit',
+        delete: 'a.delete'
     },
 
     events: {
         'click @ui.edit': function (e) {
             e.preventDefault();
-            Controller.showUserForm(this.model);
-        },
-
-        'click @ui.permissions': function (e) {
-            e.preventDefault();
-            Controller.showUserPermissions(this.model);
-        },
-
-        'click @ui.password': function (e) {
-            e.preventDefault();
-            Controller.showUserPasswordForm(this.model);
+            App.Controller.showNginxDeadForm(this.model);
         },
 
         'click @ui.delete': function (e) {
             e.preventDefault();
-            Controller.showUserDeleteConfirm(this.model);
-        },
-
-        'click @ui.login': function (e) {
-            e.preventDefault();
-
-            if (Cache.User.get('id') !== this.model.get('id')) {
-                this.ui.login.prop('disabled', true).addClass('btn-disabled');
-
-                Api.Users.loginAs(this.model.get('id'))
-                    .then(res => {
-                        Tokens.addToken(res.token, res.user.nickname || res.user.name);
-                        window.location = '/';
-                        window.location.reload();
-                    })
-                    .catch(err => {
-                        alert(err.message);
-                        this.ui.login.prop('disabled', false).removeClass('btn-disabled');
-                    });
-            }
+            App.Controller.showNginxDeadDeleteConfirm(this.model);
         }
     },
 
     templateContext: {
-        isSelf: function () {
-            return Cache.User.get('id') === this.id;
-        }
+        canManage: App.Cache.User.canManage('dead_hosts')
     },
 
     initialize: function () {

+ 4 - 3
src/frontend/js/app/nginx/dead/list/main.ejs

@@ -1,9 +1,10 @@
 <thead>
     <th width="30">&nbsp;</th>
-    <th>Name</th>
-    <th>Email</th>
-    <th>Roles</th>
+    <th><%- i18n('str', 'source') %></th>
+    <th><%- i18n('str', 'ssl') %></th>
+    <% if (canManage) { %>
     <th>&nbsp;</th>
+    <% } %>
 </thead>
 <tbody>
     <!-- items -->

+ 8 - 3
src/frontend/js/app/nginx/dead/list/main.js

@@ -1,8 +1,9 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const ItemView   = require('./item');
-const template   = require('./main.ejs');
+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',
@@ -21,6 +22,10 @@ module.exports = Mn.View.extend({
         }
     },
 
+    templateContext: {
+        canManage: App.Cache.User.canManage('dead_hosts')
+    },
+
     onRender: function () {
         this.showChildView('body', new TableBody({
             collection: this.collection

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

@@ -1,10 +1,10 @@
 <div class="card">
     <div class="card-status bg-danger"></div>
     <div class="card-header">
-        <h3 class="card-title">404 Hosts</h3>
+        <h3 class="card-title"><%- i18n('dead-hosts', 'title') %></h3>
         <div class="card-options">
             <% if (showAddButton) { %>
-            <a href="#" class="btn btn-outline-danger btn-sm ml-2 add-item">Add 404 Host</a>
+            <a href="#" class="btn btn-outline-danger btn-sm ml-2 add-item"><%- i18n('dead-hosts', 'add') %></a>
             <% } %>
         </div>
     </div>

+ 14 - 15
src/frontend/js/app/nginx/dead/main.js

@@ -1,14 +1,12 @@
 'use strict';
 
 const Mn            = require('backbone.marionette');
+const App           = require('../../main');
 const DeadHostModel = require('../../../models/dead-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-dead',
@@ -27,18 +25,18 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.add': function (e) {
             e.preventDefault();
-            Controller.showNginxDeadForm();
+            App.Controller.showNginxDeadForm();
         }
     },
 
     templateContext: {
-        showAddButton: Cache.User.canManage('dead_hosts')
+        showAddButton: App.Cache.User.canManage('dead_hosts')
     },
 
     onRender: function () {
         let view = this;
 
-        Api.Nginx.DeadHosts.getAll()
+        App.Api.Nginx.DeadHosts.getAll(['owner'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {
@@ -46,15 +44,16 @@ module.exports = Mn.View.extend({
                             collection: new DeadHostModel.Collection(response)
                         }));
                     } else {
-                        let manage = Cache.User.canManage('dead_hosts');
+                        let manage = App.Cache.User.canManage('dead_hosts');
 
                         view.showChildView('list_region', new EmptyView({
-                            title:     'There are no 404 Hosts',
-                            subtitle:  manage ? 'Why don\'t you create one?' : 'And you don\'t have permission to create one.',
-                            link:      manage ? 'Add 404 Host' : null,
-                            btn_color: 'danger',
-                            action:    function () {
-                                Controller.showNginxDeadForm();
+                            title:      App.i18n('dead-hosts', 'empty'),
+                            subtitle:   App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
+                            link:       manage ? App.i18n('dead-hosts', 'add') : null,
+                            btn_color:  'danger',
+                            permission: 'dead_hosts',
+                            action:     function () {
+                                App.Controller.showNginxDeadForm();
                             }
                         }));
                     }
@@ -65,7 +64,7 @@ module.exports = Mn.View.extend({
                     code:    err.code,
                     message: err.message,
                     retry:   function () {
-                        Controller.showNginxDead();
+                        App.Controller.showNginxDead();
                     }
                 }));
 

+ 11 - 15
src/frontend/js/app/nginx/proxy/form.js

@@ -11,8 +11,8 @@ require('jquery-mask-plugin');
 require('selectize');
 
 module.exports = Mn.View.extend({
-    template:  template,
-    className: 'modal-dialog',
+    template:      template,
+    className:     'modal-dialog',
     max_file_size: 5120,
 
     ui: {
@@ -60,19 +60,15 @@ module.exports = Mn.View.extend({
             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;
-            });
+            data.forward_port    = parseInt(data.forward_port, 10);
+            data.block_exploits  = !!data.block_exploits;
+            data.caching_enabled = !!data.caching_enabled;
+            data.ssl_enabled     = !!data.ssl_enabled;
+            data.ssl_forced      = !!data.ssl_forced;
+
+            if (typeof data.meta !== 'undefined' && typeof data.meta.letsencrypt_agree !== 'undefined') {
+                data.meta.letsencrypt_agree = !!data.meta.letsencrypt_agree;
+            }
 
             if (typeof data.domain_names === 'string' && data.domain_names) {
                 data.domain_names = data.domain_names.split(',');

+ 8 - 12
src/frontend/js/app/nginx/redirection/form.js

@@ -58,18 +58,14 @@ module.exports = Mn.View.extend({
             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;
-            });
+            data.block_exploits = !!data.block_exploits;
+            data.preserve_path  = !!data.preserve_path;
+            data.ssl_enabled    = !!data.ssl_enabled;
+            data.ssl_forced     = !!data.ssl_forced;
+
+            if (typeof data.meta !== 'undefined' && typeof data.meta.letsencrypt_agree !== 'undefined') {
+                data.meta.letsencrypt_agree = !!data.meta.letsencrypt_agree;
+            }
 
             if (typeof data.domain_names === 'string' && data.domain_names) {
                 data.domain_names = data.domain_names.split(',');

+ 6 - 3
src/frontend/js/app/nginx/stream/form.ejs

@@ -9,7 +9,7 @@
                 <div class="col-sm-12 col-md-12">
                     <div class="form-group">
                         <label class="form-label"><%- i18n('streams', 'incoming-port') %> <span class="form-required">*</span></label>
-                        <input name="incoming_port" type="number" class="form-control text-monospace" placeholder="8080" value="<%- incoming_port %>" required>
+                        <input name="incoming_port" type="number" class="form-control text-monospace" placeholder="eg: 8080" value="<%- incoming_port %>" required>
                     </div>
                 </div>
                 <div class="col-sm-8 col-md-8">
@@ -21,7 +21,7 @@
                 <div class="col-sm-4 col-md-4">
                     <div class="form-group">
                         <label class="form-label"><%- i18n('streams', 'forwarding-port') %> <span class="form-required">*</span></label>
-                        <input name="forwarding_port" type="number" class="form-control text-monospace" placeholder="80" value="<%- forwarding_port %>" required>
+                        <input name="forwarding_port" type="number" class="form-control text-monospace" placeholder="eg: 80" value="<%- forwarding_port %>" required>
                     </div>
                 </div>
                 <div class="col-sm-6 col-md-6">
@@ -38,10 +38,13 @@
                         <label class="custom-switch">
                             <input type="checkbox" class="custom-switch-input" name="udp_forwarding" value="1"<%- udp_forwarding ? ' checked' : '' %>>
                             <span class="custom-switch-indicator"></span>
-                            <span class="custom-switch-description"><%- i18n('streams', 'udp_forwarding') %></span>
+                            <span class="custom-switch-description"><%- i18n('streams', 'udp-forwarding') %></span>
                         </label>
                     </div>
                 </div>
+                <div class="col-sm-12 col-md-12">
+                    <div class="forward-type-error invalid-feedback"><%- i18n('streams', 'forward-type-error') %></div>
+                </div>
             </div>
         </form>
     </div>

+ 17 - 16
src/frontend/js/app/nginx/stream/form.js

@@ -11,19 +11,24 @@ require('jquery-mask-plugin');
 require('selectize');
 
 module.exports = Mn.View.extend({
-    template:      template,
-    className:     'modal-dialog',
-    max_file_size: 5120,
+    template:  template,
+    className: 'modal-dialog',
 
     ui: {
         form:       'form',
         forward_ip: 'input[name="forward_ip"]',
+        type_error: '.forward-type-error',
         buttons:    '.modal-footer button',
+        switches:   '.custom-switch-input',
         cancel:     'button.cancel',
         save:       'button.save'
     },
 
     events: {
+        'change @ui.switches': function () {
+            this.ui.type_error.hide();
+        },
+
         'click @ui.save': function (e) {
             e.preventDefault();
 
@@ -35,20 +40,16 @@ module.exports = Mn.View.extend({
             let view = this;
             let data = this.ui.form.serializeJSON();
 
+            if (!data.tcp_forwarding && !data.udp_forwarding) {
+                this.ui.type_error.show();
+                return;
+            }
+
             // 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;
-            });
+            data.incoming_port   = parseInt(data.incoming_port, 10);
+            data.forwarding_port = parseInt(data.forwarding_port, 10);
+            data.tcp_forwarding  = !!data.tcp_forwarding;
+            data.udp_forwarding  = !!data.udp_forwarding;
 
             let method = App.Api.Nginx.Streams.create;
             let is_new = true;

+ 11 - 13
src/frontend/js/app/nginx/stream/list/item.ejs

@@ -4,26 +4,25 @@
     </div>
 </td>
 <td>
-    <div>
-        <% domain_names.map(function(host) {
-            %>
-            <span class="tag"><%- host %></span>
-            <%
-        });
-        %>
+    <div class="text-monospace">
+        <%- incoming_port %>
     </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>
+    <div class="text-monospace"><%- forward_ip %>:<%- forwarding_port %></div>
 </td>
 <td>
-    <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
+    <div>
+        <% if (tcp_forwarding) { %>
+            <span class="tag"><%- i18n('streams', 'tcp') %></span>
+        <% }
+        if (udp_forwarding) { %>
+            <span class="tag"><%- i18n('streams', 'udp') %></span>
+        <% } %>
+    </div>
 </td>
 <% if (canManage) { %>
 <td class="text-center">
@@ -31,7 +30,6 @@
         <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>

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

@@ -16,17 +16,17 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.edit': function (e) {
             e.preventDefault();
-            App.Controller.showNginxProxyForm(this.model);
+            App.Controller.showNginxStreamForm(this.model);
         },
 
         'click @ui.delete': function (e) {
             e.preventDefault();
-            App.Controller.showNginxProxyDeleteConfirm(this.model);
+            App.Controller.showNginxStreamDeleteConfirm(this.model);
         }
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('proxy_hosts')
+        canManage: App.Cache.User.canManage('streams')
     },
 
     initialize: function () {

+ 2 - 3
src/frontend/js/app/nginx/stream/list/main.ejs

@@ -1,9 +1,8 @@
 <thead>
     <th width="30">&nbsp;</th>
-    <th><%- i18n('str', 'source') %></th>
+    <th><%- i18n('streams', 'incoming-port') %></th>
     <th><%- i18n('str', 'destination') %></th>
-    <th><%- i18n('str', 'ssl') %></th>
-    <th><%- i18n('str', 'access') %></th>
+    <th><%- i18n('streams', 'protocol') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>
     <% } %>

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

@@ -23,7 +23,7 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('proxy_hosts')
+        canManage: App.Cache.User.canManage('streams')
     },
 
     onRender: function () {

+ 17 - 2
src/frontend/js/i18n/messages.json

@@ -94,12 +94,27 @@
       "delete-confirm": "Are you sure you want to delete the Redirection host for: <strong>{domains}</strong>?"
     },
     "dead-hosts": {
-      "title": "404 Hosts"
+      "title": "404 Hosts",
+      "empty": "There are no 404 Hosts",
+      "add": "Add 404 Host",
+      "form-title": "{id, select, undefined{New} other{Edit}} 404 Host"
     },
     "streams": {
       "title": "Streams",
       "empty": "There are no Streams",
-      "add": "Add Stream"
+      "add": "Add Stream",
+      "form-title": "{id, select, undefined{New} other{Edit}} Stream",
+      "incoming-port": "Incoming Port",
+      "forward-ip": "Forward IP",
+      "forwarding-port": "Forward Port",
+      "tcp-forwarding": "TCP Forwarding",
+      "udp-forwarding": "UDP Forwarding",
+      "forward-type-error": "At least one type of protocol must be enabled",
+      "protocol": "Protocol",
+      "tcp": "TCP",
+      "udp": "UDP",
+      "delete": "Delete Stream",
+      "delete-confirm": "Are you sure you want to delete this Stream?"
     },
     "access-lists": {
       "title": "Access Lists",

+ 5 - 3
src/frontend/js/models/dead-host.js

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

+ 3 - 3
src/frontend/js/models/stream.js

@@ -11,9 +11,9 @@ const model = Backbone.Model.extend({
             created_on:      null,
             modified_on:     null,
             owner:           null,
-            incoming_port:   3000,
-            forward_ip:      '',
-            forwarding_port: 3000,
+            incoming_port:   null,
+            forward_ip:      null,
+            forwarding_port: null,
             tcp_forwarding:  true,
             udp_forwarding:  false,
             meta:            {}