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