mirror of
https://github.com/xiaoxinpro/nginx-proxy-manager-zh.git
synced 2025-01-22 12:58:13 -05:00
Certificates polish
This commit is contained in:
parent
c8592503e3
commit
065727fba2
@ -5,9 +5,9 @@ const _ = require('lodash');
|
||||
const error = require('../lib/error');
|
||||
const certificateModel = require('../models/certificate');
|
||||
const internalAuditLog = require('./audit-log');
|
||||
const internalHost = require('./host');
|
||||
const tempWrite = require('temp-write');
|
||||
const utils = require('../lib/utils');
|
||||
const moment = require('moment');
|
||||
|
||||
function omissions () {
|
||||
return ['is_deleted'];
|
||||
@ -15,6 +15,8 @@ function omissions () {
|
||||
|
||||
const internalCertificate = {
|
||||
|
||||
allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'],
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
@ -57,8 +59,39 @@ const internalCertificate = {
|
||||
update: (access, data) => {
|
||||
return access.can('certificates:update', data.id)
|
||||
.then(access_data => {
|
||||
// TODO
|
||||
return {};
|
||||
return internalCertificate.get(access, {id: data.id});
|
||||
})
|
||||
.then(row => {
|
||||
if (row.id !== data.id) {
|
||||
// Sanity check that something crazy hasn't happened
|
||||
throw new error.InternalValidationError('Certificate could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
|
||||
}
|
||||
|
||||
return certificateModel
|
||||
.query()
|
||||
.omit(omissions())
|
||||
.patchAndFetchById(row.id, data)
|
||||
.debug()
|
||||
.then(saved_row => {
|
||||
saved_row.meta = internalCertificate.cleanMeta(saved_row.meta);
|
||||
data.meta = internalCertificate.cleanMeta(data.meta);
|
||||
|
||||
// Add row.nice_name for custom certs
|
||||
if (saved_row.provider === 'other') {
|
||||
data.nice_name = saved_row.nice_name;
|
||||
}
|
||||
|
||||
// Add to audit log
|
||||
return internalAuditLog.add(access, {
|
||||
action: 'updated',
|
||||
object_type: 'certificate',
|
||||
object_id: row.id,
|
||||
meta: _.omit(data, ['expires_on']) // this prevents json circular reference because expires_on might be raw
|
||||
})
|
||||
.then(() => {
|
||||
return _.omit(saved_row, omissions());
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@ -113,10 +146,10 @@ const internalCertificate = {
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {Integer} data.id
|
||||
* @param {String} [data.reason]
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {Integer} data.id
|
||||
* @param {String} [data.reason]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
delete: (access, data) => {
|
||||
@ -134,6 +167,17 @@ const internalCertificate = {
|
||||
.where('id', row.id)
|
||||
.patch({
|
||||
is_deleted: 1
|
||||
})
|
||||
.then(() => {
|
||||
// Add to audit log
|
||||
row.meta = internalCertificate.cleanMeta(row.meta);
|
||||
|
||||
return internalAuditLog.add(access, {
|
||||
action: 'deleted',
|
||||
object_type: 'certificate',
|
||||
object_id: row.id,
|
||||
meta: _.omit(row, omissions())
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@ -204,19 +248,18 @@ const internalCertificate = {
|
||||
|
||||
/**
|
||||
* Validates that the certs provided are good.
|
||||
* This is probably a horrible way to do this.
|
||||
* No access required here, nothing is changed or stored.
|
||||
*
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {Object} data.files
|
||||
* @returns {Promise}
|
||||
*/
|
||||
validate: (access, data) => {
|
||||
validate: data => {
|
||||
return new Promise(resolve => {
|
||||
// Put file contents into an object
|
||||
let files = {};
|
||||
_.map(data.files, (file, name) => {
|
||||
if (internalHost.allowed_ssl_files.indexOf(name) !== -1) {
|
||||
if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
|
||||
files[name] = file.data.toString();
|
||||
}
|
||||
});
|
||||
@ -228,56 +271,26 @@ const internalCertificate = {
|
||||
// Then test it depending on the file type
|
||||
let promises = [];
|
||||
_.map(files, (content, type) => {
|
||||
promises.push(tempWrite(content, '/tmp')
|
||||
.then(filepath => {
|
||||
if (type === 'certificate_key') {
|
||||
return utils.exec('openssl rsa -in ' + filepath + ' -check')
|
||||
.then(result => {
|
||||
return {tmp: filepath, result: result.split("\n").shift()};
|
||||
}).catch(err => {
|
||||
return {tmp: filepath, result: false, err: new error.ValidationError('Certificate Key is not valid')};
|
||||
});
|
||||
|
||||
} else if (type === 'certificate') {
|
||||
return utils.exec('openssl x509 -in ' + filepath + ' -text -noout')
|
||||
.then(result => {
|
||||
return {tmp: filepath, result: result};
|
||||
}).catch(err => {
|
||||
return {tmp: filepath, result: false, err: new error.ValidationError('Certificate is not valid')};
|
||||
});
|
||||
} else {
|
||||
return {tmp: filepath, result: false};
|
||||
}
|
||||
})
|
||||
.then(file_result => {
|
||||
// Remove temp files
|
||||
fs.unlinkSync(file_result.tmp);
|
||||
delete file_result.tmp;
|
||||
|
||||
return {[type]: file_result};
|
||||
})
|
||||
);
|
||||
promises.push(new Promise((resolve, reject) => {
|
||||
if (type === 'certificate_key') {
|
||||
resolve(internalCertificate.checkPrivateKey(content));
|
||||
} else {
|
||||
// this should handle `certificate` and intermediate certificate
|
||||
resolve(internalCertificate.getCertificateInfo(content, true));
|
||||
}
|
||||
}).then(res => {
|
||||
return {[type]: res};
|
||||
}));
|
||||
});
|
||||
|
||||
// With the results, delete the temp files for security mainly.
|
||||
// If there was an error with any of them, wait until we've done the deleting
|
||||
// before throwing it.
|
||||
return Promise.all(promises)
|
||||
.then(files => {
|
||||
let data = {};
|
||||
let err = null;
|
||||
|
||||
_.each(files, file => {
|
||||
data = _.assign({}, data, file);
|
||||
if (typeof file.err !== 'undefined' && file.err) {
|
||||
err = file.err;
|
||||
}
|
||||
});
|
||||
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
});
|
||||
@ -297,28 +310,159 @@ const internalCertificate = {
|
||||
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.validate(data)
|
||||
.then(validations => {
|
||||
if (typeof validations.certificate === 'undefined') {
|
||||
throw new error.ValidationError('Certificate file was not provided');
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
_.map(data.files, (file, name) => {
|
||||
if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
|
||||
row.meta[name] = file.data.toString();
|
||||
}
|
||||
});
|
||||
|
||||
return internalCertificate.update(access, {
|
||||
id: data.id,
|
||||
expires_on: certificateModel.raw('FROM_UNIXTIME(' + validations.certificate.dates.to + ')'),
|
||||
domain_names: [validations.certificate.cn],
|
||||
meta: row.meta
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return _.pick(row.meta, internalHost.allowed_ssl_files);
|
||||
return _.pick(row.meta, internalCertificate.allowed_ssl_files);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Uses the openssl command to validate the private key.
|
||||
* It will save the file to disk first, then run commands on it, then delete the file.
|
||||
*
|
||||
* @param {String} private_key This is the entire key contents as a string
|
||||
*/
|
||||
checkPrivateKey: private_key => {
|
||||
return tempWrite(private_key, '/tmp')
|
||||
.then(filepath => {
|
||||
return utils.exec('openssl rsa -in ' + filepath + ' -check -noout')
|
||||
.then(result => {
|
||||
if (!result.toLowerCase().includes('key ok')) {
|
||||
throw new error.ValidationError(result);
|
||||
}
|
||||
|
||||
fs.unlinkSync(filepath);
|
||||
return true;
|
||||
}).catch(err => {
|
||||
fs.unlinkSync(filepath);
|
||||
throw new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Uses the openssl command to both validate and get info out of the certificate.
|
||||
* It will save the file to disk first, then run commands on it, then delete the file.
|
||||
*
|
||||
* @param {String} certificate This is the entire cert contents as a string
|
||||
* @param {Boolean} [throw_expired] Throw when the certificate is out of date
|
||||
*/
|
||||
getCertificateInfo: (certificate, throw_expired) => {
|
||||
return tempWrite(certificate, '/tmp')
|
||||
.then(filepath => {
|
||||
let cert_data = {};
|
||||
|
||||
return utils.exec('openssl x509 -in ' + filepath + ' -subject -noout')
|
||||
.then(result => {
|
||||
// subject=CN = something.example.com
|
||||
let regex = /(?:subject=)?[^=]+=\s+(\S+)/gim;
|
||||
let match = regex.exec(result);
|
||||
|
||||
if (typeof match[1] === 'undefined') {
|
||||
throw new error.ValidationError('Could not determine subject from certificate: ' + result);
|
||||
}
|
||||
|
||||
cert_data['cn'] = match[1];
|
||||
})
|
||||
.then(() => {
|
||||
return utils.exec('openssl x509 -in ' + filepath + ' -issuer -noout');
|
||||
})
|
||||
.then(result => {
|
||||
// issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
|
||||
let regex = /^(?:issuer=)?(.*)$/gim;
|
||||
let match = regex.exec(result);
|
||||
|
||||
if (typeof match[1] === 'undefined') {
|
||||
throw new error.ValidationError('Could not determine issuer from certificate: ' + result);
|
||||
}
|
||||
|
||||
cert_data['issuer'] = match[1];
|
||||
})
|
||||
.then(() => {
|
||||
return utils.exec('openssl x509 -in ' + filepath + ' -dates -noout');
|
||||
})
|
||||
.then(result => {
|
||||
// notBefore=Jul 14 04:04:29 2018 GMT
|
||||
// notAfter=Oct 12 04:04:29 2018 GMT
|
||||
let valid_from = null;
|
||||
let valid_to = null;
|
||||
|
||||
let lines = result.split('\n');
|
||||
lines.map(function (str) {
|
||||
let regex = /^(\S+)=(.*)$/gim;
|
||||
let match = regex.exec(str.trim());
|
||||
|
||||
if (match && typeof match[2] !== 'undefined') {
|
||||
let date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10);
|
||||
|
||||
if (match[1].toLowerCase() === 'notbefore') {
|
||||
valid_from = date;
|
||||
} else if (match[1].toLowerCase() === 'notafter') {
|
||||
valid_to = date;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!valid_from || !valid_to) {
|
||||
throw new error.ValidationError('Could not determine dates from certificate: ' + result);
|
||||
}
|
||||
|
||||
if (throw_expired && valid_to < parseInt(moment().format('X'), 10)) {
|
||||
throw new error.ValidationError('Certificate has expired');
|
||||
}
|
||||
|
||||
cert_data['dates'] = {
|
||||
from: valid_from,
|
||||
to: valid_to
|
||||
};
|
||||
})
|
||||
.then(() => {
|
||||
fs.unlinkSync(filepath);
|
||||
return cert_data;
|
||||
}).catch(err => {
|
||||
fs.unlinkSync(filepath);
|
||||
throw new error.ValidationError('Certificate is not valid (' + err.message + ')', err);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans the ssl keys from the meta object and sets them to "true"
|
||||
*
|
||||
* @param {Object} meta
|
||||
* @param {Boolean} [remove]
|
||||
* @returns {Object}
|
||||
*/
|
||||
cleanMeta: function (meta, remove) {
|
||||
internalCertificate.allowed_ssl_files.map(key => {
|
||||
if (typeof meta[key] !== 'undefined' && meta[key]) {
|
||||
if (remove) {
|
||||
delete meta[key];
|
||||
} else {
|
||||
meta[key] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return meta;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1,15 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('lodash');
|
||||
const error = require('../lib/error');
|
||||
const proxyHostModel = require('../models/proxy_host');
|
||||
const redirectionHostModel = require('../models/redirection_host');
|
||||
const deadHostModel = require('../models/dead_host');
|
||||
|
||||
const internalHost = {
|
||||
|
||||
allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'],
|
||||
|
||||
/**
|
||||
* Internal use only, checks to see if the domain is already taken by any other record
|
||||
*
|
||||
@ -66,21 +62,6 @@ const internalHost = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans the ssl keys from the meta object and sets them to "true"
|
||||
*
|
||||
* @param {Object} meta
|
||||
* @returns {*}
|
||||
*/
|
||||
cleanMeta: function (meta) {
|
||||
internalHost.allowed_ssl_files.map(key => {
|
||||
if (typeof meta[key] !== 'undefined' && meta[key]) {
|
||||
meta[key] = true;
|
||||
}
|
||||
});
|
||||
return meta;
|
||||
},
|
||||
|
||||
/**
|
||||
* Private call only
|
||||
*
|
||||
|
@ -203,7 +203,7 @@ router
|
||||
res.status(400)
|
||||
.send({error: 'No files were uploaded'});
|
||||
} else {
|
||||
internalCertificate.validate(res.locals.access, {
|
||||
internalCertificate.validate({
|
||||
files: req.files
|
||||
})
|
||||
.then(result => {
|
||||
|
@ -47,7 +47,7 @@
|
||||
items.push(meta.name);
|
||||
break;
|
||||
case 'certificate':
|
||||
%> <span class="text-teal"><i class="fe fe-shield"></i></span> <%
|
||||
%> <span class="text-pink"><i class="fe fe-shield"></i></span> <%
|
||||
if (meta.provider === 'letsencrypt') {
|
||||
items = meta.domain_names;
|
||||
} else {
|
||||
|
18
src/frontend/js/app/nginx/certificates-list-item.ejs
Normal file
18
src/frontend/js/app/nginx/certificates-list-item.ejs
Normal file
@ -0,0 +1,18 @@
|
||||
<div>
|
||||
<% if (id === 'new') { %>
|
||||
<div class="title">
|
||||
<i class="fe fe-shield text-success"></i> Request a new SSL Certificate
|
||||
</div>
|
||||
<span class="description">with Let's Encrypt</span>
|
||||
<% } else if (id > 0) { %>
|
||||
<div class="title">
|
||||
<i class="fe fe-shield text-pink"></i> <%- provider === 'other' ? nice_name : domain_names.join(', ') %>
|
||||
</div>
|
||||
<span class="description"><%- i18n('ssl', provider) %> – Expires: <%- formatDbDate(expires_on, 'Do MMMM YYYY, h:mm a') %></span>
|
||||
<% } else { %>
|
||||
<div class="title">
|
||||
<i class="fe fe-shield-off text-danger"></i> None
|
||||
</div>
|
||||
<span class="description">This host will not use HTTPS</span>
|
||||
<% } %>
|
||||
</div>
|
@ -1,12 +1,12 @@
|
||||
<div class="card">
|
||||
<div class="card-status bg-teal"></div>
|
||||
<div class="card-status bg-pink"></div>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><%- i18n('certificates', 'title') %></h3>
|
||||
<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) { %>
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-outline-teal btn-sm ml-2 dropdown-toggle" data-toggle="dropdown">
|
||||
<button type="button" class="btn btn-outline-pink btn-sm ml-2 dropdown-toggle" data-toggle="dropdown">
|
||||
<%- i18n('certificates', 'add') %>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
|
@ -57,7 +57,7 @@ module.exports = Mn.View.extend({
|
||||
title: App.i18n('certificates', 'empty'),
|
||||
subtitle: App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
|
||||
link: manage ? App.i18n('certificates', 'add') : null,
|
||||
btn_color: 'teal',
|
||||
btn_color: 'pink',
|
||||
permission: 'certificates',
|
||||
action: function () {
|
||||
App.Controller.showNginxCertificateForm();
|
||||
|
@ -52,7 +52,7 @@
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-12">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Access List</label>
|
||||
<label class="form-label"><%- i18n('proxy-hosts', 'access-list') %></label>
|
||||
<select name="access_list_id" class="form-control custom-select">
|
||||
<option value="0" selected="selected"><%- i18n('access-lists', 'public') %></option>
|
||||
</select>
|
||||
@ -64,76 +64,41 @@
|
||||
<!-- SSL -->
|
||||
<div role="tabpanel" class="tab-pane" id="ssl-options">
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-md-6">
|
||||
<div class="col-sm-12 col-md-12">
|
||||
<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>
|
||||
<label class="form-label">SSL Certificate</label>
|
||||
<select name="certificate_id" class="form-control custom-select" placeholder="None">
|
||||
<option selected value="0" data-data="{"id":0}" <%- certificate_id ? '' : 'selected' %>>None</option>
|
||||
<option selected value="new" data-data="{"id":"new"}">Request a new SSL Certificate</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-md-6">
|
||||
<div class="col-sm-12 col-md-12">
|
||||
<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' %>>
|
||||
<input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- certificate_id ? '' : ' 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="col-sm-12 col-md-12 letsencrypt">
|
||||
<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="col-sm-12 col-md-12 letsencrypt">
|
||||
<div class="form-group">
|
||||
<label class="custom-switch">
|
||||
<input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required<%- getLetsencryptAgree() ? ' checked' : '' %>>
|
||||
<input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required>
|
||||
<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>
|
||||
|
@ -1,10 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('underscore');
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../../main');
|
||||
const ProxyHostModel = require('../../../models/proxy-host');
|
||||
const template = require('./form.ejs');
|
||||
const Mn = require('backbone.marionette');
|
||||
const App = require('../../main');
|
||||
const ProxyHostModel = require('../../../models/proxy-host');
|
||||
const template = require('./form.ejs');
|
||||
const certListItemTemplate = require('../certificates-list-item.ejs');
|
||||
const Helpers = require('../../../lib/helpers');
|
||||
|
||||
require('jquery-serializejson');
|
||||
require('jquery-mask-plugin');
|
||||
@ -16,36 +17,28 @@ module.exports = Mn.View.extend({
|
||||
max_file_size: 5120,
|
||||
|
||||
ui: {
|
||||
form: 'form',
|
||||
domain_names: 'input[name="domain_names"]',
|
||||
forward_ip: 'input[name="forward_ip"]',
|
||||
buttons: '.modal-footer button',
|
||||
cancel: 'button.cancel',
|
||||
save: 'button.save',
|
||||
ssl_enabled: 'input[name="ssl_enabled"]',
|
||||
ssl_options: '#ssl-options input',
|
||||
ssl_provider: 'input[name="ssl_provider"]',
|
||||
other_ssl_certificate: '#other_ssl_certificate',
|
||||
other_ssl_certificate_key: '#other_ssl_certificate_key',
|
||||
|
||||
// SSL hiding and showing
|
||||
all_ssl: '.letsencrypt-ssl, .other-ssl',
|
||||
letsencrypt_ssl: '.letsencrypt-ssl',
|
||||
other_ssl: '.other-ssl'
|
||||
form: 'form',
|
||||
domain_names: 'input[name="domain_names"]',
|
||||
forward_ip: 'input[name="forward_ip"]',
|
||||
buttons: '.modal-footer button',
|
||||
cancel: 'button.cancel',
|
||||
save: 'button.save',
|
||||
certificate_select: 'select[name="certificate_id"]',
|
||||
ssl_options: '#ssl-options input',
|
||||
letsencrypt: '.letsencrypt'
|
||||
},
|
||||
|
||||
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.certificate_select': function () {
|
||||
let id = this.ui.certificate_select.val();
|
||||
if (id === 'new') {
|
||||
this.ui.letsencrypt.show().find('input').prop('disabled', false);
|
||||
} else {
|
||||
this.ui.letsencrypt.hide().find('input').prop('disabled', true);
|
||||
}
|
||||
|
||||
'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);
|
||||
let enabled = id === 'new' || parseInt(id, 10) > 0;
|
||||
this.ui.ssl_options.prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5);
|
||||
},
|
||||
|
||||
'click @ui.save': function (e) {
|
||||
@ -63,23 +56,30 @@ module.exports = Mn.View.extend({
|
||||
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(',');
|
||||
}
|
||||
|
||||
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;
|
||||
// Check for any domain names containing wildcards, which are not allowed with letsencrypt
|
||||
if (data.certificate_id === 'new') {
|
||||
let domain_err = false;
|
||||
data.domain_names.map(function(name) {
|
||||
if (name.match(/\*/im)) {
|
||||
domain_err = true;
|
||||
}
|
||||
});
|
||||
|
||||
let must_require_ssl_files = require_ssl_files && !view.model.hasSslFiles('other');
|
||||
if (domain_err) {
|
||||
alert('Cannot request Let\'s Encrypt Certificate for wildcard domains');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
data.certificate_id = parseInt(data.certificate_id, 0);
|
||||
}
|
||||
|
||||
let method = App.Api.Nginx.ProxyHosts.create;
|
||||
let is_new = true;
|
||||
|
||||
if (this.model.get('id')) {
|
||||
// edit
|
||||
@ -88,55 +88,11 @@ module.exports = Mn.View.extend({
|
||||
data.id = this.model.get('id');
|
||||
}
|
||||
|
||||
// check files are attached
|
||||
if (require_ssl_files) {
|
||||
if (!this.ui.other_ssl_certificate[0].files.length || !this.ui.other_ssl_certificate[0].files[0].size) {
|
||||
if (must_require_ssl_files) {
|
||||
alert('certificate file is not attached');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (this.ui.other_ssl_certificate[0].files[0].size > this.max_file_size) {
|
||||
alert('certificate file is too large (> 5kb)');
|
||||
return;
|
||||
}
|
||||
ssl_files.push({name: 'other_certificate', file: this.ui.other_ssl_certificate[0].files[0]});
|
||||
}
|
||||
|
||||
if (!this.ui.other_ssl_certificate_key[0].files.length || !this.ui.other_ssl_certificate_key[0].files[0].size) {
|
||||
if (must_require_ssl_files) {
|
||||
alert('certificate key file is not attached');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (this.ui.other_ssl_certificate_key[0].files[0].size > this.max_file_size) {
|
||||
alert('certificate key file is too large (> 5kb)');
|
||||
return;
|
||||
}
|
||||
ssl_files.push({name: 'other_certificate_key', file: this.ui.other_ssl_certificate_key[0].files[0]});
|
||||
}
|
||||
}
|
||||
|
||||
this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
|
||||
method(data)
|
||||
.then(result => {
|
||||
view.model.set(result);
|
||||
|
||||
// Now upload the certs if we need to
|
||||
if (ssl_files.length) {
|
||||
let form_data = new FormData();
|
||||
|
||||
ssl_files.map(function (file) {
|
||||
form_data.append(file.name, file.file);
|
||||
});
|
||||
|
||||
return App.Api.Nginx.ProxyHosts.setCerts(view.model.get('id'), form_data)
|
||||
.then(result => {
|
||||
view.model.set('meta', _.assign({}, view.model.get('meta'), result));
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
App.UI.closeModal(function () {
|
||||
if (is_new) {
|
||||
App.Controller.showNginxProxy();
|
||||
@ -152,23 +108,20 @@ module.exports = Mn.View.extend({
|
||||
|
||||
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;
|
||||
return App.Cache.User.get('email');
|
||||
}
|
||||
},
|
||||
|
||||
onRender: function () {
|
||||
let view = this;
|
||||
|
||||
// IP Address
|
||||
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');
|
||||
|
||||
// Domain names
|
||||
this.ui.domain_names.selectize({
|
||||
delimiter: ',',
|
||||
persist: false,
|
||||
@ -181,6 +134,37 @@ module.exports = Mn.View.extend({
|
||||
},
|
||||
createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
|
||||
});
|
||||
|
||||
// Certificates
|
||||
this.ui.letsencrypt.hide();
|
||||
this.ui.certificate_select.selectize({
|
||||
valueField: 'id',
|
||||
labelField: 'nice_name',
|
||||
searchField: ['nice_name', 'domain_names'],
|
||||
create: false,
|
||||
preload: true,
|
||||
allowEmptyOption: true,
|
||||
render: {
|
||||
option: function (item) {
|
||||
item.i18n = App.i18n;
|
||||
item.formatDbDate = Helpers.formatDbDate;
|
||||
return certListItemTemplate(item);
|
||||
}
|
||||
},
|
||||
load: function (query, callback) {
|
||||
App.Api.Nginx.Certificates.getAll()
|
||||
.then(rows => {
|
||||
callback(rows);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
callback();
|
||||
});
|
||||
},
|
||||
onLoad: function () {
|
||||
view.ui.certificate_select[0].selectize.setValue(view.model.get('certificate_id'));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
initialize: function (options) {
|
||||
|
@ -88,7 +88,8 @@
|
||||
"delete": "Delete Proxy Host",
|
||||
"delete-confirm": "Are you sure you want to delete the Proxy host for: <strong>{domains}</strong>?",
|
||||
"help-title": "What is a Proxy Host?",
|
||||
"help-content": "A Proxy Host is the incoming endpoint for a web service that you want to forward.\nIt provides optional SSL termination for your service that might not have SSL support built in.\nProxy Hosts are the most common use for the Nginx Proxy Manager."
|
||||
"help-content": "A Proxy Host is the incoming endpoint for a web service that you want to forward.\nIt provides optional SSL termination for your service that might not have SSL support built in.\nProxy Hosts are the most common use for the Nginx Proxy Manager.",
|
||||
"access-list": "Access List"
|
||||
},
|
||||
"redirection-hosts": {
|
||||
"title": "Redirection Hosts",
|
||||
|
@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const numeral = require('numeral');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = {
|
||||
|
||||
@ -10,5 +11,18 @@ module.exports = {
|
||||
*/
|
||||
niceNumber: function (number) {
|
||||
return numeral(number).format('0,0');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {String|Number} date
|
||||
* @param {String} format
|
||||
* @returns {String}
|
||||
*/
|
||||
formatDbDate: function (date, format) {
|
||||
if (typeof date === 'number') {
|
||||
return moment.unix(date).format(format);
|
||||
}
|
||||
|
||||
return moment(date).format(format);
|
||||
}
|
||||
};
|
||||
|
@ -1,30 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('underscore');
|
||||
const Mn = require('backbone.marionette');
|
||||
const moment = require('moment');
|
||||
const i18n = require('../app/i18n');
|
||||
const _ = require('underscore');
|
||||
const Mn = require('backbone.marionette');
|
||||
const i18n = require('../app/i18n');
|
||||
const Helpers = require('./helpers');
|
||||
|
||||
let render = Mn.Renderer.render;
|
||||
|
||||
Mn.Renderer.render = function (template, data, view) {
|
||||
|
||||
data = _.clone(data);
|
||||
|
||||
data.i18n = i18n;
|
||||
|
||||
/**
|
||||
* @param {String} date
|
||||
* @param {String} format
|
||||
* @returns {String}
|
||||
*/
|
||||
data.formatDbDate = function (date, format) {
|
||||
if (typeof date === 'number') {
|
||||
return moment.unix(date).format(format);
|
||||
}
|
||||
|
||||
return moment(date).format(format);
|
||||
};
|
||||
data = _.clone(data);
|
||||
data.i18n = i18n;
|
||||
data.formatDbDate = Helpers.formatDbDate;
|
||||
|
||||
return render.call(this, template, data, view);
|
||||
};
|
||||
|
192
src/frontend/scss/selectize.scss
Normal file
192
src/frontend/scss/selectize.scss
Normal file
@ -0,0 +1,192 @@
|
||||
.selectize-dropdown-header {
|
||||
position: relative;
|
||||
padding: 5px 8px;
|
||||
background: #f8f8f8;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
-webkit-border-radius: 3px 3px 0 0;
|
||||
-moz-border-radius: 3px 3px 0 0;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.selectize-dropdown-header-close {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
margin-top: -12px;
|
||||
font-size: 20px !important;
|
||||
line-height: 20px;
|
||||
color: #303030;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.selectize-dropdown-header-close:hover {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.selectize-dropdown.plugin-optgroup_columns .optgroup {
|
||||
float: left;
|
||||
border-top: 0 none;
|
||||
border-right: 1px solid #f2f2f2;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child {
|
||||
border-right: 0 none;
|
||||
}
|
||||
|
||||
.selectize-dropdown.plugin-optgroup_columns .optgroup:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selectize-dropdown.plugin-optgroup_columns .optgroup-header {
|
||||
border-top: 0 none;
|
||||
}
|
||||
|
||||
.selectize-control.plugin-remove_button [data-value] {
|
||||
position: relative;
|
||||
padding-right: 24px !important;
|
||||
}
|
||||
|
||||
.selectize-control.plugin-remove_button [data-value] .remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
padding: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
border-left: 1px solid #0073bb;
|
||||
-webkit-border-radius: 0 2px 2px 0;
|
||||
-moz-border-radius: 0 2px 2px 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selectize-control.plugin-remove_button [data-value] .remove:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.selectize-control.plugin-remove_button [data-value].active .remove {
|
||||
border-left-color: #00578d;
|
||||
}
|
||||
|
||||
.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.selectize-control.plugin-remove_button .disabled [data-value] .remove {
|
||||
border-left-color: #aaaaaa;
|
||||
}
|
||||
|
||||
.selectize-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selectize-dropdown {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: inherit;
|
||||
line-height: 18px;
|
||||
color: #303030;
|
||||
}
|
||||
|
||||
.selectize-control.single {
|
||||
display: inline-block;
|
||||
cursor: text;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.selectize-dropdown {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
margin: -1px 0 0 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-top: 0 none;
|
||||
-webkit-border-radius: 0 0 3px 3px;
|
||||
-moz-border-radius: 0 0 3px 3px;
|
||||
border-radius: 0 0 3px 3px;
|
||||
-webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selectize-dropdown [data-selectable] {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectize-dropdown [data-selectable] .highlight {
|
||||
background: rgba(125, 168, 208, 0.2);
|
||||
-webkit-border-radius: 1px;
|
||||
-moz-border-radius: 1px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.selectize-dropdown [data-selectable],
|
||||
.selectize-dropdown .optgroup-header {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.selectize-dropdown .optgroup:first-child .optgroup-header {
|
||||
border-top: 0 none;
|
||||
}
|
||||
|
||||
.selectize-dropdown .optgroup-header {
|
||||
color: #303030;
|
||||
cursor: default;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.selectize-dropdown .active {
|
||||
color: #495c68;
|
||||
background-color: #f5fafd;
|
||||
}
|
||||
|
||||
.selectize-dropdown .active.create {
|
||||
color: #495c68;
|
||||
}
|
||||
|
||||
.selectize-dropdown .create {
|
||||
color: rgba(48, 48, 48, 0.5);
|
||||
}
|
||||
|
||||
.selectize-dropdown-content {
|
||||
max-height: 200px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectize-dropdown .optgroup-header {
|
||||
padding-top: 7px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.selectize-dropdown .optgroup {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.selectize-dropdown .optgroup:first-child {
|
||||
border-top: 0 none;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
@import "~tabler-ui/dist/assets/css/dashboard";
|
||||
@import "tabler-extra";
|
||||
@import "selectize";
|
||||
@import "custom";
|
||||
|
||||
/* Before any JS content is loaded */
|
||||
|
@ -1,6 +1,7 @@
|
||||
$teal: #2bcbba;
|
||||
$yellow: #f1c40f;
|
||||
$blue: #467fcf;
|
||||
$pink: #f66d9b;
|
||||
|
||||
/* For Card bodies where I don't want padding */
|
||||
.card-body.no-padding {
|
||||
@ -67,6 +68,26 @@ $blue: #467fcf;
|
||||
border-color: $blue;
|
||||
}
|
||||
|
||||
/* Pink Outline Buttons */
|
||||
.btn-outline-pink {
|
||||
color: $pink;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
border-color: $pink;
|
||||
}
|
||||
|
||||
.btn-outline-pink:hover {
|
||||
color: #fff;
|
||||
background-color: $pink;
|
||||
border-color: $pink;
|
||||
}
|
||||
|
||||
.btn-outline-pink:not(:disabled):not(.disabled):active, .btn-outline-pink:not(:disabled):not(.disabled).active, .show > .btn-outline-pink.dropdown-toggle {
|
||||
color: #fff;
|
||||
background-color: $pink;
|
||||
border-color: $pink;
|
||||
}
|
||||
|
||||
/* dimmer */
|
||||
|
||||
.dimmer .loader {
|
||||
|
Loading…
Reference in New Issue
Block a user