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 @@
+
+
+
@@ -643,4 +734,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {{ 'backups.archives.location' | tr }} |
+ {{ 'backups.archives.info' | tr }} |
+ {{ 'backups.archives.archiveDate' | 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));