Bladeren bron

Certificates into their own section

Jamie Curnow 7 jaren geleden
bovenliggende
commit
c749a22b52
41 gewijzigde bestanden met toevoegingen van 597 en 383 verwijderingen
  1. 1 1
      package.json
  2. 91 4
      src/backend/internal/certificate.js
  3. 2 6
      src/backend/internal/dead-host.js
  4. 1 1
      src/backend/internal/host.js
  5. 2 40
      src/backend/internal/proxy-host.js
  6. 2 6
      src/backend/internal/redirection-host.js
  7. 16 17
      src/backend/migrations/20180618015850_initial.js
  8. 5 1
      src/backend/models/certificate.js
  9. 16 3
      src/backend/models/dead_host.js
  10. 17 4
      src/backend/models/proxy_host.js
  11. 17 4
      src/backend/models/redirection_host.js
  12. 74 7
      src/backend/routes/api/nginx/certificates.js
  13. 0 34
      src/backend/routes/api/nginx/proxy_hosts.js
  14. 144 0
      src/backend/schema/endpoints/certificates.json
  15. 3 0
      src/backend/schema/index.json
  16. 9 9
      src/frontend/js/app/api.js
  17. 8 0
      src/frontend/js/app/audit-log/list/item.ejs
  18. 26 0
      src/frontend/js/app/controller.js
  19. 1 1
      src/frontend/js/app/nginx/access/list/item.ejs
  20. 43 99
      src/frontend/js/app/nginx/certificates/form.ejs
  21. 20 67
      src/frontend/js/app/nginx/certificates/form.js
  22. 14 16
      src/frontend/js/app/nginx/certificates/list/item.ejs
  23. 5 7
      src/frontend/js/app/nginx/certificates/list/item.js
  24. 3 4
      src/frontend/js/app/nginx/certificates/list/main.ejs
  25. 9 1
      src/frontend/js/app/nginx/certificates/main.ejs
  26. 2 1
      src/frontend/js/app/nginx/certificates/main.js
  27. 2 2
      src/frontend/js/app/nginx/dead/list/item.ejs
  28. 1 1
      src/frontend/js/app/nginx/dead/main.js
  29. 2 2
      src/frontend/js/app/nginx/proxy/list/item.ejs
  30. 1 1
      src/frontend/js/app/nginx/proxy/main.js
  31. 2 2
      src/frontend/js/app/nginx/redirection/list/item.ejs
  32. 1 1
      src/frontend/js/app/nginx/redirection/main.js
  33. 1 1
      src/frontend/js/app/nginx/stream/list/item.ejs
  34. 1 1
      src/frontend/js/app/users/list/item.ejs
  35. 12 8
      src/frontend/js/i18n/messages.json
  36. 20 4
      src/frontend/js/models/certificate.js
  37. 9 9
      src/frontend/js/models/dead-host.js
  38. 3 12
      src/frontend/js/models/proxy-host.js
  39. 3 3
      src/frontend/js/models/redirection-host.js
  40. 3 2
      src/frontend/js/models/stream.js
  41. 5 1
      src/frontend/scss/custom.scss

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "nginx-proxy-manager",
   "version": "2.0.0",
