diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index 661950dc..8a4369bf 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -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']; @@ -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 diff --git a/backend/package.json b/backend/package.json index 2130c7b8..7d62b83b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/api/nginx/certificates.js b/backend/routes/api/nginx/certificates.js index 553a0bba..32995c53 100644 --- a/backend/routes/api/nginx/certificates.js +++ b/backend/routes/api/nginx/certificates.js @@ -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 * diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 9d11d268..2511a789 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -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', ''); @@ -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") } } }, diff --git a/frontend/js/app/nginx/certificates/list/item.ejs b/frontend/js/app/nginx/certificates/list/item.ejs index 87930dce..1a73605b 100644 --- a/frontend/js/app/nginx/certificates/list/item.ejs +++ b/frontend/js/app/nginx/certificates/list/item.ejs @@ -41,6 +41,7 @@ <%- i18n('audit-log', 'certificate') %> #<%- id %> <% if (provider === 'letsencrypt') { %> <%- i18n('certificates', 'force-renew') %> + <%- i18n('certificates', 'download') %> <% } %> <%- i18n('str', 'delete') %> diff --git a/frontend/js/app/nginx/certificates/list/item.js b/frontend/js/app/nginx/certificates/list/item.js index c967fdb8..ca167fae 100644 --- a/frontend/js/app/nginx/certificates/list/item.js +++ b/frontend/js/app/nginx/certificates/list/item.js @@ -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: { @@ -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')) } }, diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 6962a4db..9feb82d2 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -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": {