'use strict'; /* global angular:false */ /* global $:false */ /* global SECRET_PLACEHOLDER */ 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('/'); }); $scope.config = Client.getConfig(); $scope.user = Client.getUserInfo(); $scope.manualBackupApps = []; $scope.backupConfig = {}; $scope.backups = []; // List is from http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region $scope.s3Regions = [ { name: 'Asia Pacific (Mumbai)', value: 'ap-south-1' }, { name: 'Asia Pacific (Osaka-Local)', value: 'ap-northeast-3' }, { name: 'Asia Pacific (Seoul)', value: 'ap-northeast-2' }, { name: 'Asia Pacific (Singapore)', value: 'ap-southeast-1' }, { name: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' }, { name: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' }, { name: 'Canada (Central)', value: 'ca-central-1' }, { name: 'EU (Frankfurt)', value: 'eu-central-1' }, { name: 'EU (Ireland)', value: 'eu-west-1' }, { name: 'EU (London)', value: 'eu-west-2' }, { name: 'EU (Paris)', value: 'eu-west-3' }, { name: 'EU (Stockholm)', value: 'eu-north-1' }, { name: 'South America (São Paulo)', value: 'sa-east-1' }, { name: 'US East (N. Virginia)', value: 'us-east-1' }, { name: 'US East (Ohio)', value: 'us-east-2' }, { name: 'US West (N. California)', value: 'us-west-1' }, { name: 'US West (Oregon)', value: 'us-west-2' }, ]; $scope.wasabiRegions = [ { name: 'EU Central 1', value: 'https://s3.eu-central-1.wasabisys.com' }, { name: 'US East 1', value: 'https://s3.us-east-1.wasabisys.com' }, { name: 'US East 2', value: 'https://s3.us-east-2.wasabisys.com ' }, { name: 'US West 1', value: 'https://s3.us-west-1.wasabisys.com' } ]; $scope.doSpacesRegions = [ { name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' }, { name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' }, { name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' }, { name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' }, { name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' } ]; $scope.exoscaleSosRegions = [ { name: 'AT-VIE-1', value: 'https://sos-at-vie-1.exo.io' }, { name: 'CH-DK-2', value: 'https://sos-ch-dk-2.exo.io' }, { name: 'CH-GVA-2', value: 'https://sos-ch-gva-2.exo.io' }, { name: 'DE-FRA-1', value: 'https://sos-de-fra-1.exo.io' }, ]; // https://www.scaleway.com/docs/object-storage-feature/ $scope.scalewayRegions = [ { name: 'FR-PAR', value: 'https://s3.fr-par.scw.cloud', region: 'fr-par' }, // default { name: 'NL-AMS', value: 'https://s3.nl-ams.scw.cloud', region: 'nl-ams' } ]; $scope.linodeRegions = [ { name: 'Newark', value: 'us-east-1.linodeobjects.com', region: 'us-east-1' }, // default { name: 'Frankfurt', value: 'eu-central-1.linodeobjects.com', region: 'us-east-1' }, ]; $scope.ovhRegions = [ { name: 'Beauharnois (BHS)', value: 'https://s3.bhs.cloud.ovh.net', region: 'bhs' }, // default { name: 'Frankfurt (DE)', value: 'https://s3.de.cloud.ovh.net', region: 'de' }, { name: 'Gravelines (GRA)', value: 'https://s3.gra.cloud.ovh.net', region: 'gra' }, { name: 'Strasbourg (SBG)', value: 'https://s3.sbg.cloud.ovh.net', region: 'sbg' }, { name: 'London (UK)', value: 'https://s3.uk.cloud.ovh.net', region: 'uk' }, { name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' }, ]; $scope.storageProvider = [ { name: 'Amazon S3', value: 's3' }, { name: 'Backblaze B2 (S3 API)', value: 'backblaze-b2' }, { name: 'CIFS Mount', value: 'cifs' }, { name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' }, { name: 'Exoscale SOS', value: 'exoscale-sos' }, { name: 'Filesystem', value: 'filesystem' }, { name: 'Google Cloud Storage', value: 'gcs' }, { name: 'Linode Object Storage', value: 'linode-objectstorage' }, { name: 'Minio', value: 'minio' }, { name: 'NFS Mount', value: 'nfs' }, { name: 'OVH Object Storage', value: 'ovh-objectstorage' }, { name: 'S3 API Compatible (v4)', value: 's3-v4-compat' }, { name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' }, { name: 'SSHFS Mount', value: 'sshfs' }, { name: 'Wasabi', value: 'wasabi' }, { name: 'No-op (Only for testing)', value: 'noop' } ]; $scope.retentionPolicies = [ { name: '2 days', value: { keepWithinSecs: 2 * 24 * 60 * 60 }}, { name: '1 week', value: { keepWithinSecs: 7 * 24 * 60 * 60 }}, // default { name: '1 month', value: { keepWithinSecs: 30 * 24 * 60 * 60 }}, { name: '2 daily, 4 weekly', value: { keepDaily: 2, keepWeekly: 4 }}, { name: '3 daily, 4 weekly, 6 monthly', value: { keepDaily: 3, keepWeekly: 4, keepMonthly: 6 }}, { name: '7 daily, 4 weekly, 12 monthly', value: { keepDaily: 7, keepWeekly: 4, keepMonthly: 12 }}, { name: 'Forever', value: { keepWithinSecs: -1 }} ]; $scope.intervalTimes = [ { name: 'Every 6 hours', value: 6 * 60 * 60 }, { name: 'Every 12 hours', value: 12 * 60 * 60 }, { name: 'Every day', value: 24 * 60 * 60 }, { name: 'Every 3 days', value: 3 * 24 * 60 * 60 }, { name: 'Every week', value: 7 * 24 * 60 * 60 }, ]; $scope.formats = [ { name: 'Tarball (zipped)', value: 'tgz' }, { name: 'rsync', value: 'rsync' } ]; $scope.prettyProviderName = function (provider) { switch (provider) { case 'caas': return 'Managed Cloudron'; default: return provider; } }; $scope.prettyBackupInterval = function (interval) { var tmp = $scope.intervalTimes.find(function (i) { return i.value === interval; }); return tmp ? tmp.name : ''; }; $scope.prettyBackupRetentionPolicy = function (retentionPolicy) { var tmp = $scope.retentionPolicies.find(function (p) { return angular.equals(p.value, retentionPolicy); }); return tmp ? tmp.name : ''; }; $scope.createBackup = { busy: false, percent: 0, message: '', errorMessage: '', taskId: '', taskType: 'backup', checkStatus: function () { Client.getLatestTaskByType($scope.createBackup.taskType, function (error, task) { if (error) return console.error(error); if (!task) return; $scope.createBackup.taskId = task.id; $scope.createBackup.updateStatus(); }); }, updateStatus: function () { Client.getTask($scope.createBackup.taskId, function (error, data) { if (error) return window.setTimeout($scope.createBackup.updateStatus, 5000); if (!data.active) { $scope.createBackup.busy = false; $scope.createBackup.message = ''; $scope.createBackup.percent = 100; // indicates that 'result' is valid $scope.createBackup.errorMessage = data.success ? '' : data.error.message; return fetchBackups(); } $scope.createBackup.busy = true; $scope.createBackup.percent = data.percent; $scope.createBackup.message = data.message; window.setTimeout($scope.createBackup.updateStatus, 3000); }); }, startBackup: function () { $scope.createBackup.busy = true; $scope.createBackup.percent = 0; $scope.createBackup.message = ''; $scope.createBackup.errorMessage = ''; $scope.createBackup.taskType = 'backup'; Client.startBackup(function (error, taskId) { if (error) { if (error.statusCode === 409 && error.message.indexOf('full_backup') !== -1) { $scope.createBackup.errorMessage = 'Backup already in progress. Please retry later.'; } else if (error.statusCode === 409) { $scope.createBackup.errorMessage = 'App task is currently in progress. Please retry later.'; } else { console.error(error); $scope.createBackup.errorMessage = error.message; } $scope.createBackup.busy = false; $('#createBackupFailedModal').modal('show'); return; } $scope.createBackup.taskId = taskId; $scope.createBackup.updateStatus(); }); }, cleanupBackups: function (backup) { $('#cleanupBackupsModal').modal('show'); }, startCleanup: function () { $scope.createBackup.busy = true; $scope.createBackup.percent = 0; $scope.createBackup.message = ''; $scope.createBackup.errorMessage = ''; $scope.createBackup.taskType = 'cleanBackups'; $('#cleanupBackupsModal').modal('hide'); Client.cleanupBackups(function (error, taskId) { if (error) console.error(error); $scope.createBackup.taskId = taskId; $scope.createBackup.updateStatus(); }); }, stopTask: function () { Client.stopTask($scope.createBackup.taskId, function (error) { if (error) { if (error.statusCode === 409) { $scope.createBackup.errorMessage = 'No backup is currently in progress'; } else { console.error(error); $scope.createBackup.errorMessage = error.message; } $scope.createBackup.busy = false; return; } }); } }; $scope.listBackups = { }; $scope.s3like = function (provider) { return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces' || provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'linode-objectstorage' || provider === 'ovh-objectstorage'; }; $scope.mountlike = function (provider) { return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs'; }; // https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server#18197341 function download(filename, text) { var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } $scope.downloadConfig = function (backup) { // secrets and tokens already come with placeholder characters we remove them var tmp = { backupId: backup.id, encrypted: !!$scope.backupConfig.password // we add this just to help the import UI }; Object.keys($scope.backupConfig).forEach(function (k) { if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = $scope.backupConfig[k]; }); download('cloudron_backup.json', JSON.stringify(tmp)); }; $scope.backupDetails = { backup: null, show: function (backup) { $scope.backupDetails.backup = backup; $('#backupDetailsModal').modal('show'); } }; $scope.configureScheduleAndRetention = { busy: false, error: {}, retentionPolicy: $scope.retentionPolicies[0], intervalSecs: 24 * 60 * 60, show: function () { $scope.configureScheduleAndRetention.error = {}; $scope.configureScheduleAndRetention.busy = false; var selectedPolicy = $scope.retentionPolicies.find(function (x) { return angular.equals(x.value, $scope.backupConfig.retentionPolicy); }); if (!selectedPolicy) selectedPolicy = $scope.retentionPolicies[0]; $scope.configureScheduleAndRetention.retentionPolicy = selectedPolicy.value; $scope.configureScheduleAndRetention.intervalSecs = $scope.backupConfig.intervalSecs; $('#configureScheduleAndRetentionModal').modal('show'); }, submit: function () { $scope.configureScheduleAndRetention.error = {}; $scope.configureScheduleAndRetention.busy = true; // start with the full backupConfig since the api requires all fields var backupConfig = $scope.backupConfig; backupConfig.retentionPolicy = $scope.configureScheduleAndRetention.retentionPolicy; backupConfig.intervalSecs = $scope.configureScheduleAndRetention.intervalSecs; Client.setBackupConfig(backupConfig, function (error) { $scope.configureScheduleAndRetention.busy = false; if (error) { if (error.statusCode === 424) { $scope.configureScheduleAndRetention.error.generic = error.message; } else if (error.statusCode === 400) { $scope.configureScheduleAndRetention.error.generic = error.message; } else { console.error('Unable to change schedule or retention.', error); } return; } $('#configureScheduleAndRetentionModal').modal('hide'); getBackupConfig(); }); } }; $scope.configureBackup = { busy: false, error: {}, provider: '', bucket: '', prefix: '', accessKeyId: '', secretAccessKey: '', gcsKey: { keyFileName: '', content: '' }, region: '', endpoint: '', backupFolder: '', mountPoint: '', acceptSelfSignedCerts: false, useHardlinks: true, externalDisk: false, format: 'tgz', password: '', clearProviderFields: function () { $scope.configureBackup.bucket = ''; $scope.configureBackup.prefix = ''; $scope.configureBackup.accessKeyId = ''; $scope.configureBackup.secretAccessKey = ''; $scope.configureBackup.gcsKey.keyFileName = ''; $scope.configureBackup.gcsKey.content = ''; $scope.configureBackup.endpoint = ''; $scope.configureBackup.region = ''; $scope.configureBackup.backupFolder = ''; $scope.configureBackup.mountPoint = ''; $scope.configureBackup.acceptSelfSignedCerts = false; $scope.configureBackup.useHardlinks = true; $scope.configureBackup.externalDisk = false; }, show: function () { $scope.configureBackup.error = {}; $scope.configureBackup.busy = false; $scope.configureBackup.provider = $scope.backupConfig.provider; $scope.configureBackup.bucket = $scope.backupConfig.bucket; $scope.configureBackup.prefix = $scope.backupConfig.prefix; $scope.configureBackup.region = $scope.backupConfig.region; $scope.configureBackup.accessKeyId = $scope.backupConfig.accessKeyId; $scope.configureBackup.secretAccessKey = $scope.backupConfig.secretAccessKey; if ($scope.backupConfig.provider === 'gcs') { $scope.configureBackup.gcsKey.keyFileName = $scope.backupConfig.credentials.client_email; $scope.configureBackup.gcsKey.content = JSON.stringify({ project_id: $scope.backupConfig.projectId, client_email: $scope.backupConfig.credentials.client_email, private_key: $scope.backupConfig.credentials.private_key, }); } $scope.configureBackup.endpoint = $scope.backupConfig.endpoint; $scope.configureBackup.password = $scope.backupConfig.password; $scope.configureBackup.backupFolder = $scope.backupConfig.backupFolder; $scope.configureBackup.mountPoint = $scope.backupConfig.mountPoint; $scope.configureBackup.format = $scope.backupConfig.format; $scope.configureBackup.acceptSelfSignedCerts = !!$scope.backupConfig.acceptSelfSignedCerts; $scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks; $scope.configureBackup.externalDisk = !!$scope.backupConfig.externalDisk; $('#configureBackupModal').modal('show'); }, submit: function () { $scope.configureBackup.error = {}; $scope.configureBackup.busy = true; var backupConfig = { provider: $scope.configureBackup.provider, format: $scope.configureBackup.format, // required for api call to provide all fields intervalSecs: $scope.backupConfig.intervalSecs, retentionPolicy: $scope.backupConfig.retentionPolicy }; if ($scope.configureBackup.password) backupConfig.password = $scope.configureBackup.password; // only set provider specific fields, this will clear them in the db if ($scope.s3like(backupConfig.provider)) { backupConfig.bucket = $scope.configureBackup.bucket; backupConfig.prefix = $scope.configureBackup.prefix; backupConfig.accessKeyId = $scope.configureBackup.accessKeyId; backupConfig.secretAccessKey = $scope.configureBackup.secretAccessKey; if ($scope.configureBackup.endpoint) backupConfig.endpoint = $scope.configureBackup.endpoint; if (backupConfig.provider === 's3') { if ($scope.configureBackup.region) backupConfig.region = $scope.configureBackup.region; delete backupConfig.endpoint; } else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') { backupConfig.region = $scope.configureBackup.region || 'us-east-1'; backupConfig.acceptSelfSignedCerts = $scope.configureBackup.acceptSelfSignedCerts; backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI } else if (backupConfig.provider === 'exoscale-sos') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'wasabi') { backupConfig.region = $scope.wasabiRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'scaleway-objectstorage') { backupConfig.region = $scope.scalewayRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'linode-objectstorage') { backupConfig.region = $scope.linodeRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'ovh-objectstorage') { backupConfig.region = $scope.ovhRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'digitalocean-spaces') { backupConfig.region = 'us-east-1'; } } else if (backupConfig.provider === 'gcs') { backupConfig.bucket = $scope.configureBackup.bucket; backupConfig.prefix = $scope.configureBackup.prefix; try { var serviceAccountKey = JSON.parse($scope.configureBackup.gcsKey.content); backupConfig.projectId = serviceAccountKey.project_id; backupConfig.credentials = { client_email: serviceAccountKey.client_email, private_key: serviceAccountKey.private_key }; if (!backupConfig.projectId || !backupConfig.credentials || !backupConfig.credentials.client_email || !backupConfig.credentials.private_key) { throw 'fields_missing'; } } catch (e) { $scope.configureBackup.error.generic = 'Cannot parse Google Service Account Key: ' + e.message; $scope.configureBackup.error.gcsKeyInput = true; $scope.configureBackup.busy = false; return; } } else if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs') { backupConfig.mountPoint = $scope.configureBackup.mountPoint; backupConfig.prefix = $scope.configureBackup.prefix; backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks; } else if (backupConfig.provider === 'filesystem') { backupConfig.backupFolder = $scope.configureBackup.backupFolder; backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks; backupConfig.externalDisk = $scope.configureBackup.externalDisk; } Client.setBackupConfig(backupConfig, function (error) { $scope.configureBackup.busy = false; if (error) { if (error.statusCode === 424) { $scope.configureBackup.error.generic = error.message; if (error.message.indexOf('AWS Access Key Id') !== -1) { $scope.configureBackup.error.accessKeyId = true; $scope.configureBackup.accessKeyId = ''; $scope.configureBackupForm.accessKeyId.$setPristine(); $('#inputConfigureBackupAccessKeyId').focus(); } else if (error.message.indexOf('not match the signature') !== -1 ) { $scope.configureBackup.error.secretAccessKey = true; $scope.configureBackup.secretAccessKey = ''; $scope.configureBackupForm.secretAccessKey.$setPristine(); $('#inputConfigureBackupSecretAccessKey').focus(); } else if (error.message.toLowerCase() === 'access denied') { $scope.configureBackup.error.bucket = true; $scope.configureBackup.bucket = ''; $scope.configureBackupForm.bucket.$setPristine(); $('#inputConfigureBackupBucket').focus(); } else if (error.message.indexOf('ECONNREFUSED') !== -1) { $scope.configureBackup.error.generic = 'Unknown region'; $scope.configureBackup.error.region = true; $scope.configureBackupForm.region.$setPristine(); $('#inputConfigureBackupDORegion').focus(); } else if (error.message.toLowerCase() === 'wrong region') { $scope.configureBackup.error.generic = 'Wrong S3 Region'; $scope.configureBackup.error.region = true; $scope.configureBackupForm.region.$setPristine(); $('#inputConfigureBackupS3Region').focus(); } else { $('#inputConfigureBackupBucket').focus(); } } else if (error.statusCode === 400) { $scope.configureBackup.error.generic = error.message; if (error.message.indexOf('password') !== -1) { $scope.configureBackup.error.password = true; $scope.configureBackupForm.password.$setPristine(); } else if ($scope.configureBackup.provider === 'filesystem') { $scope.configureBackup.error.backupFolder = true; } } else { console.error('Unable to change provider.', error); } return; } // $scope.configureBackup.reset(); $('#configureBackupModal').modal('hide'); getBackupConfig(); }); } }; function fetchBackups() { Client.getBackups(function (error, backups) { if (error) return console.error(error); $scope.backups = backups; $scope.backups = $scope.backups.slice(0, 20); // only show 20 since we don't have pagination // add contents property var appsById = {}; Client.getInstalledApps().forEach(function (app) { appsById[app.id] = app; }); $scope.backups.forEach(function (backup) { backup.contents = []; backup.dependsOn.forEach(function (appBackupId) { let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy if (!match || !appsById[match[1]]) return; backup.contents.push(appsById[match[1]]); }); }); }); } function getBackupConfig() { Client.getBackupConfig(function (error, backupConfig) { if (error) return console.error(error); $scope.backupConfig = backupConfig; }); } Client.onReady(function () { fetchBackups(); getBackupConfig(); $scope.manualBackupApps = Client.getInstalledApps().filter(function (app) { return !app.enableBackup; }); // show backup status $scope.createBackup.checkStatus(); }); function readFileLocally(obj, file, fileName) { return function (event) { $scope.$apply(function () { obj[file] = null; obj[fileName] = event.target.files[0].name; var reader = new FileReader(); reader.onload = function (result) { if (!result.target || !result.target.result) return console.error('Unable to read local file'); obj[file] = result.target.result; }; reader.readAsText(event.target.files[0]); }); }; } document.getElementById('gcsKeyFileInput').onchange = readFileLocally($scope.configureBackup.gcsKey, 'content', 'keyFileName'); // setup all the dialog focus handling ['configureBackupModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find("[autofocus]:first").focus(); }); }); $('.modal-backdrop').remove(); }]);