diff --git a/dashboard/public/js/client.js b/dashboard/public/js/client.js index ab897d013..0a67213ce 100644 --- a/dashboard/public/js/client.js +++ b/dashboard/public/js/client.js @@ -941,7 +941,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }); }; - Client.prototype.installApp = function (id, manifest, title, config, callback) { + Client.prototype.installApp = function (id, manifest, config, callback) { var data = { appStoreId: id + '@' + manifest.version, subdomain: config.subdomain, @@ -953,7 +953,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout key: config.key, sso: config.sso, overwriteDns: config.overwriteDns, - upstreamUri: config.upstreamUri + upstreamUri: config.upstreamUri, + backupId: config.backupId // when restoring from archive }; post('/api/v1/apps', data, null, function (error, data, status) { @@ -1498,6 +1499,31 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }); }; + Client.prototype.listArchives = function (callback) { + var config = { + params: { + page: 1, + per_page: 100 + } + }; + + get('/api/v1/archives', config, function (error, data, status) { + if (error) return callback(error); + if (status !== 200) return callback(new ClientError(status, data)); + + callback(null, data.archives); + }); + }; + + Client.prototype.deleteArchive = function (id, callback) { + del('/api/v1/archives/' + id, null, function (error, data, status) { + if (error) return callback(error); + if (status !== 204) return callback(new ClientError(status, data)); + + callback(null); + }); + }; + Client.prototype.getBackups = function (callback) { var page = 1; var perPage = 100; diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json index b546b87d1..8e8691fa0 100644 --- a/dashboard/public/translation/en.json +++ b/dashboard/public/translation/en.json @@ -643,6 +643,15 @@ "tooltip": "This will also preserve the mail and {{ appsLength }} app backup(s)." }, "remotePath": "Remote Path" + }, + "archives": { + "title": "Archives", + "location": "Location", + "archiveDate": "Archive Date", + "info": "Info" + }, + "archive": { + "description": "Deleted archives are cleaned up based on the backup policy." } }, "branding": { diff --git a/dashboard/public/translation/nl.json b/dashboard/public/translation/nl.json index ed101af2a..c1a0c5d71 100644 --- a/dashboard/public/translation/nl.json +++ b/dashboard/public/translation/nl.json @@ -1089,7 +1089,7 @@ "title": "Crash herstel", "enableRecoveryModeAction": "Herstelmodus inschakelen", "disableRecoveryModeAction": "Herstelmodus uitschakelen", - "restartAction": "App herstarten", + "restartAction": "Herstarten", "description": "Indien de app niet reageert, probeer dan een herstart van de app. Indien de app continue herstart vanwege een defecte plug-in of verkeerde configuratie, plaats de app dan in herstel-modus voor toegang tot de Terminal.\nVolg deze instructies om de app weer werkend te krijgen.." }, "taskError": { @@ -1102,14 +1102,14 @@ "uninstall": { "startStop": { "title": "Start / Stop", - "startAction": "Start App", - "stopAction": "Stop App", + "startAction": "Start", + "stopAction": "Stop", "description": "Apps kunnen ook gestopt worden in plaats van de-installeren om server capaciteit vrij te maken. Toekomstige app backups bevatten geen wijzigingen tussen nu en de laatste app backup. Start daarom handmatig een backup alvorens de app te stoppen." }, "uninstall": { "title": "De-installeer", "uninstallAction": "De-installeer", - "description": "Hierdoor wordt de app direct verwijderd inclusief alle bijbehorende data. De bijbehorende site wordt onbereikbaar." + "description": "Hierdoor wordt de app gedeïnstalleerd inclusief alle bijbehorende data. Backups worden opgeschoond op basis van het backup-beleid." } }, "appInfo": { @@ -1127,7 +1127,7 @@ "uninstallDialog": { "uninstallAction": "De-installeer", "title": "De-installeer {{ app }}", - "description": "Hiermee de-installeer je direct {{ app }} inclusief alle bijbehorende gegevens." + "description": "Hiermee deïnstalleer je {{ app }} inclusief alle bijbehorende gegevens." }, "domainCollisionDialog": { "title": "Domeinbotsing", @@ -1233,6 +1233,17 @@ "notes": { "title": "Admin Notities" } + }, + "archive": { + "action": "Archiveer", + "latestBackupInfo": "De laatste backup werd gemaakt op {{date}}.", + "title": "Archief", + "description": "De laatste app backup wordt toegevoegd aan het App Archief. De app wordt gedeïnstalleerd maar kan hersteld worden vanuit het Backup Overzicht. Andere backups worden opgeschoond op basis van het backup-beleid.", + "noBackup": "Deze app heeft geen backup. Archiveren vereist minstens één backup." + }, + "archiveDialog": { + "title": "Archief {{app}}", + "description": "Hiermee wordt de app gedeïnstalleerd en wordt de laatste app backup van {{date}} bewaard in het App Archief." } }, "network": { diff --git a/dashboard/public/views/appstore.js b/dashboard/public/views/appstore.js index 5c7f4fa06..3673b541c 100644 --- a/dashboard/public/views/appstore.js +++ b/dashboard/public/views/appstore.js @@ -339,7 +339,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran return; } - Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, $scope.appInstall.app.title, data, function (error, newAppId) { + Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, data, function (error, newAppId) { if (error) { var errorMessage = error.message.toLowerCase(); diff --git a/dashboard/public/views/backups.html b/dashboard/public/views/backups.html index 6c482103e..4d3bfed57 100644 --- a/dashboard/public/views/backups.html +++ b/dashboard/public/views/backups.html @@ -450,6 +450,97 @@ + + +

{{ 'backups.title' | tr }}

@@ -643,4 +734,56 @@
+ +

+ {{ 'backups.archives.title' | tr }} +

+ +
+

+ +
+
+
+

+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ 'backups.archives.location' | tr }}{{ 'backups.archives.info' | tr }}{{ 'main.actions' | tr }}
+ + + {{ archive.appConfig.fqdn }} + + {{ archive.appConfig.manifest.title }} + + {{ archive.creationTime | prettyDate }} + + + +
+
+
+
+
+ diff --git a/dashboard/public/views/backups.js b/dashboard/public/views/backups.js index ba6cfcfd3..31f6fa138 100644 --- a/dashboard/public/views/backups.js +++ b/dashboard/public/views/backups.js @@ -2,7 +2,7 @@ /* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */ /* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO, REGIONS_HETZNER */ -/* global document, window, FileReader */ +/* global async, ERROR */ angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) { Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); }); @@ -23,6 +23,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat $scope.backupTasks = []; $scope.cleanupTasks = []; + $scope.domains = []; + $scope.s3Regions = REGIONS_S3; $scope.wasabiRegions = REGIONS_WASABI; $scope.doSpacesRegions = REGIONS_DIGITALOCEAN; @@ -266,7 +268,171 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat } }; - $scope.listBackups = { + $scope.listArchives = { + ready: false, + archives: [], + + fetch: function () { + Client.listArchives(function (error, archives) { + if (error) Client.error(error); + $scope.listArchives.archives = archives; + $scope.listArchives.ready = true; + }); + }, + + delete: function (archive) { + Client.deleteArchive(archive.id, function (error) { + if (error) Client.error(error); + $scope.listArchives.fetch(); + }); + }, + }; + + // keep in sync with app.js + $scope.clone = { + busy: false, + error: {}, + + archive: null, + subdomain: '', + domain: null, + secondaryDomains: {}, + needsOverwrite: false, + overwriteDns: false, + ports: {}, + portsEnabled: {}, + portInfo: {}, + + init: function () { + Client.getDomains(function (error, domains) { + if (error) return console.error('Unable to get domain listing.', error); + $scope.domains = domains; + }); + }, + + show: function (archive) { + $scope.clone.error = {}; + $scope.clone.archive = archive; + const app = archive.appConfig; + const manifest = archive.appConfig.manifest; + $scope.clone.domain = $scope.domains.find(function (d) { return app.domain === d.domain; }); // pre-select the app's domain + + $scope.clone.needsOverwrite = false; + $scope.clone.overwriteDns = false; + + $scope.clone.secondaryDomains = {}; + + var httpPorts = manifest.httpPorts || {}; + for (var env2 in httpPorts) { + $scope.clone.secondaryDomains[env2] = { + subdomain: httpPorts[env2].defaultValue || '', + domain: $scope.clone.domain + }; + } + + $scope.clone.portInfo = angular.extend({}, manifest.tcpPorts, manifest.udpPorts); // Portbinding map only for information + // set default ports + for (var env in $scope.clone.portInfo) { + $scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0; + $scope.clone.portsEnabled[env] = true; + } + + $('#appCloneModal').modal('show'); + }, + + submit: function () { + $scope.clone.busy = true; + + var secondaryDomains = {}; + for (var env2 in $scope.clone.secondaryDomains) { + secondaryDomains[env2] = { + subdomain: $scope.clone.secondaryDomains[env2].subdomain, + domain: $scope.clone.secondaryDomains[env2].domain.domain + }; + } + + // only use enabled ports + var finalPorts = {}; + for (var env in $scope.clone.ports) { + if ($scope.clone.portsEnabled[env]) { + finalPorts[env] = $scope.clone.ports[env]; + } + } + + var data = { + subdomain: $scope.clone.subdomain, + domain: $scope.clone.domain.domain, + secondaryDomains: secondaryDomains, + ports: finalPorts, + backupId: $scope.clone.archive.backupId, + overwriteDns: $scope.clone.overwriteDns + }; + + var allDomains = [{ domain: data.domain, subdomain: data.subdomain }].concat(Object.keys(secondaryDomains).map(function (k) { + return { + domain: secondaryDomains[k].domain, + subdomain: secondaryDomains[k].subdomain + }; + })); + async.eachSeries(allDomains, function (domain, callback) { + if ($scope.clone.overwriteDns) return callback(); + + Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) { + if (error) return callback(error); + + var fqdn = domain.subdomain + '.' + domain.domain; + + if (result.error) { + if (result.error.reason === ERROR.ACCESS_DENIED) return callback({ type: 'provider', fqdn: fqdn, message: 'DNS credentials for ' + domain.domain + ' are invalid. Update it in Domains & Certs view' }); + return callback({ type: 'provider', fqdn: fqdn, message: result.error.message }); + } + if (result.needsOverwrite) { + $scope.clone.needsOverwrite = true; + $scope.clone.overwriteDns = true; + return callback({ type: 'externally_exists', fqdn: fqdn, message: 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron' }); + } + + callback(); + }); + }, function (error) { + if (error) { + if (error.type) { + $scope.clone.error.location = error; + $scope.clone.busy = false; + } else { + Client.error(error); + } + + $scope.clone.error.location = error; + $scope.clone.busy = false; + return; + } + + const app = $scope.clone.archive.appConfig; + + Client.installApp(app.manifest.id, app.manifest, data, function (error/*, clonedApp */) { + $scope.clone.busy = false; + + if (error) { + var errorMessage = error.message.toLowerCase(); + if (errorMessage.indexOf('port') !== -1) { + $scope.clone.error.port = error.message; + } else if (error.message.indexOf('location') !== -1 || error.message.indexOf('subdomain') !== -1) { + // TODO extract fqdn from error message, currently we just set it always to the main location + $scope.clone.error.location = { type: 'internally_exists', fqdn: data.subdomain + '.' + data.domain, message: error.message }; + $('#cloneLocationInput').focus(); + } else { + Client.error(error); + } + return; + } + + $('#appCloneModal').modal('hide'); + + $location.path('/apps'); + }); + }); + } }; $scope.s3like = function (provider) { @@ -860,12 +1026,15 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat fetchBackups(); getBackupConfig(); + $scope.listArchives.fetch(); + $scope.manualBackupApps = Client.getInstalledApps().filter(function (app) { return app.type !== APP_TYPES.LINK && !app.enableBackup; }); // show backup status $scope.createBackup.init(); $scope.cleanupBackups.init(); $scope.backupPolicy.init(); + $scope.clone.init(); getBackupTasks(); getCleanupTasks(); diff --git a/src/archives.js b/src/archives.js index 8c18a7db0..6ea87e295 100644 --- a/src/archives.js +++ b/src/archives.js @@ -24,6 +24,8 @@ function postProcess(result) { result.appConfig = result.appConfigJson ? safe.JSON.parse(result.appConfigJson) : null; delete result.appConfigJson; + result.iconUrl = result.hasIcon || result.hasAppStoreIcon ? `/api/v1/archives/${result.id}/icon` : null; + return result; } diff --git a/src/routes/archives.js b/src/routes/archives.js index ddfe771cf..74322aa4c 100644 --- a/src/routes/archives.js +++ b/src/routes/archives.js @@ -49,7 +49,8 @@ async function get(req, res, next) { } async function getIcon(req, res, next) { - assert.strictEqual(typeof req.app, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + assert.strictEqual(typeof req.resource, 'object'); const [error, icon] = await safe(archives.getIcon(req.params.id, { original: req.query.original })); if (error) return next(BoxError.toHttpError(error));