-  "description": "A nice web interface for managing your endpoints",
+  "description": "A beautiful interface for creating Nginx endpoints",
   "main": "src/backend/index.js",
   "devDependencies": {
     "babel-core": "^6.26.3",

+ 91 - 4
src/backend/internal/certificate.js

@@ -3,6 +3,8 @@
 const _                = require('lodash');
 const error            = require('../lib/error');
 const certificateModel = require('../models/certificate');
+const internalAuditLog = require('./audit-log');
+const internalHost     = require('./host');
 
 function omissions () {
     return ['is_deleted'];
@@ -17,9 +19,27 @@ const internalCertificate = {
      */
     create: (access, data) => {
         return access.can('certificates:create', data)
-            .then(access_data => {
-                // TODO
-                return {};
+            .then(() => {
+                data.owner_user_id = access.token.get('attrs').id;
+
+                return certificateModel
+                    .query()
+                    .omit(omissions())
+                    .insertAndFetch(data);
+            })
+            .then(row => {
+                data.meta = _.assign({}, data.meta || {}, row.meta);
+
+                // Add to audit log
+                return internalAuditLog.add(access, {
+                    action:      'created',
+                    object_type: 'certificate',
+                    object_id:   row.id,
+                    meta:        data
+                })
+                    .then(() => {
+                        return row;
+                    });
             });
     },
 
@@ -135,7 +155,7 @@ const internalCertificate = {
                     .groupBy('id')
                     .omit(['is_deleted'])
                     .allowEager('[owner]')
-                    .orderBy('name', 'ASC');
+                    .orderBy('nice_name', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
                     query.andWhere('owner_user_id', access.token.get('attrs').id);
@@ -177,6 +197,73 @@ const internalCertificate = {
             .then(row => {
                 return parseInt(row.count, 10);
             });
+    },
+
+    /**
+     * Validates that the certs provided are good
+     *
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @param   {Object}  data.files
+     * @returns {Promise}
+     */
+    validate: (access, data) => {
+        return new Promise((resolve, reject) => {
+            let files = {};
+            _.map(data.files, (file, name) => {
+                if (internalHost.allowed_ssl_files.indexOf(name) !== -1) {
+                    files[name] = file.data.toString();
+                }
+            });
+
+            resolve(files);
+        })
+            .then(files => {
+
+                // TODO: validate using openssl
+                // files.certificate
+                // files.certificate_key
+
+                return true;
+            });
+    },
+
+    /**
+     * @param   {Access}  access
+     * @param   {Object}  data
+     * @param   {Integer} data.id
+     * @param   {Object}  data.files
+     * @returns {Promise}
+     */
+    upload: (access, data) => {
+        return internalCertificate.get(access, {id: data.id})
+            .then(row => {
+                if (row.provider !== 'other') {
+                    throw new error.ValidationError('Cannot upload certificates for this type of provider');
+                }
+
+                _.map(data.files, (file, name) => {
+                    if (internalHost.allowed_ssl_files.indexOf(name) !== -1) {
+                        row.meta[name] = file.data.toString();
+                    }
+                });
+
+                return internalCertificate.update(access, {
+                    id:   data.id,
+                    meta: row.meta
+                });
+            })
+            .then(row => {
+                return internalAuditLog.add(access, {
+                    action:      'updated',
+                    object_type: 'certificate',
+                    object_id:   row.id,
+                    meta:        data
+                })
+                    .then(() => {
+                        return _.pick(row.meta, internalHost.allowed_ssl_files);
+                    });
+            });
     }
 };
 

+ 2 - 6
src/backend/internal/dead-host.js

@@ -41,10 +41,6 @@ const internalDeadHost = {
                 // 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())
@@ -149,7 +145,7 @@ const internalDeadHost = {
                     .query()
                     .where('is_deleted', 0)
                     .andWhere('id', data.id)
-                    .allowEager('[owner]')
+                    .allowEager('[owner,certificate]')
                     .first();
 
                 if (access_data.permission_visibility !== 'all') {
@@ -274,7 +270,7 @@ const internalDeadHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .allowEager('[owner]')
+                    .allowEager('[owner,certificate]')
                     .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {

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

@@ -8,7 +8,7 @@ const deadHostModel        = require('../models/dead_host');
 
 const internalHost = {
 
-    allowed_ssl_files: ['other_certificate', 'other_certificate_key'],
+    allowed_ssl_files: ['certificate', 'certificate_key'],
 
     /**
      * Internal use only, checks to see if the domain is already taken by any other record

+ 2 - 40
src/backend/internal/proxy-host.js

@@ -41,10 +41,6 @@ const internalProxyHost = {
                 // 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 proxyHostModel
                     .query()
                     .omit(omissions())
@@ -151,7 +147,7 @@ const internalProxyHost = {
                     .query()
                     .where('is_deleted', 0)
                     .andWhere('id', data.id)
-                    .allowEager('[owner,access_list]')
+                    .allowEager('[owner,access_list,certificate]')
                     .first();
 
                 if (access_data.permission_visibility !== 'all') {
@@ -226,40 +222,6 @@ const internalProxyHost = {
             });
     },
 
-    /**
-     * @param   {Access}  access
-     * @param   {Object}  data
-     * @param   {Integer} data.id
-     * @param   {Object}  data.files
-     * @returns {Promise}
-     */
-    setCerts: (access, data) => {
-        return internalProxyHost.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 internalProxyHost.update(access, {
-                    id:   data.id,
-                    meta: row.meta
-                });
-            })
-            .then(row => {
-                return internalAuditLog.add(access, {
-                    action:      'updated',
-                    object_type: 'proxy-host',
-                    object_id:   row.id,
-                    meta:        data
-                })
-                    .then(() => {
-                        return _.pick(row.meta, internalHost.allowed_ssl_files);
-                    });
-            });
-    },
-
     /**
      * All Hosts
      *
@@ -276,7 +238,7 @@ const internalProxyHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .allowEager('[owner,access_list]')
+                    .allowEager('[owner,access_list,certificate]')
                     .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {

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

@@ -41,10 +41,6 @@ const internalRedirectionHost = {
                 // 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())
@@ -149,7 +145,7 @@ const internalRedirectionHost = {
                     .query()
                     .where('is_deleted', 0)
                     .andWhere('id', data.id)
-                    .allowEager('[owner]')
+                    .allowEager('[owner,certificate]')
                     .first();
 
                 if (access_data.permission_visibility !== 'all') {
@@ -274,7 +270,7 @@ const internalRedirectionHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .allowEager('[owner]')
+                    .allowEager('[owner,certificate]')
                     .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {

+ 16 - 17
src/backend/migrations/20180618015850_initial.js

@@ -22,7 +22,7 @@ exports.up = function (knex/*, Promise*/) {
         table.integer('user_id').notNull().unsigned();
         table.string('type', 30).notNull();
         table.string('secret').notNull();
-        table.json('meta').notNull();
+        table.json('meta').notNull().defaultTo('{}');
         table.integer('is_deleted').notNull().unsigned().defaultTo(0);
     })
         .then(() => {
@@ -72,12 +72,11 @@ exports.up = function (knex/*, Promise*/) {
                 table.string('forward_ip').notNull();
                 table.integer('forward_port').notNull().unsigned();
                 table.integer('access_list_id').notNull().unsigned().defaultTo(0);
-                table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
-                table.string('ssl_provider').notNull().defaultTo('');
+                table.integer('certificate_id').notNull().unsigned().defaultTo(0);
                 table.integer('ssl_forced').notNull().unsigned().defaultTo(0);
                 table.integer('caching_enabled').notNull().unsigned().defaultTo(0);
                 table.integer('block_exploits').notNull().unsigned().defaultTo(0);
-                table.json('meta').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {
@@ -92,11 +91,10 @@ exports.up = function (knex/*, Promise*/) {
                 table.json('domain_names').notNull();
                 table.string('forward_domain_name').notNull();
                 table.integer('preserve_path').notNull().unsigned().defaultTo(0);
-                table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
-                table.string('ssl_provider').notNull().defaultTo('');
+                table.integer('certificate_id').notNull().unsigned().defaultTo(0);
                 table.integer('ssl_forced').notNull().unsigned().defaultTo(0);
                 table.integer('block_exploits').notNull().unsigned().defaultTo(0);
-                table.json('meta').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {
@@ -109,10 +107,9 @@ exports.up = function (knex/*, Promise*/) {
                     table.integer('owner_user_id').notNull().unsigned();
                     table.integer('is_deleted').notNull().unsigned().defaultTo(0);
                     table.json('domain_names').notNull();
-                    table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
-                    table.string('ssl_provider').notNull().defaultTo('');
+                    table.integer('certificate_id').notNull().unsigned().defaultTo(0);
                     table.integer('ssl_forced').notNull().unsigned().defaultTo(0);
-                    table.json('meta').notNull();
+                    table.json('meta').notNull().defaultTo('{}');
                 });
         })
         .then(() => {
@@ -129,7 +126,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.integer('forwarding_port').notNull().unsigned();
                 table.integer('tcp_forwarding').notNull().unsigned().defaultTo(0);
                 table.integer('udp_forwarding').notNull().unsigned().defaultTo(0);
-                table.json('meta').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {
@@ -142,7 +139,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.integer('owner_user_id').notNull().unsigned();
                 table.integer('is_deleted').notNull().unsigned().defaultTo(0);
                 table.string('name').notNull();
-                table.json('meta').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {
@@ -154,9 +151,11 @@ exports.up = function (knex/*, Promise*/) {
                 table.dateTime('modified_on').notNull();
                 table.integer('owner_user_id').notNull().unsigned();
                 table.integer('is_deleted').notNull().unsigned().defaultTo(0);
-                table.string('name').notNull();
-                // TODO
-                table.json('meta').notNull();
+                table.string('provider').notNull();
+                table.string('nice_name').notNull().defaultTo('');
+                table.json('domain_names').notNull().defaultTo('[]');
+                table.dateTime('expires_on').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {
@@ -169,7 +168,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.integer('access_list_id').notNull().unsigned();
                 table.string('username').notNull();
                 table.string('password').notNull();
-                table.json('meta').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {
@@ -183,7 +182,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.string('object_type').notNull().defaultTo('');
                 table.integer('object_id').notNull().unsigned().defaultTo(0);
                 table.string('action').notNull();
-                table.json('meta').notNull();
+                table.json('meta').notNull().defaultTo('{}');
             });
         })
         .then(() => {

+ 5 - 1
src/backend/models/certificate.js

@@ -13,6 +13,10 @@ class Certificate extends Model {
     $beforeInsert () {
         this.created_on  = Model.raw('NOW()');
         this.modified_on = Model.raw('NOW()');
+
+        if (typeof this.expires_on === 'undefined') {
+            this.expires_on = Model.raw('NOW()');
+        }
     }
 
     $beforeUpdate () {
@@ -28,7 +32,7 @@ class Certificate extends Model {
     }
 
     static get jsonAttributes () {
-        return ['meta'];
+        return ['domain_names', 'meta'];
     }
 
     static get relationMappings () {

+ 16 - 3
src/backend/models/dead_host.js

@@ -3,9 +3,10 @@
 
 'use strict';
 
-const db    = require('../db');
-const Model = require('objection').Model;
-const User  = require('./user');
+const db          = require('../db');
+const Model       = require('objection').Model;
+const User        = require('./user');
+const Certificate = require('./certificate');
 
 Model.knex(db);
 
@@ -44,6 +45,18 @@ class DeadHost extends Model {
                     qb.where('user.is_deleted', 0);
                     qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
                 }
+            },
+            certificate: {
+                relation:   Model.HasOneRelation,
+                modelClass: Certificate,
+                join:       {
+                    from: 'dead_host.certificate_id',
+                    to:   'certificate.id'
+                },
+                modify:     function (qb) {
+                    qb.where('certificate.is_deleted', 0);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']);
+                }
             }
         };
     }

+ 17 - 4
src/backend/models/proxy_host.js

@@ -3,10 +3,11 @@
 
 'use strict';
 
-const db         = require('../db');
-const Model      = require('objection').Model;
-const User       = require('./user');
-const AccessList = require('./access_list');
+const db          = require('../db');
+const Model       = require('objection').Model;
+const User        = require('./user');
+const AccessList  = require('./access_list');
+const Certificate = require('./certificate');
 
 Model.knex(db);
 
@@ -61,6 +62,18 @@ class ProxyHost extends Model {
                     qb.where('access_list.is_deleted', 0);
                     qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']);
                 }
+            },
+            certificate: {
+                relation:   Model.HasOneRelation,
+                modelClass: Certificate,
+                join:       {
+                    from: 'proxy_host.certificate_id',
+                    to:   'certificate.id'
+                },
+                modify:     function (qb) {
+                    qb.where('certificate.is_deleted', 0);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']);
+                }
             }
         };
     }

+ 17 - 4
src/backend/models/redirection_host.js

@@ -3,9 +3,10 @@
 
 'use strict';
 
-const db    = require('../db');
-const Model = require('objection').Model;
-const User  = require('./user');
+const db          = require('../db');
+const Model       = require('objection').Model;
+const User        = require('./user');
+const Certificate = require('./certificate');
 
 Model.knex(db);
 
@@ -33,7 +34,7 @@ class RedirectionHost extends Model {
 
     static get relationMappings () {
         return {
-            owner: {
+            owner:       {
                 relation:   Model.HasOneRelation,
                 modelClass: User,
                 join:       {
@@ -44,6 +45,18 @@ class RedirectionHost extends Model {
                     qb.where('user.is_deleted', 0);
                     qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
                 }
+            },
+            certificate: {
+                relation:   Model.HasOneRelation,
+                modelClass: Certificate,
+                join:       {
+                    from: 'redirection_host.certificate_id',
+                    to:   'certificate.id'
+                },
+                modify:     function (qb) {
+                    qb.where('certificate.is_deleted', 0);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']);
+                }
             }
         };
     }

+ 74 - 7
src/backend/routes/api/nginx/certificates.js

@@ -75,7 +75,7 @@ router
  * /api/nginx/certificates/123
  */
 router
-    .route('/:host_id')
+    .route('/:certificate_id')
     .options((req, res) => {
         res.sendStatus(204);
     })
@@ -88,10 +88,10 @@ router
      */
     .get((req, res, next) => {
         validator({
-            required:             ['host_id'],
+            required:             ['certificate_id'],
             additionalProperties: false,
             properties:           {
-                host_id: {
+                certificate_id: {
                     $ref: 'definitions#/definitions/id'
                 },
                 expand:  {
@@ -99,12 +99,12 @@ router
                 }
             }
         }, {
-            host_id: req.params.host_id,
+            certificate_id: req.params.certificate_id,
             expand:  (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null)
         })
             .then(data => {
                 return internalCertificate.get(res.locals.access, {
-                    id:     parseInt(data.host_id, 10),
+                    id:     parseInt(data.certificate_id, 10),
                     expand: data.expand
                 });
             })
@@ -123,7 +123,7 @@ router
     .put((req, res, next) => {
         apiValidator({$ref: 'endpoints/certificates#/links/2/schema'}, req.body)
             .then(payload => {
-                payload.id = parseInt(req.params.host_id, 10);
+                payload.id = parseInt(req.params.certificate_id, 10);
                 return internalCertificate.update(res.locals.access, payload);
             })
             .then(result => {
@@ -139,7 +139,7 @@ router
      * Update and existing certificate
      */
     .delete((req, res, next) => {
-        internalCertificate.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)})
+        internalCertificate.delete(res.locals.access, {id: parseInt(req.params.certificate_id, 10)})
             .then(result => {
                 res.status(200)
                     .send(result);
@@ -147,4 +147,71 @@ router
             .catch(next);
     });
 
+/**
+ * Upload Certs
+ *
+ * /api/nginx/certificates/123/upload
+ */
+router
+    .route('/:certificate_id/upload')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * POST /api/nginx/certificates/123/upload
+     *
+     * Upload certificates
+     */validate
+    .post((req, res, next) => {
+        if (!req.files) {
+            res.status(400)
+                .send({error: 'No files were uploaded'});
+        } else {
+            internalCertificate.upload(res.locals.access, {
+                id:    parseInt(req.params.certificate_id, 10),
+                files: req.files
+            })
+                .then(result => {
+                    res.status(200)
+                        .send(result);
+                })
+                .catch(next);
+        }
+    });
+
+/**
+ * Validate Certs before saving
+ *
+ * /api/nginx/certificates/validate
+ */
+router
+    .route('/validate')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes
+
+    /**
+     * POST /api/nginx/certificates/validate
+     *
+     * Validate certificates
+     */
+    .post((req, res, next) => {
+        if (!req.files) {
+            res.status(400)
+                .send({error: 'No files were uploaded'});
+        } else {
+            internalCertificate.validate(res.locals.access, {
+                files: req.files
+            })
+                .then(result => {
+                    res.status(200)
+                        .send(result);
+                })
+                .catch(next);
+        }
+    });
+
 module.exports = router;

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

@@ -147,38 +147,4 @@ router
             .catch(next);
     });
 
-/**
- * Specific proxy-host Certificates
- *
- * /api/nginx/proxy-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/proxy-hosts/123/certificates
-     *
-     * Upload certifications
-     */
-    .post((req, res, next) => {
-        if (!req.files) {
-            res.status(400)
-                .send({error: 'No files were uploaded'});
-        } else {
-            internalProxyHost.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;

+ 144 - 0
src/backend/schema/endpoints/certificates.json

@@ -0,0 +1,144 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "endpoints/certificates",
+  "title": "Certificates",
+  "description": "Endpoints relating to Certificates",
+  "stability": "stable",
+  "type": "object",
+  "definitions": {
+    "id": {
+      "$ref": "../definitions.json#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "../definitions.json#/definitions/created_on"
+    },
+    "modified_on": {
+      "$ref": "../definitions.json#/definitions/modified_on"
+    },
+    "provider": {
+      "$ref": "../definitions.json#/definitions/ssl_provider"
+    },
+    "nice_name": {
+      "type": "string",
+      "description": "Nice Name for the custom certificate"
+    },
+    "domain_names": {
+      "$ref": "../definitions.json#/definitions/domain_names"
+    },
+    "expires_on": {
+      "description": "Date and time of expiration",
+      "format": "date-time",
+      "readOnly": true,
+      "type": "string"
+    },
+    "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"
+    },
+    "provider": {
+      "$ref": "#/definitions/provider"
+    },
+    "nice_name": {
+      "$ref": "#/definitions/nice_name"
+    },
+    "domain_names": {
+      "$ref": "#/definitions/domain_names"
+    },
+    "expires_on": {
+      "$ref": "#/definitions/expires_on"
+    },
+    "meta": {
+      "$ref": "#/definitions/meta"
+    }
+  },
+  "links": [
+    {
+      "title": "List",
+      "description": "Returns a list of Certificates",
+      "href": "/nginx/certificates",
+      "access": "private",
+      "method": "GET",
+      "rel": "self",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "targetSchema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Create",
+      "description": "Creates a new Certificate",
+      "href": "/nginx/certificates",
+      "access": "private",
+      "method": "POST",
+      "rel": "create",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "schema": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": [
+          "provider"
+        ],
+        "properties": {
+          "provider": {
+            "$ref": "#/definitions/provider"
+          },
+          "nice_name": {
+            "$ref": "#/definitions/nice_name"
+          },
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
+          },
+          "meta": {
+            "$ref": "#/definitions/meta"
+          }
+        }
+      },
+      "targetSchema": {
+        "properties": {
+          "$ref": "#/properties"
+        }
+      }
+    },
+    {
+      "title": "Delete",
+      "description": "Deletes a existing Certificate",
+      "href": "/nginx/certificates/{definitions.identity.example}",
+      "access": "private",
+      "method": "DELETE",
+      "rel": "delete",
+      "http_header": {
+        "$ref": "../examples.json#/definitions/auth_header"
+      },
+      "targetSchema": {
+        "type": "boolean"
+      }
+    }
+  ]
+}

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

@@ -28,6 +28,9 @@
     },
     "streams": {
       "$ref": "endpoints/streams.json"
+    },
+    "certificates": {
+      "$ref": "endpoints/certificates.json"
     }
   }
 }

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

@@ -323,15 +323,6 @@ module.exports = {
              */
             delete: function (id) {
                 return fetch('delete', 'nginx/proxy-hosts/' + id);
-            },
-
-            /**
-             * @param  {Integer}  id
-             * @param  {FormData} form_data
-             * @params {Promise}
-             */
-            setCerts: function (id, form_data) {
-                return FileUpload('nginx/proxy-hosts/' + id + '/certificates', form_data);
             }
         },
 
@@ -535,6 +526,15 @@ module.exports = {
              */
             delete: function (id) {
                 return fetch('delete', 'nginx/certificates/' + id);
+            },
+
+            /**
+             * @param  {Integer}  id
+             * @param  {FormData} form_data
+             * @params {Promise}
+             */
+            upload: function (id, form_data) {
+                return FileUpload('nginx/certificates/' + id + '/upload', form_data);
             }
         }
     },

+ 8 - 0
src/frontend/js/app/audit-log/list/item.ejs

@@ -46,6 +46,14 @@
                 %> <span class="text-teal"><i class="fe fe-user"></i></span> <%
                 items.push(meta.name);
                 break;
+            case 'certificate':
+                %> <span class="text-teal"><i class="fe fe-shield"></i></span> <%
+                if (meta.provider === 'letsencrypt') {
+                    items = meta.domain_names;
+                } else {
+                    items.push(meta.nice_name);
+                }
+                break;
         }
         %>&nbsp;<%- i18n('audit-log', action, {name: i18n('audit-log', object_type)}) %>
         &mdash;

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

@@ -333,6 +333,32 @@ module.exports = {
         }
     },
 
+    /**
+     * Nginx Certificate Form
+     *
+     * @param [model]
+     */
+    showNginxCertificateForm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
+            require(['./main', './nginx/certificates/form'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
+    /**
+     * Certificate Delete Confirm
+     *
+     * @param model
+     */
+    showNginxCertificateDeleteConfirm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('certificates')) {
+            require(['./main', './nginx/certificates/delete'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Audit Log
      */

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

@@ -26,7 +26,7 @@
     <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
 </td>
 <% if (canManage) { %>
-<td class="text-center">
+<td class="text-right">
     <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">

+ 43 - 99
src/frontend/js/app/nginx/certificates/form.ejs

@@ -1,118 +1,62 @@
 <div class="modal-content">
     <div class="modal-header">
-        <h5 class="modal-title"><%- i18n('certificates', 'form-title', {id: id}) %></h5>
+        <h5 class="modal-title"><%- i18n('certificates', 'form-title', {provider: provider}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
-    <div class="modal-body has-tabs">
+    <div class="modal-body">
         <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 class="row">
+                <% if (provider === 'letsencrypt') { %>
+                    <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 class="text-blue"><i class="fe fe-alert-triangle"></i> <%- i18n('ssl', 'hosts-warning') %></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 class="col-sm-12 col-md-12">
+                        <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 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">
+                        <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 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>
+                <% } else if (provider === 'other') { %>
+                    <!-- Other -->
+                    <div class="col-sm-12 col-md-12">
+                        <div class="form-group">
+                            <label class="form-label"><%- i18n('str', 'name') %> <span class="form-required">*</span></label>
+                            <input name="nice_name" type="text" class="form-control" placeholder="" value="<%- nice_name %>" required>
                         </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 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" required>
+                                <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
                             </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 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" required>
+                                <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
                             </div>
                         </div>
                     </div>
-                </div>
+                <% } %>
             </div>
-
-
         </form>
     </div>
     <div class="modal-footer">

+ 20 - 67
src/frontend/js/app/nginx/certificates/form.js

@@ -7,7 +7,6 @@ const CertificateModel = require('../../../models/certificate');
 const template         = require('./form.ejs');
 
 require('jquery-serializejson');
-require('jquery-mask-plugin');
 require('selectize');
 
 module.exports = Mn.View.extend({
@@ -18,36 +17,14 @@ module.exports = Mn.View.extend({
     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'
+        other_ssl_certificate_key: '#other_ssl_certificate_key'
     },
 
     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();
 
@@ -58,66 +35,50 @@ module.exports = Mn.View.extend({
 
             let view = this;
             let data = this.ui.form.serializeJSON();
+            data.provider = this.model.get('provider');
 
             // 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.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.ProxyHosts.create;
-            let is_new            = true;
-
-            let must_require_ssl_files = require_ssl_files && !view.model.hasSslFiles('other');
+            let method    = App.Api.Nginx.Certificates.create;
+            let is_new    = true;
+            let ssl_files = [];
 
             if (this.model.get('id')) {
                 // edit
                 is_new  = false;
-                method  = App.Api.Nginx.ProxyHosts.update;
+                method  = App.Api.Nginx.Certificates.update;
                 data.id = this.model.get('id');
             }
 
             // check files are attached
-            if (require_ssl_files) {
+            if (this.model.get('provider') === 'other' && !this.model.hasSslFiles()) {
                 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;
-                    }
+                    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]});
+                    ssl_files.push({name: '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;
-                    }
+                    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]});
+                    ssl_files.push({name: 'certificate_key', file: this.ui.other_ssl_certificate_key[0].files[0]});
                 }
             }
 
@@ -134,7 +95,7 @@ module.exports = Mn.View.extend({
                             form_data.append(file.name, file.file);
                         });
 
-                        return App.Api.Nginx.ProxyHosts.setCerts(view.model.get('id'), form_data)
+                        return App.Api.Nginx.Certificates.upload(view.model.get('id'), form_data)
                             .then(result => {
                                 view.model.set('meta', _.assign({}, view.model.get('meta'), result));
                             });
@@ -143,7 +104,7 @@ module.exports = Mn.View.extend({
                 .then(() => {
                     App.UI.closeModal(function () {
                         if (is_new) {
-                            App.Controller.showNginxProxy();
+                            App.Controller.showNginxCertificates();
                         }
                     });
                 })
@@ -165,14 +126,6 @@ module.exports = Mn.View.extend({
     },
 
     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,
@@ -183,13 +136,13 @@ module.exports = Mn.View.extend({
                     text:  input
                 };
             },
-            createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
+            createFilter: /^(?:[^.*]+\.?)+[^.]$/
         });
     },
 
     initialize: function (options) {
         if (typeof options.model === 'undefined' || !options.model) {
-            this.model = new CertificateModel.Model();
+            this.model = new CertificateModel.Model({provider: 'letsencrypt'});
         }
     }
 });

+ 14 - 16
src/frontend/js/app/nginx/certificates/list/item.ejs

@@ -5,34 +5,32 @@
 </td>
 <td>
     <div>
-        <% domain_names.map(function(host) {
-        %>
-        <span class="tag"><%- host %></span>
-        <%
-        });
-        %>
+        <% if (provider === 'letsencrypt') { %>
+            <% domain_names.map(function(host) {
+                %>
+                <span class="tag"><%- host %></span>
+                <%
+            });
+            %>
+        <% } else { %>
+            <%- nice_name %>
+        <% } %>
     </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>
+    <%- i18n('ssl', provider) %>
 </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 class="<%- isExpired() ? 'text-danger' : '' %>">
+    <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %>
 </td>
 <% if (canManage) { %>
-<td class="text-center">
+<td class="text-right">
     <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>

+ 5 - 7
src/frontend/js/app/nginx/certificates/list/item.js

@@ -1,6 +1,7 @@
 'use strict';
 
 const Mn       = require('backbone.marionette');
+const moment   = require('moment');
 const App      = require('../../../main');
 const template = require('./item.ejs');
 
@@ -9,16 +10,10 @@ module.exports = Mn.View.extend({
     tagName:  'tr',
 
     ui: {
-        edit:   'a.edit',
         delete: 'a.delete'
     },
 
     events: {
-        'click @ui.edit': function (e) {
-            e.preventDefault();
-            App.Controller.showNginxCertificateForm(this.model);
-        },
-
         'click @ui.delete': function (e) {
             e.preventDefault();
             App.Controller.showNginxCertificateDeleteConfirm(this.model);
@@ -26,7 +21,10 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        canManage: App.Cache.User.canManage('certificates')
+        canManage: App.Cache.User.canManage('certificates'),
+        isExpired: function () {
+            return moment(this.expires_on).isBefore(moment());
+        }
     },
 
     initialize: function () {

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

@@ -1,9 +1,8 @@
 <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>
+    <th><%- i18n('str', 'name') %></th>
+    <th><%- i18n('all-hosts', 'cert-provider') %></th>
+    <th><%- i18n('str', 'expires') %></th>
     <% if (canManage) { %>
     <th>&nbsp;</th>
     <% } %>

+ 9 - 1
src/frontend/js/app/nginx/certificates/main.ejs

@@ -5,7 +5,15 @@
         <div class="card-options">
             <a href="#" class="btn btn-outline-secondary btn-sm ml-2 help"><i class="fe fe-help-circle"></i></a>
             <% if (showAddButton) { %>
-            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-item"><%- i18n('certificates', 'add') %></a>
+            <div class="dropdown">
+                <button type="button" class="btn btn-outline-teal btn-sm ml-2 dropdown-toggle" data-toggle="dropdown">
+                    <%- i18n('certificates', 'add') %>
+                </button>
+                <div class="dropdown-menu">
+                    <a class="dropdown-item add-item" data-cert="letsencrypt" href="#"><%- i18n('ssl', 'letsencrypt') %></a>
+                    <a class="dropdown-item add-item" data-cert="other" href="#"><%- i18n('ssl', 'other') %></a>
+                </div>
+            </div>
             <% } %>
         </div>
     </div>

+ 2 - 1
src/frontend/js/app/nginx/certificates/main.js

@@ -26,7 +26,8 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.add': function (e) {
             e.preventDefault();
-            App.Controller.showNginxCertificateForm();
+            let model = new CertificateModel.Model({provider: $(e.currentTarget).data('cert')});
+            App.Controller.showNginxCertificateForm(model);
         },
 
         'click @ui.help': function (e) {

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

@@ -17,7 +17,7 @@
     </div>
 </td>
 <td>
-    <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
+    <div><%- certificate ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %></div>
 </td>
 <td>
     <%
@@ -31,7 +31,7 @@
     <% } %>
 </td>
 <% if (canManage) { %>
-<td class="text-center">
+<td class="text-right">
     <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">

+ 1 - 1
src/frontend/js/app/nginx/dead/main.js

@@ -42,7 +42,7 @@ module.exports = Mn.View.extend({
     onRender: function () {
         let view = this;
 
-        App.Api.Nginx.DeadHosts.getAll(['owner'])
+        App.Api.Nginx.DeadHosts.getAll(['owner', 'certificate'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {

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

@@ -20,7 +20,7 @@
     <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><%- certificate ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %></div>
 </td>
 <td>
     <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
@@ -37,7 +37,7 @@
     <% } %>
 </td>
 <% if (canManage) { %>
-<td class="text-center">
+<td class="text-right">
     <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">

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

@@ -42,7 +42,7 @@ module.exports = Mn.View.extend({
     onRender: function () {
         let view = this;
 
-        App.Api.Nginx.ProxyHosts.getAll(['owner', 'access_list'])
+        App.Api.Nginx.ProxyHosts.getAll(['owner', 'access_list', 'certificate'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {

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

@@ -20,7 +20,7 @@
     <div class="text-monospace"><%- forward_domain_name %></div>
 </td>
 <td>
-    <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
+    <div><%- certificate ? i18n('ssl', certificate.provider) : i18n('ssl', 'none') %></div>
 </td>
 <td>
     <%
@@ -34,7 +34,7 @@
     <% } %>
 </td>
 <% if (canManage) { %>
-<td class="text-center">
+<td class="text-right">
     <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">

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

@@ -42,7 +42,7 @@ module.exports = Mn.View.extend({
     onRender: function () {
         let view = this;
 
-        App.Api.Nginx.RedirectionHosts.getAll(['owner'])
+        App.Api.Nginx.RedirectionHosts.getAll(['owner', 'certificate'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {

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

@@ -36,7 +36,7 @@
     <% } %>
 </td>
 <% if (canManage) { %>
-<td class="text-center">
+<td class="text-right">
     <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">

+ 1 - 1
src/frontend/js/app/users/list/item.ejs

@@ -25,7 +25,7 @@
         <%- r.join(', ') %>
     </div>
 </td>
-<td class="text-center">
+<td class="text-right">
     <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">

+ 12 - 8
src/frontend/js/i18n/messages.json

@@ -27,7 +27,8 @@
       "status": "Status",
       "online": "Online",
       "offline": "Offline",
-      "unknown": "Unknown"
+      "unknown": "Unknown",
+      "expires": "Expires"
     },
     "login": {
       "title": "Login to your account"
@@ -72,11 +73,12 @@
     },
     "ssl": {
       "letsencrypt": "Let's Encrypt",
-      "other": "Other",
+      "other": "Custom",
       "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>",
-      "delete-ssl": "The SSL certificates attached will be removed, this action cannot be recovered."
+      "delete-ssl": "The SSL certificates attached will NOT be removed, they will need to be removed manually.",
+      "hosts-warning": "These domains must be already configured to point to this installation"
     },
     "proxy-hosts": {
       "title": "Proxy Hosts",
@@ -132,11 +134,12 @@
       "help-content": "A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP traffic directly to another computer on the network.\nIf you're running game servers, FTP or SSH servers this can come in handy."
     },
     "certificates": {
-      "title": "Certificates",
-      "empty": "There are no Certificates",
-      "add": "Add Certificate",
-      "delete": "Delete Certificate",
-      "delete-confirm": "Are you sure you want to delete this certificate? Any hosts using it will need to be updated later.",
+      "title": "SSL Certificates",
+      "empty": "There are no SSL Certificates",
+      "add": "Add SSL Certificate",
+      "form-title": "Add {provider, select, letsencrypt{Let's Encrypt} other{Custom}} Certificate",
+      "delete": "Delete SSL Certificate",
+      "delete-confirm": "Are you sure you want to delete this SSL Certificate? Any hosts using it will need to be updated later.",
       "help-title": "SSL Certificates",
       "help-content": "TODO"
     },
@@ -185,6 +188,7 @@
       "dead-host": "404 Host",
       "stream": "Stream",
       "user": "User",
+      "certificate": "Certificate",
       "created": "Created {name}",
       "updated": "Updated {name}",
       "deleted": "Deleted {name}",

+ 20 - 4
src/frontend/js/models/certificate.js

@@ -7,12 +7,28 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
-            id:              0,
-            created_on:      null,
-            modified_on:     null,
+            id:                0,
+            created_on:        null,
+            modified_on:       null,
+            provider:          '',
+            nice_name:         '',
+            domain_names:      [],
+            expires_on:        null,
+            meta:              {},
             // The following are expansions:
-            owner:           null
+            owner:             null,
+            proxy_hosts:       [],
+            redirection_hosts: [],
+            dead_hosts:        []
         };
+    },
+
+    /**
+     * @returns {Boolean}
+     */
+    hasSslFiles: function () {
+        let meta = this.get('meta');
+        return typeof meta['certificate'] !== 'undefined' && meta['certificate'] && typeof meta['certificate_key'] !== 'undefined' && meta['certificate_key'];
     }
 });
 

+ 9 - 9
src/frontend/js/models/dead-host.js

@@ -7,16 +7,16 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
-            id:           0,
-            created_on:   null,
-            modified_on:  null,
-            domain_names: [],
-            ssl_enabled:  false,
-            ssl_provider: false,
-            ssl_forced:   false,
-            meta:         {},
+            id:             0,
+            created_on:     null,
+            modified_on:    null,
+            domain_names:   [],
+            certificate_id: 0,
+            ssl_forced:     false,
+            meta:           {},
             // The following are expansions:
-            owner:        null
+            owner:          null,
+            certificate:    null
         };
     }
 });

+ 3 - 12
src/frontend/js/models/proxy-host.js

@@ -14,25 +14,16 @@ const model = Backbone.Model.extend({
             forward_ip:      '',
             forward_port:    null,
             access_list_id:  0,
-            ssl_enabled:     false,
-            ssl_provider:    false,
+            certificate_id:  0,
             ssl_forced:      false,
             caching_enabled: false,
             block_exploits:  false,
             meta:            {},
             // The following are expansions:
             owner:           null,
-            access_list:     null
+            access_list:     null,
+            certificate:     null
         };
-    },
-
-    /**
-     * @param   {String}  type     'letsencrypt' or 'other'
-     * @returns {Boolean}
-     */
-    hasSslFiles: function (type) {
-        let meta = this.get('meta');
-        return typeof meta[type + '_certificate'] !== 'undefined' && meta[type + '_certificate'] && typeof meta[type + '_certificate_key'] !== 'undefined' && meta[type + '_certificate_key'];
     }
 });
 

+ 3 - 3
src/frontend/js/models/redirection-host.js

@@ -13,13 +13,13 @@ const model = Backbone.Model.extend({
             domain_names:        [],
             forward_domain_name: '',
             preserve_path:       true,
-            ssl_enabled:         false,
-            ssl_provider:        false,
+            certificate_id:      0,
             ssl_forced:          false,
             block_exploits:      false,
             meta:                {},
             // The following are expansions:
-            owner:               null
+            owner:               null,
+            certificate:         null
         };
     }
 });

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

@@ -10,13 +10,14 @@ const model = Backbone.Model.extend({
             id:              0,
             created_on:      null,
             modified_on:     null,
-            owner:           null,
             incoming_port:   null,
             forward_ip:      null,
             forwarding_port: null,
             tcp_forwarding:  true,
             udp_forwarding:  false,
-            meta:            {}
+            meta:            {},
+            // The following are expansions:
+            owner:           null
         };
     }
 });

+ 5 - 1
src/frontend/scss/custom.scss

@@ -22,4 +22,8 @@ a:hover {
 
 .min-100 {
     min-height: 100px;
-}
+}
+
+.card-options .dropdown-menu a:not(.btn) {
+    margin-left: 0;
+}