Files
cloudron-box/dashboard/public/views/backups.js
T
Girish Ramakrishnan 41bc08a07e backup: move appConfig to backups table
this is useful for clone also to copy notes, operators, checklist
of the time when the backup was made (as opposed to current)

at this point, it's not clear why we need a archives table. it's
an optimization to not have to store icon for every backup.
2024-12-10 21:04:37 +01:00

1110 lines
48 KiB
JavaScript

'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,
app: null, // just for simpler access . it's a fake app object!
ask: function (archive) {
$scope.archiveDelete.busy = false;
$scope.archiveDelete.error = {};
$scope.archiveDelete.archive = archive;
$scope.archiveDelete.app = archive.appConfig;
$('#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,
app: null, // just for simpler access . it's a fake app object!
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;
const manifest = archive.appConfig.manifest;
$scope.archiveRestore.app = archive.appConfig;
$scope.archiveRestore.subdomain = $scope.archiveRestore.app.subdomain;
$scope.archiveRestore.domain = $scope.domains.find(function (d) { return $scope.archiveRestore.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 = 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
$scope.archiveRestore.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({}, manifest.tcpPorts, manifest.udpPorts); // Portbinding map only for information
// set default ports
for (var env in $scope.archiveRestore.portInfo) {
if ($scope.archiveRestore.app.portBindings[env]) { // was enabled in the app
$scope.archiveRestore.ports[env] = $scope.archiveRestore.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) {
// 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];
});
var filename = 'cloudron-backup-config-' + (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + ' (' + $scope.config.adminFqdn + ')' + '.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();
}]);