Skip to content

Added support to download Let's Encrypt Certificate #1343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Sep 2, 2021
67 changes: 67 additions & 0 deletions backend/internal/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const internalHost = require('./host');
const letsencryptStaging = process.env.NODE_ENV !== 'production';
const letsencryptConfig = '/etc/letsencrypt.ini';
const certbotCommand = 'certbot';
const archiver = require('archiver');
const path = require('path');

function omissions() {
return ['is_deleted'];
Expand Down Expand Up @@ -335,6 +337,71 @@ const internalCertificate = {
});
},

/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @returns {Promise}
*/
download: (access, data) => {
return new Promise((resolve, reject) => {
access.can('certificates:get', data)
.then(() => {
return internalCertificate.get(access, data);
})
.then((certificate) => {
if (certificate.provider === 'letsencrypt') {
const zipDirectory = '/etc/letsencrypt/live/npm-' + data.id;

if (!fs.existsSync(zipDirectory)) {
throw new error.ItemNotFoundError('Certificate ' + certificate.nice_name + ' does not exists');
}

let certFiles = fs.readdirSync(zipDirectory)
.filter((fn) => fn.endsWith('.pem'))
.map((fn) => fs.realpathSync(path.join(zipDirectory, fn)));
const downloadName = 'npm-' + data.id + '-' + `${Date.now()}.zip`;
const opName = '/tmp/' + downloadName;
internalCertificate.zipFiles(certFiles, opName)
.then(() => {
logger.debug('zip completed : ', opName);
const resp = {
fileName: opName
};
resolve(resp);
}).catch((err) => reject(err));
} else {
throw new error.ValidationError('Only Let\'sEncrypt certificates can be downloaded');
}
}).catch((err) => reject(err));
});
},

/**
* @param {String} source
* @param {String} out
* @returns {Promise}
*/
zipFiles(source, out) {
const archive = archiver('zip', { zlib: { level: 9 } });
const stream = fs.createWriteStream(out);

return new Promise((resolve, reject) => {
source
.map((fl) => {
let fileName = path.basename(fl);
logger.debug(fl, 'added to certificate zip');
archive.file(fl, { name: fileName });
});
archive
.on('error', (err) => reject(err))
.pipe(stream);

stream.on('close', () => resolve());
archive.finalize();
});
},

/**
* @param {Access} access
* @param {Object} data
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"main": "js/index.js",
"dependencies": {
"ajv": "^6.12.0",
"archiver": "^5.3.0",
"batchflow": "^0.4.0",
"bcrypt": "^5.0.0",
"body-parser": "^1.19.0",
Expand Down
29 changes: 29 additions & 0 deletions backend/routes/api/nginx/certificates.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,35 @@ router
.catch(next);
});


/**
* Download LE Certs
*
* /api/nginx/certificates/123/download
*/
router
.route('/:certificate_id/download')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode())

/**
* GET /api/nginx/certificates/123/download
*
* Renew certificate
*/
.get((req, res, next) => {
internalCertificate.download(res.locals.access, {
id: parseInt(req.params.certificate_id, 10)
})
.then((result) => {
res.status(200)
.download(result.fileName);
})
.catch(next);
});

/**
* Validate Certs before saving
*
Expand Down
53 changes: 53 additions & 0 deletions frontend/js/app/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,51 @@ function FileUpload(path, fd) {
});
}

//ref : https://codepen.io/chrisdpratt/pen/RKxJNo
function DownloadFile(verb, path, filename) {
return new Promise(function (resolve, reject) {
let api_url = '/api/';
let url = api_url + path;
let token = Tokens.getTopToken();

$.ajax({
url: url,
type: verb,
crossDomain: true,
xhrFields: {
withCredentials: true,
responseType: 'blob'
},

beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null));
},

success: function (data) {
var a = document.createElement('a');
var url = window.___URL.createObjectURL(data);
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
window.___URL.revokeObjectURL(url);
},

error: function (xhr, status, error_thrown) {
let code = 400;

if (typeof xhr.responseJSON !== 'undefined' && typeof xhr.responseJSON.error !== 'undefined' && typeof xhr.responseJSON.error.message !== 'undefined') {
error_thrown = xhr.responseJSON.error.message;
code = xhr.responseJSON.error.code || 500;
}

reject(new ApiError(error_thrown, xhr.responseText, code));
}
});
});
}

module.exports = {
status: function () {
return fetch('get', '');
Expand Down Expand Up @@ -638,6 +683,14 @@ module.exports = {
*/
renew: function (id, timeout = 180000) {
return fetch('post', 'nginx/certificates/' + id + '/renew', undefined, {timeout});
},

/**
* @param {Number} id
* @returns {Promise}
*/
download: function (id) {
return DownloadFile('get', "nginx/certificates/" + id + "/download", "certificate.zip")
}
}
},
Expand Down
1 change: 1 addition & 0 deletions frontend/js/app/nginx/certificates/list/item.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<span class="dropdown-header"><%- i18n('audit-log', 'certificate') %> #<%- id %></span>
<% if (provider === 'letsencrypt') { %>
<a href="#" class="renew dropdown-item"><i class="dropdown-icon fe fe-refresh-cw"></i> <%- i18n('certificates', 'force-renew') %></a>
<a href="#" class="download dropdown-item"><i class="dropdown-icon fe fe-download"></i> <%- i18n('certificates', 'download') %></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>
Expand Down
8 changes: 7 additions & 1 deletion frontend/js/app/nginx/certificates/list/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ module.exports = Mn.View.extend({
ui: {
host_link: '.host-link',
renew: 'a.renew',
delete: 'a.delete'
delete: 'a.delete',
download: 'a.download'
},

events: {
Expand All @@ -29,6 +30,11 @@ module.exports = Mn.View.extend({
e.preventDefault();
let win = window.open($(e.currentTarget).attr('rel'), '_blank');
win.focus();
},

'click @ui.download': function (e) {
e.preventDefault();
App.Api.Nginx.Certificates.download(this.model.get('id'))
}
},

Expand Down
1 change: 1 addition & 0 deletions frontend/js/i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
"other-certificate-key": "Certificate Key",
"other-intermediate-certificate": "Intermediate Certificate",
"force-renew": "Renew Now",
"download": "Download",
"renew-title": "Renew Let'sEncrypt Certificate"
},
"access-lists": {
Expand Down