'use strict'; /* 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 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('/'); }); $scope.SECRET_PLACEHOLDER = SECRET_PLACEHOLDER; $scope.MIN_MEMORY_LIMIT = 1024 * 1024 * 1024; // 1 GB $scope.MAX_MEMORY_LIMIT = $scope.MIN_MEMORY_LIMIT; // set later $scope.config = Client.getConfig(); $scope.user = Client.getUserInfo(); $scope.memory = null; // { memory, swap } $scope.mountStatus = null; // { state, message } $scope.manualBackupApps = []; $scope.currentTimeZone = ''; $scope.backupConfig = {}; $scope.backups = []; $scope.backupTasks = []; $scope.cleanupTasks = []; $scope.domains = []; $scope.s3Regions = REGIONS_S3; $scope.wasabiRegions = REGIONS_WASABI; $scope.doSpacesRegions = REGIONS_DIGITALOCEAN; $scope.exoscaleSosRegions = REGIONS_EXOSCALE; $scope.scalewayRegions = REGIONS_SCALEWAY; $scope.linodeRegions = REGIONS_LINODE; $scope.ovhRegions = REGIONS_OVH; $scope.ionosRegions = REGIONS_IONOS; $scope.upcloudRegions = REGIONS_UPCLOUD; $scope.vultrRegions = REGIONS_VULTR; $scope.contaboRegions = REGIONS_CONTABO; $scope.hetznerRegions = REGIONS_HETZNER; $scope.storageProviders = STORAGE_PROVIDERS.concat([ { name: 'No-op (Only for testing)', value: 'noop' } ]); $scope.backupRetentions = [ { 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: '3 months', value: { keepWithinSecs: 3 * 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 }} ]; // values correspond to cron days $scope.cronDays = [ { name: 'Sunday', value: 0 }, { name: 'Monday', value: 1 }, { name: 'Tuesday', value: 2 }, { name: 'Wednesday', value: 3 }, { name: 'Thursday', value: 4 }, { name: 'Friday', value: 5 }, { name: 'Saturday', value: 6 }, ]; // generates 24h time sets (instead of american 12h) to avoid having to translate everything to locales eg. 12:00 $scope.cronHours = Array.from({ length: 24 }).map(function (v, i) { return { name: (i < 10 ? '0' : '') + i + ':00', value: i }; }); $scope.formats = BACKUP_FORMATS; $scope.prettyProviderName = function (provider) { switch (provider) { case 'caas': return 'Managed Cloudron'; default: return provider; } }; $scope.prettyBackupSchedule = function (pattern) { if (!pattern) return ''; var tmp = pattern.split(' '); var hours = tmp[2].split(','), days = tmp[5].split(','); var prettyDay; if (days.length === 7 || days[0] === '*') { prettyDay = 'Everyday'; } else { prettyDay = days.map(function (day) { return $scope.cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(','); } var prettyHour = hours.map(function (hour) { return $scope.cronHours[parseInt(hour, 10)].name; }).join(','); return prettyDay + ' at ' + prettyHour; }; $scope.prettyBackupRetention = function (retention) { var tmp = $scope.backupRetentions.find(function (p) { return angular.equals(p.value, retention); }); return tmp ? tmp.name : ''; }; $scope.remount = { busy: false, error: null, submit: function () { if (!$scope.mountlike($scope.backupConfig.provider)) return; $scope.remount.busy = true; $scope.remount.error = null; Client.remountBackupStorage(function (error) { if (error) { console.error('Failed to remount backup storage.', error); $scope.remount.error = error.message; } // give the backend some time $timeout(function () { $scope.remount.busy = false; getBackupConfig(); }, 2000); }); } }; $scope.createBackup = { busy: false, percent: 0, message: '', errorMessage: '', taskId: '', init: function () { Client.getLatestTaskByType(TASK_TYPES.TASK_BACKUP, 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; getBackupTasks(); 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 = ''; 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; } getBackupTasks(); $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; getBackupTasks(); return; } }); } }; $scope.cleanupBackups = { busy: false, taskId: 0, init: function () { Client.getLatestTaskByType(TASK_TYPES.TASK_CLEAN_BACKUPS, function (error, task) { if (error) return console.error(error); if (!task) return; $scope.cleanupBackups.taskId = task.id; $scope.cleanupBackups.updateStatus(); getCleanupTasks(); }); }, updateStatus: function () { Client.getTask($scope.cleanupBackups.taskId, function (error, data) { if (error) return window.setTimeout($scope.cleanupBackups.updateStatus, 5000); if (!data.active) { $scope.cleanupBackups.busy = false; getCleanupTasks(); fetchBackups(); return; } $scope.cleanupBackups.busy = true; $scope.cleanupBackups.message = data.message; window.setTimeout($scope.cleanupBackups.updateStatus, 3000); }); }, ask: function () { $('#cleanupBackupsModal').modal('show'); }, start: function () { $scope.cleanupBackups.busy = true; $('#cleanupBackupsModal').modal('hide'); Client.cleanupBackups(function (error, taskId) { if (error) console.error(error); $scope.cleanupBackups.taskId = taskId; $scope.cleanupBackups.updateStatus(); getCleanupTasks(); }); } }; $scope.archiveList = { ready: false, archives: [], fetch: function () { Client.listArchives(function (error, archives) { if (error) Client.error(error); $scope.archiveList.archives = archives; $scope.archiveList.ready = true; // ensure we use the full api oprigin $scope.archiveList.archives.forEach(a => { a.iconUrl = window.cloudronApiOrigin + a.iconUrl; }); }); }, }; $scope.archiveDelete = { busy: false, error: {}, archive: null, title: '', fqdn: '', ask: function (archive) { $scope.archiveDelete.busy = false; $scope.archiveDelete.error = {}; $scope.archiveDelete.archive = archive; $scope.archiveDelete.title = archive.manifest.title; $scope.archiveDelete.fqdn = archive.appConfig?.fqdn || '-'; $('#archiveDeleteModal').modal('show'); }, submit: function () { $scope.archiveDelete.busy = true; $scope.archiveDelete.error = {}; Client.deleteArchive($scope.archiveDelete.archive.id, function (error) { $scope.archiveDelete.busy = false; if (error) return console.error('Unable to delete archive.', error.statusCode, error.message); $scope.archiveList.fetch(); $('#archiveDeleteModal').modal('hide'); }); } }; // keep in sync with app.js $scope.archiveRestore = { busy: false, error: {}, archive: null, manifest: null, appStoreId: '', fqdn: '', subdomain: '', domain: null, secondaryDomains: {}, needsOverwrite: false, overwriteDns: false, ports: {}, portsEnabled: {}, portInfo: {}, accessRestriction: { users: [], groups: [] }, 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.archiveRestore.error = {}; $scope.archiveRestore.archive = archive; $scope.archiveRestore.manifest = archive.manifest; const app = archive.appConfig || { subdomain: '', domain: $scope.domains[0].domain, secondaryDomains: [], portBindings: {} }; // pre-8.2 backups do not have appConfig $scope.archiveRestore.fqdn = archive.appConfig?.fqdn || '-'; $scope.archiveRestore.subdomain = app.subdomain; $scope.archiveRestore.domain = $scope.domains.find(function (d) { return app.domain === d.domain; }); // try to pre-select the app's domain $scope.archiveRestore.needsOverwrite = false; $scope.archiveRestore.overwriteDns = false; $scope.archiveRestore.secondaryDomains = {}; var httpPorts = archive.manifest.httpPorts || {}; for (var env2 in httpPorts) { $scope.archiveRestore.secondaryDomains[env2] = { subdomain: httpPorts[env2].defaultValue || '', domain: $scope.archiveRestore.domain }; } // now fill secondaryDomains with real values, if it exists app.secondaryDomains.forEach(function (sd) { $scope.archiveRestore.secondaryDomains[sd.environmentVariable] = { subdomain: sd.subdomain, domain: $scope.domains.find(function (d) { return sd.domain === d.domain; }) }; }); $scope.archiveRestore.portInfo = angular.extend({}, archive.manifest.tcpPorts, archive.manifest.udpPorts); // Portbinding map only for information // set default ports for (var env in $scope.archiveRestore.portInfo) { if (app.portBindings[env]) { // was enabled in the app $scope.archiveRestore.ports[env] = app.portBindings[env].hostPort; $scope.archiveRestore.portsEnabled[env] = true; } else { $scope.archiveRestore.ports[env] = $scope.archiveRestore.portInfo[env].defaultValue || 0; $scope.archiveRestore.portsEnabled[env] = false; } } $('#restoreArchiveModal').modal('show'); }, submit: function () { $scope.archiveRestore.busy = true; var secondaryDomains = {}; for (var env2 in $scope.archiveRestore.secondaryDomains) { secondaryDomains[env2] = { subdomain: $scope.archiveRestore.secondaryDomains[env2].subdomain, domain: $scope.archiveRestore.secondaryDomains[env2].domain.domain }; } // only use enabled ports var finalPorts = {}; for (var env in $scope.archiveRestore.ports) { if ($scope.archiveRestore.portsEnabled[env]) { finalPorts[env] = $scope.archiveRestore.ports[env]; } } var data = { subdomain: $scope.archiveRestore.subdomain, domain: $scope.archiveRestore.domain.domain, secondaryDomains: secondaryDomains, ports: finalPorts, overwriteDns: $scope.archiveRestore.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.archiveRestore.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.archiveRestore.needsOverwrite = true; $scope.archiveRestore.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.archiveRestore.error.location = error; $scope.archiveRestore.busy = false; } else { Client.error(error); } $scope.archiveRestore.error.location = error; $scope.archiveRestore.busy = false; return; } Client.unarchiveApp($scope.archiveRestore.archive.id, data, function (error/*, newApp */) { $scope.archiveRestore.busy = false; if (error) { var errorMessage = error.message.toLowerCase(); if (errorMessage.indexOf('port') !== -1) { $scope.archiveRestore.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.archiveRestore.error.location = { type: 'internally_exists', fqdn: data.subdomain + '.' + data.domain, message: error.message }; $('#cloneLocationInput').focus(); } else { Client.error(error); } return; } $('#restoreArchiveModal').modal('hide'); $location.path('/apps'); }); }); } }; $scope.s3like = function (provider) { return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces' || provider === 'hetzner-objectstorage' || provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2' || provider === 'contabo-objectstorage'; }; $scope.mountlike = function (provider) { return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs' || provider === 'disk'; }; // 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, isArchive) { // can also be a archive object // secrets and tokens already come with placeholder characters we remove them var tmp = { remotePath: backup.remotePath, 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]; }); let filename; if (isArchive) { filename = `${backup.appConfig.fqdn}-archive-config-${(new Date(backup.creationTime)).toISOString().split('T')[0]}.json`; } else { filename = `${$scope.config.adminFqdn}-backup-config-${(new Date(backup.creationTime)).toISOString().split('T')[0]}.json`; } download(filename, JSON.stringify(tmp, null, 4)); }; $scope.editBackup = { busy: false, error: null, backup: null, label: '', persist: false, show: function (backup) { $scope.editBackup.backup = backup; $scope.editBackup.label = backup.label; $scope.editBackup.persist = backup.preserveSecs === -1; $scope.editBackup.error = null; $scope.editBackup.busy = false; $('#editBackupModal').modal('show'); }, submit: function () { $scope.editBackup.error = null; $scope.editBackup.busy = true; Client.editBackup($scope.editBackup.backup.id, $scope.editBackup.label, $scope.editBackup.persist ? -1 : 0, function (error) { $scope.editBackup.busy = false; if (error) return $scope.editBackup.error = error.message; fetchBackups(); $('#editBackupModal').modal('hide'); }); } }; $scope.backupDetails = { backup: null, show: function (backup) { $scope.backupDetails.backup = backup; $('#backupDetailsModal').modal('show'); } }; $scope.backupPolicy = { busy: false, error: {}, currentPolicy: null, retention: null, days: [], hours: [], init: function () { Client.getBackupPolicy(function (error, policy) { if (error) Client.error(error); $scope.backupPolicy.currentPolicy = policy; }); }, show: function () { $scope.backupPolicy.error = {}; $scope.backupPolicy.busy = false; var selectedRetention = $scope.backupRetentions.find(function (x) { return angular.equals(x.value, $scope.backupPolicy.currentPolicy.retention); }); if (!selectedRetention) selectedRetention = $scope.backupRetentions[0]; $scope.backupPolicy.retention = selectedRetention.value; var tmp = $scope.backupPolicy.currentPolicy.schedule.split(' '); var hours = tmp[2].split(','), days = tmp[5].split(','); if (days[0] === '*') { $scope.backupPolicy.days = angular.copy($scope.cronDays, []); } else { $scope.backupPolicy.days = days.map(function (day) { return $scope.cronDays[parseInt(day, 10)]; }); } $scope.backupPolicy.hours = hours.map(function (hour) { return $scope.cronHours[parseInt(hour, 10)]; }); $('#backupPolicyModal').modal('show'); }, valid: function () { return $scope.backupPolicy.days.length && $scope.backupPolicy.hours.length; }, submit: function () { if (!$scope.backupPolicy.days.length) return; if (!$scope.backupPolicy.hours.length) return; $scope.backupPolicy.error = {}; $scope.backupPolicy.busy = true; var daysPattern; if ($scope.backupPolicy.days.length === 7) daysPattern = '*'; else daysPattern = $scope.backupPolicy.days.map(function (d) { return d.value; }); var hoursPattern; if ($scope.backupPolicy.hours.length === 24) hoursPattern = '*'; else hoursPattern = $scope.backupPolicy.hours.map(function (d) { return d.value; }); var policy = { retention: $scope.backupPolicy.retention, schedule: '00 00 ' + hoursPattern + ' * * ' + daysPattern }; Client.setBackupPolicy(policy, function (error) { $scope.backupPolicy.busy = false; if (error) { if (error.statusCode === 424) { $scope.backupPolicy.error.generic = error.message; } else if (error.statusCode === 400) { $scope.backupPolicy.error.generic = error.message; } else { console.error('Unable to change schedule or retention.', error); } return; } $('#backupPolicyModal').modal('hide'); $scope.backupPolicy.init(); }); } }; $scope.$watch('configureBackup.disk', function (newValue) { if (!newValue) return; $scope.configureBackup.mountOptions.diskPath = '/dev/disk/by-uuid/' + newValue.uuid; }); $scope.configureBackup = { busy: false, error: {}, provider: '', bucket: '', prefix: '', accessKeyId: '', secretAccessKey: '', gcsKey: { keyFileName: '', content: '' }, region: '', endpoint: '', backupFolder: '', mountPoint: '', acceptSelfSignedCerts: false, useHardlinks: true, chown: true, format: 'tgz', password: '', passwordRepeat: '', encryptedFilenames: true, advancedVisible: false, memoryTicks: [], memoryLimit: $scope.MIN_MEMORY_LIMIT, uploadPartSize: 50 * 1024 * 1024, copyConcurrency: '', downloadConcurrency: '', syncConcurrency: '', // sort of similar to upload blockDevices: [], disk: null, mountOptions: { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' }, 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.chown = true; $scope.configureBackup.memoryLimit = $scope.MIN_MEMORY_LIMIT; // scaleway only supports 1000 parts per object (https://www.scaleway.com/en/docs/s3-multipart-upload/) $scope.configureBackup.uploadPartSize = $scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024; $scope.configureBackup.downloadConcurrency = $scope.configureBackup.provider === 's3' ? 30 : 10; $scope.configureBackup.syncConcurrency = $scope.configureBackup.provider === 's3' ? 20 : 10; $scope.configureBackup.copyConcurrency = $scope.configureBackup.provider === 's3' ? 500 : 10; $scope.configureBackup.disk = null; $scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' }; }, show: function () { $scope.configureBackup.error = {}; $scope.configureBackup.busy = false; $scope.configureBackup.advancedVisible = 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.passwordRepeat = ''; $scope.configureBackup.encryptedFilenames = 'encryptedFilenames' in $scope.backupConfig ? $scope.backupConfig.encryptedFilenames : true; $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.chown = $scope.backupConfig.chown; const limits = $scope.backupConfig.limits || {}; $scope.configureBackup.memoryLimit = limits.memoryLimit ? Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT) : $scope.MIN_MEMORY_LIMIT; $scope.configureBackup.uploadPartSize = limits.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024); $scope.configureBackup.downloadConcurrency = limits.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10); $scope.configureBackup.syncConcurrency = limits.syncConcurrency || ($scope.backupConfig.provider === 's3' ? 20 : 10); $scope.configureBackup.copyConcurrency = limits.copyConcurrency || ($scope.backupConfig.provider === 's3' ? 500 : 10); var mountOptions = $scope.backupConfig.mountOptions || {}; $scope.configureBackup.mountOptions = { host: mountOptions.host || '', remoteDir: mountOptions.remoteDir || '', username: mountOptions.username || '', password: mountOptions.password || '', diskPath: mountOptions.diskPath || '', seal: mountOptions.seal, user: mountOptions.user || '', port: mountOptions.port || 22, privateKey: mountOptions.privateKey || '' }; Client.getBlockDevices(function (error, result) { if (error) return console.error('Failed to list blockdevices:', error); // only offer non /, /boot or /home disks result = result.filter(function (d) { return d.mountpoint !== '/' && d.mountpoint !== '/home' && d.mountpoint !== '/boot'; }); // only offer xfs and ext4 disks result = result.filter(function (d) { return d.type === 'xfs' || d.type === 'ext4'; }); // amend label for UI result.forEach(function (d) { d.label = d.path; // pre-select current if set if (d.path === $scope.configureBackup.mountOptions.diskPath || ('/dev/disk/by-uuid/' + d.uuid) === $scope.configureBackup.mountOptions.diskPath) { $scope.configureBackup.disk = d; } }); $scope.configureBackup.blockDevices = result; $('#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 schedulePattern: $scope.backupConfig.schedulePattern, retentionPolicy: $scope.backupConfig.retentionPolicy, limits: { memoryLimit: parseInt($scope.configureBackup.memoryLimit), }, }; if ($scope.configureBackup.password) { backupConfig.password = $scope.configureBackup.password; backupConfig.encryptedFilenames = $scope.configureBackup.encryptedFilenames; // ignored with tgz format } // 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 === 'ionos-objectstorage') { backupConfig.region = $scope.ionosRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'vultr-objectstorage') { backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'contabo-objectstorage') { backupConfig.region = $scope.contaboRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets) } else if (backupConfig.provider === 'upcloud-objectstorage') { // the UI sets region and endpoint var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint); backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'digitalocean-spaces') { backupConfig.region = 'us-east-1'; } else if (backupConfig.provider === 'hetzner-objectstorage') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } backupConfig.limits.uploadPartSize = parseInt($scope.configureBackup.uploadPartSize); } 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 ($scope.mountlike(backupConfig.provider)) { backupConfig.prefix = $scope.configureBackup.prefix; backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks; backupConfig.mountOptions = {}; if (backupConfig.provider === 'cifs' || backupConfig.provider === 'sshfs' || backupConfig.provider === 'nfs') { backupConfig.mountOptions.host = $scope.configureBackup.mountOptions.host; backupConfig.mountOptions.remoteDir = $scope.configureBackup.mountOptions.remoteDir; if (backupConfig.provider === 'cifs') { backupConfig.mountOptions.username = $scope.configureBackup.mountOptions.username; backupConfig.mountOptions.password = $scope.configureBackup.mountOptions.password; backupConfig.mountOptions.seal = $scope.configureBackup.mountOptions.seal; } else if (backupConfig.provider === 'sshfs') { backupConfig.mountOptions.user = $scope.configureBackup.mountOptions.user; backupConfig.mountOptions.port = $scope.configureBackup.mountOptions.port; backupConfig.mountOptions.privateKey = $scope.configureBackup.mountOptions.privateKey; } } else if (backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs' || backupConfig.provider === 'disk') { backupConfig.mountOptions.diskPath = $scope.configureBackup.mountOptions.diskPath; } else if (backupConfig.provider === 'mountpoint') { backupConfig.mountPoint = $scope.configureBackup.mountPoint; backupConfig.chown = $scope.configureBackup.chown; backupConfig.preserveAttributes = true; } } else if (backupConfig.provider === 'filesystem') { backupConfig.backupFolder = $scope.configureBackup.backupFolder; backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks; } if (backupConfig.format === 'rsync') { backupConfig.limits.downloadConcurrency = parseInt($scope.configureBackup.downloadConcurrency); backupConfig.limits.syncConcurrency = parseInt($scope.configureBackup.syncConcurrency); backupConfig.limits.copyConcurrency = parseInt($scope.configureBackup.copyConcurrency); } 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 { $scope.configureBackup.error.generic = error.message; } return; } // $scope.configureBackup.reset(); $('#configureBackupModal').modal('hide'); getBackupConfig(); }); } }; function fetchBackups() { Client.getBackups(function (error, backups) { if (error) return console.error(error); $scope.backups = backups; // add contents property var appsById = {}, appsByFqdn = {}; Client.getInstalledApps().forEach(function (app) { appsById[app.id] = app; appsByFqdn[app.fqdn] = app; }); $scope.backups.forEach(function (backup) { backup.contents = []; // { id, label, fqdn } backup.dependsOn.forEach(function (appBackupId) { const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy if (!match) return; // for example, 'mail' const app = appsById[match[1]]; if (app) { backup.contents.push({ id: app.id, label: app.label, fqdn: app.fqdn }); } else { backup.contents.push({ id: match[1], label: null, fqdn: null }); } }); }); }); } function getBackupConfig() { Client.getBackupConfig(function (error, backupConfig) { if (error) return console.error(error); $scope.backupConfig = backupConfig; $scope.mountStatus = null; if (!$scope.mountlike($scope.backupConfig.provider)) return; Client.getBackupMountStatus(function (error, mountStatus) { if (error) return console.error(error); $scope.mountStatus = mountStatus; }); }); } function getBackupTasks() { Client.getTasksByType(TASK_TYPES.TASK_BACKUP, function (error, tasks) { if (error) return console.error(error); if (!tasks.length) return; $scope.backupTasks = tasks.slice(0, 10); }); } function getCleanupTasks() { Client.getTasksByType(TASK_TYPES.TASK_CLEAN_BACKUPS, function (error, tasks) { if (error) return console.error(error); if (!tasks.length) return; $scope.cleanupTasks = tasks.slice(0, 10); }); } Client.onReady(function () { Client.memory(function (error, memory) { if (error) console.error(error); $scope.memory = memory; var nearestGb = Math.ceil($scope.memory.memory / (1024*1024*1024)) * 1024 * 1024 * 1024; $scope.MAX_MEMORY_LIMIT = nearestGb; fetchBackups(); getBackupConfig(); $scope.archiveList.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.archiveRestore.init(); getBackupTasks(); getCleanupTasks(); }); }); 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', 'editBackupModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find('[autofocus]:first').focus(); }); }); $('.modal-backdrop').remove(); }]);