diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 4c72abc..e1e093d 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -21,8 +21,8 @@ class SetupWizard { // Storage sits BEFORE Recommended on purpose: a location has to exist // before an app can be placed on it, and the Recommended step can then // offer the big apps a home other than the system disk. - this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Recommended', 'Metrics']; - this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛡️', '📊']; + this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Recommended', 'Metrics']; + this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '🛡️', '📊']; // Storage is skipped entirely when this box has nowhere else to put things // — one disk means one answer, and a step with nothing in it is noise. // Set by loadStorage() once the candidate scan comes back. @@ -36,6 +36,8 @@ class SetupWizard { this.storageDefault = 'primary'; // Where LibrePortal's own tree should live (root-only to change post-install). this.storageSystemChoice = 'primary'; + // Backup destination: '' = none, 'primary' = system disk, else a drive path. + this.backupDest = ''; this.installLevel = 'beginner'; this.totalSteps = this._effectiveTotalSteps(); this.domainCount = 0; // tracked dynamically as the user adds rows @@ -247,8 +249,21 @@ class SetupWizard { - +
+
+
Backups + ? +
+
+

+
+
+ + +
Recommended Apps

Pre-selected to give you a working install out of the box.

@@ -272,7 +287,7 @@ class SetupWizard { default — they're only useful if the user wants the MONITORING toggle on apps to do anything. Advanced-only: this whole step is skipped when the user chose Beginner on step 1. --> -
+
Metrics Apps

Optional. Install these to enable per-app "Export metrics to Grafana" later.

@@ -543,6 +558,7 @@ class SetupWizard { }); this.renderStorageChoices(); + this.renderBackupChoices(); if (note) { note.innerHTML = this.storageCandidates.length @@ -564,6 +580,8 @@ class SetupWizard { this.storageDefault = 'primary'; // Where LibrePortal's own tree should live (root-only to change post-install). this.storageSystemChoice = 'primary'; + // Backup destination: '' = none, 'primary' = system disk, else a drive path. + this.backupDest = ''; this.storageSystemChoice = 'primary'; return; } @@ -617,6 +635,70 @@ class SetupWizard { sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system`; } + // Backups: one question, asked at setup rather than left to be discovered. + // + // Offered destinations are the drives we already scanned, minus the one + // holding app data where we can tell — a backup on the same disk as the data + // survives a bad update or a deletion, but not the disk dying, and that is + // the case people assume they are covered for. + renderBackupChoices() { + const box = this.container.querySelector('#sw-backup-choices'); + const note = this.container.querySelector('#sw-backup-note'); + if (!box) return; + + const appTarget = this.storageDefault && this.storageDefault !== 'primary' + ? this.storageDefault : (this.storageSystem ? this.storageSystem.mount : '/'); + + const opts = [{ value: '', label: 'Not now — set this up later' }]; + this.storageCandidates + .filter(c => c.verdict !== 'refuse') + .forEach(c => opts.push({ value: c.path, label: c.path, shared: c.path === appTarget })); + opts.push({ value: 'primary', label: 'System disk', shared: this.storageDefault === 'primary' }); + + box.innerHTML = ` +
+ Back up to + +
+ `; + + box.querySelector('#sw-backup-dest').addEventListener('change', (e) => { + this.backupDest = e.target.value; + this.renderBackupMsg(opts); + }); + this.renderBackupMsg(opts); + + if (note) { + note.innerHTML = this.storageCandidates.length + ? '' + : 'Only one drive found. A backup here still protects against deletions and bad updates \u2014 but not against this disk failing. Add another destination later from the Backup page.'; + } + } + + renderBackupMsg(opts) { + const msg = this.container.querySelector('#sw-backup-msg'); + if (!msg) return; + const chosen = opts.find(o => o.value === this.backupDest); + + if (!this.backupDest) { + msg.style.display = ''; + msg.innerHTML = 'No backups will be taken. You can set this up any time from the Backup page \u2014 but nothing is protected until you do.'; + return; + } + if (chosen && chosen.shared) { + msg.style.display = ''; + msg.innerHTML = 'This is the same drive your app data is on. That still covers accidental deletion, ' + + 'a bad update and ransomware \u2014 but not this disk failing, since the data and its only copy would go together.'; + return; + } + msg.style.display = ''; + msg.innerHTML = 'The repository is encrypted and its password is generated during install. ' + + 'Write it down somewhere other than this machine \u2014 without it a backup cannot be opened. ' + + 'It is shown on the Backup page once setup finishes.'; + } + // Details modal — the technical spec, every check with its full explanation, // and (when the drive isn't in fstab) the offer to make it permanent. // @@ -779,8 +861,8 @@ class SetupWizard { } } } - // 4 = Recommended (Storage was inserted at 3, shifting this along). - if (idx === 4) { + // 5 = Recommended (Storage at 3 and Backups at 4 shifted this along). + if (idx === 5) { const traefikBox = this.container.querySelector('input[data-app="traefik"]'); if (traefikBox && traefikBox.checked) { const tEmail = $('#sw-traefik-email').value.trim(); @@ -1139,7 +1221,9 @@ class SetupWizard { storage_default: (this.storageDefault && this.storageDefault !== 'primary') ? this.storageDefault : 'primary', // Recorded so the finished-setup screen can repeat the relocate command. // Nothing acts on it: moving LibrePortal's own tree needs real root. - storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary' + storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary', + // '' = don't configure backups; 'primary' = system disk; else a drive path. + backup_dest: this.backupDest || '' }; // Apply the experience choice to the WebUI immediately so the next diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index d3f6c55..7b27cb8 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -27,6 +27,7 @@ setupApplyConfig() local storage_json=$(echo "$payload" | jq -c '.storage // []') local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []') local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"') + local backup_dest=$(echo "$payload" | jq -r '.backup_dest // ""') if [[ -n "$install_name" ]]; then updateConfigOption "CFG_INSTALL_NAME" "$install_name" @@ -107,6 +108,47 @@ setupApplyConfig() fi fi + # Backup destination chosen on the Backups step. Configured here rather than + # left for the user to find later: the people most likely to need a restore + # are the ones who never got round to setting one up. + # + # Created disabled by locationAdd, so enable it and initialise the repo — + # an un-initialised location is a destination that silently backs up + # nothing. + if [[ -n "$backup_dest" && "$backup_dest" != "null" ]]; then + local bpath bname + if [[ "$backup_dest" == "primary" ]]; then + bpath="${backup_dir%/}" + bname="local" + else + bpath="${backup_dest%/}/libreportal-backups" + bname="${backup_dest##*/}" + fi + + local bidx + if bidx=$(locationAdd "$bname" local 2>/dev/null | tail -1) && [[ "$bidx" =~ ^[0-9]+$ ]]; then + local bcfg; bcfg=$(backupLocationConfig "$bidx") + if [[ "$backup_dest" != "primary" ]]; then + updateConfigOption "CFG_BACKUP_LOC_${bidx}_PATH_MODE" "custom" "$bcfg" >/dev/null + updateConfigOption "CFG_BACKUP_LOC_${bidx}_PATH" "$bpath" "$bcfg" >/dev/null + fi + updateConfigOption "CFG_BACKUP_LOC_${bidx}_ENABLED" "true" "$bcfg" >/dev/null + source "$bcfg" 2>/dev/null + + if engineInitLocation "$bidx" >/dev/null 2>&1; then + isSuccessful "Backups will go to $bpath" + # Deliberately NOT echoed here: the repository password is a + # secret and task output is logged. It is shown on the Backup + # page, which is what the wizard told the user. + isNotice "The repository password is on the Backup page — keep a copy somewhere other than this machine." + else + isNotice "Backup location '$bname' was created but could not be initialised — open the Backup page to finish it." + fi + else + isNotice "Could not create a backup location at $bpath — set one up from the Backup page." + fi + fi + local domains_count=$(echo "$domains_json" | jq -r 'length') if [[ "$domains_count" -gt 0 ]]; then local i=0