diff --git a/dashboard/src/components/AppImportDialog.vue b/dashboard/src/components/AppImportDialog.vue index 294f1f9ed..376f8f54c 100644 --- a/dashboard/src/components/AppImportDialog.vue +++ b/dashboard/src/components/AppImportDialog.vue @@ -6,6 +6,7 @@ import { s3like } from '../utils.js'; import BackupProviderForm from './BackupProviderForm.vue'; import AppsModel from '../models/AppsModel.js'; import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_WASABI } from '../constants.js'; +import { SECRET_PLACEHOLDER } from '../constants.js'; const appsModel = AppsModel.create(); @@ -18,6 +19,9 @@ const formError = ref({}); const providerConfig = ref({}); const provider = ref(''); const remotePath = ref(''); +const format = ref(''); +const encryptionPassword = ref(''); +const encryptedFilenames = ref(false); async function onSubmit() { if (!form.value.reportValidity()) return; @@ -163,25 +167,23 @@ function onBackupConfigChanged(event) { reader.onload = function (result) { if (!result.target || !result.target.result) return console.error('Unable to read backup config'); - let config; + let data; try { - config = JSON.parse(result.target.result); - if (config.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path - config.remotePath = config.backupFolder + '/' + config.remotePath; - delete config.backupFolder; + data = JSON.parse(result.target.result); // 'provider', 'config', 'limits', 'format', 'remotePath', 'encrypted', 'encryptedFilenames' + if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path + data.remotePath = `${data.config.backupFolder}/${data.remotePath}`; } } catch (e) { console.error('Unable to parse backup config', e); return; } - provider.value = config.provider; - remotePath.value = config.remotePath; - - // we assume property names match here, this does not yet work for gcs keys - Object.keys(config).forEach(function (k) { - providerConfig.value[k] = config[k]; - }); + provider.value = data.provider; + remotePath.value = data.remotePath; + providerConfig.value = data.config; + format.value = data.format; + encryptionPassword.value = data.encrypted ? SECRET_PLACEHOLDER : ''; + encryptedFilenames.value = data.encryptedFilenames; }; reader.readAsText(event.target.files[0]); @@ -233,7 +235,14 @@ defineExpose({ - + diff --git a/dashboard/src/components/BackupProviderForm.vue b/dashboard/src/components/BackupProviderForm.vue index 6fcbdddca..cd9ba3765 100644 --- a/dashboard/src/components/BackupProviderForm.vue +++ b/dashboard/src/components/BackupProviderForm.vue @@ -10,6 +10,10 @@ import { mountlike, s3like } from '../utils.js'; const provider = defineModel('provider'); const providerConfig = defineModel('providerConfig'); +const format = defineModel('format'); +const encryptionPassword = defineModel('encryptionPassword'); +const encryptedFilenames = defineModel('encryptedFilenames'); + const props = defineProps({ formError: {}, importOnly: { @@ -292,16 +296,16 @@ onMounted(async () => { - -
{{ $t('backups.configureBackupStorage.s3LikeNote') }}
+ +
{{ $t('backups.configureBackupStorage.s3LikeNote') }}
- + -
{{ $t('backups.configureBackupStorage.encryptionDescription') }}
- +
{{ $t('backups.configureBackupStorage.encryptionDescription') }}
+

{{ $t('backups.configureBackupStorage.advancedSettings') }}

@@ -325,19 +329,19 @@ onMounted(async () => { - +
{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}
- +
{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}
- +
{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }} {{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }} diff --git a/dashboard/src/components/app/Backups.vue b/dashboard/src/components/app/Backups.vue index fdf3dbe06..a60fff0a2 100644 --- a/dashboard/src/components/app/Backups.vue +++ b/dashboard/src/components/app/Backups.vue @@ -7,18 +7,16 @@ const t = i18n.t; import { ref, onMounted, useTemplateRef } from 'vue'; import { Icon, Button, Switch, Checkbox, FormGroup, TextInput, TableView, ButtonGroup, Dialog, ProgressBar } from '@cloudron/pankow'; import { prettyLongDate } from '@cloudron/pankow/utils'; -import { API_ORIGIN, SECRET_PLACEHOLDER } from '../../constants.js'; +import { API_ORIGIN } from '../../constants.js'; import { download } from '../../utils.js'; import AppImportDialog from '../AppImportDialog.vue'; import AppRestoreDialog from '../AppRestoreDialog.vue'; import SettingsItem from '../SettingsItem.vue'; import AppsModel from '../../models/AppsModel.js'; -import BackupsModel from '../../models/BackupsModel.js'; import BackupTargetsModel from '../../models/BackupTargetsModel.js'; import TasksModel from '../../models/TasksModel.js'; const appsModel = AppsModel.create(); -const backupsModel = BackupsModel.create(); const backupTargetsModel = BackupTargetsModel.create(); const tasksModel = TasksModel.create(); @@ -128,7 +126,6 @@ async function onStopBackup() { stopBackupBusy.value = false; } - function onEdit(backup) { editBusy.value = false; editBackup.value = backup; @@ -159,16 +156,10 @@ async function onDownloadConfig(backup) { const [error, backupTarget] = await backupTargetsModel.get(backup.targetId); if (error) return console.error(error); - // secrets and tokens already come with placeholder characters we remove them const tmp = { - remotePath: backup.remotePath, - encrypted: !!backupTarget.password, // we add this just to help the import UI - encryptedFilenames: !!backupTarget.encryptedFilenames + remotePath: backup.remotePath }; - - console.log(backupTarget); - - for (const k of ['provider', 'config', 'limits', 'format']) { + for (const k of ['provider', 'config', 'limits', 'format', 'encrypted', 'encryptedFilenames']) { tmp[k] = backupTarget[k]; } diff --git a/src/apps.js b/src/apps.js index 1651bb937..2ecc13a47 100644 --- a/src/apps.js +++ b/src/apps.js @@ -2352,31 +2352,31 @@ async function importApp(app, data, auditSource) { const appId = app.id; - const { remotePath, backupFormat, backupConfig } = data; + const { remotePath, format, encryptionPassword, encryptedFilenames, config } = data; let error = checkAppState(app, exports.ISTATE_PENDING_IMPORT); if (error) throw error; - let restoreConfig; + let encryption, restoreConfig; if (data.remotePath) { // if not provided, we import in-place - error = backupTargets.validateFormat(backupFormat); + error = backupTargets.validateFormat(format); if (error) throw error; - if ('password' in backupConfig) { - backupConfig.encryption = hush.generateEncryptionKeysSync(backupConfig.password); - delete backupConfig.password; + if (encryptionPassword) { + encryption = hush.generateEncryptionKeysSync(encryptionPassword); + encryption.encryptedFilenames = !!encryptedFilenames; } else { - backupConfig.encryption = null; + encryption = null; } - await backupTargets.setupManagedStorage(backupConfig, `/mnt/appimport-${app.id}`); // this validates mountOptions . this is not cleaned up, it's fine - backupConfig.rootPath = backupTargets.getRootPath(backupConfig, `/mnt/appimport-${app.id}`); - error = await backupTargets.testStorage(Object.assign({ mountPath: `/mnt/appimport-${app.id}` }, backupConfig)); // this validates provider and it's api options. requires mountPath + await backupTargets.setupManagedStorage(config, `/mnt/appimport-${app.id}`); // this validates mountOptions . this is not cleaned up, it's fine + config.rootPath = backupTargets.getRootPath(config, `/mnt/appimport-${app.id}`); + error = await backupTargets.testStorage(Object.assign({ mountPath: `/mnt/appimport-${app.id}` }, config)); // this validates provider and it's api options. requires mountPath if (error) throw error; - restoreConfig = { remotePath, backupFormat, backupConfig }; - } else { + restoreConfig = { remotePath, backupFormat: format, backupConfig: config }; + } else { // inPlace restoreConfig = { remotePath: null }; } diff --git a/src/backuptargets.js b/src/backuptargets.js index d799f89c8..0cac1402e 100644 --- a/src/backuptargets.js +++ b/src/backuptargets.js @@ -96,11 +96,10 @@ function postProcess(result) { function removePrivateFields(target) { assert.strictEqual(typeof target, 'object'); - if (target.encryption) { - target.encryptedFilenames = target.encryption.encryptedFilenames; - delete target.encryption; - target.password = constants.SECRET_PLACEHOLDER; - } + target.encrypted = target.encryption !== null; + target.encryptedFilenames = target.encryption?.encryptedFilenames || false; + delete target.encryption; + delete target.config.rootPath; target.config = storage.api(target.provider).removePrivateFields(target.config); return target; diff --git a/src/routes/apps.js b/src/routes/apps.js index a82e56d5d..c5bcefbf1 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -575,22 +575,21 @@ async function importApp(req, res, next) { const data = req.body; - if ('remotePath' in data) { // if not provided, we import in-place + if ('remotePath' in data) { if (typeof data.remotePath !== 'string' || !data.remotePath) return next(new HttpError(400, 'remotePath must be non-empty string')); - if (typeof data.backupFormat !== 'string') return next(new HttpError(400, 'backupFormat must be string')); + if (typeof data.format !== 'string') return next(new HttpError(400, 'format must be string')); + if (typeof config.provider !== 'string') return next(new HttpError(400, 'provider is required')); + if (typeof data.config !== 'object' || !data.config) return next(new HttpError(400, 'config must be an object')); - if ('backupConfig' in data && typeof data.backupConfig !== 'object') return next(new HttpError(400, 'backupConfig must be an object')); + const config = req.body.config; - const backupConfig = req.body.backupConfig; + if ('encryptionPassword' in config && typeof config.encryptionPassword !== 'string') return next(new HttpError(400, 'encryptionPassword must be a string')); + if ('encryptedFilenames' in config && typeof config.encryptedFilenames !== 'boolean') return next(new HttpError(400, 'encryptedFilenames must be a boolean')); - if (backupConfig) { - if (typeof backupConfig.provider !== 'string') return next(new HttpError(400, 'provider is required')); - if ('password' in backupConfig && typeof backupConfig.password !== 'string') return next(new HttpError(400, 'password must be a string')); - if ('encryptedFilenames' in backupConfig && typeof backupConfig.encryptedFilenames !== 'boolean') return next(new HttpError(400, 'encryptedFilenames must be a boolean')); - - // testing backup config can take sometime - req.clearTimeout(); - } + // testing backup config can take sometime + req.clearTimeout(); + } else { + if (typeof data.inPlace !== 'boolean') return next(new HttpError(400, 'remotePath or inPlace is required')); } const [error, result] = await safe(apps.importApp(req.resources.app, data, AuditSource.fromRequest(req)));