diff --git a/containers/libreportal/frontend/core/boot/js/system-orchestrator.js b/containers/libreportal/frontend/core/boot/js/system-orchestrator.js
index ebae760..34f28a9 100755
--- a/containers/libreportal/frontend/core/boot/js/system-orchestrator.js
+++ b/containers/libreportal/frontend/core/boot/js/system-orchestrator.js
@@ -109,6 +109,10 @@ class SystemOrchestrator {
return new Promise((resolve, reject) => {
// Create and show setup wizard
const setupWizard = new SetupWizard();
+ // A handle on the running wizard, for the headless tests and for anyone
+ // debugging a step from the console. The instance is otherwise local to
+ // this promise and unreachable once show() returns.
+ window.setupWizard = setupWizard;
setupWizard.initialize(this.setupDetector, async () => {
try {
// Setup completed, continue with normal loading
diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
index dacc61a..30d6084 100755
--- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css
+++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
@@ -1421,6 +1421,7 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
.setup-storage-choice {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 12px;
margin-bottom: 10px;
}
@@ -1445,3 +1446,18 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
margin-top: 4px;
word-break: break-all;
}
+
+/* A custom storage path that cannot be used says so under the box, rather
+ than being quietly swapped for the system disk on the apply side. Its own
+ flex line, offset to sit under the input rather than under the label. */
+.setup-storage-choice-custom { padding-left: 132px; }
+.setup-storage-choice-err {
+ flex: 0 0 100%;
+ margin: -4px 0 0;
+ font-size: 0.82em;
+ color: #ff9b8f;
+}
+.setup-storage-choice-err:empty { display: none; }
+.setup-storage-choice input.is-invalid {
+ border-color: rgba(255, 120, 100, 0.75);
+}
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index c63626a..0888d4e 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -5,6 +5,10 @@
// /api/setup/save which fans out into separate tasks per app, then this UI
// hands off to the tasks page focused on the first task.
+// Marker for "I will type a path" in the two storage dropdowns. Not a valid
+// path itself, so it can never be mistaken for one.
+const CUSTOM_PATH = '__custom__';
+
class SetupWizard {
constructor() {
this.container = null;
@@ -608,10 +612,17 @@ class SetupWizard {
_storageCard(c, key, opts) {
const o = opts || {};
const refused = c.verdict === 'refuse';
+ // A registered location whose drive is not currently mounted is a different
+ // thing from one that has a problem: nothing is wrong with it, it is simply
+ // not here. Calling that "needs care" next to a blank size and an empty
+ // meter told a new user nothing at all — the row looked like a drive that
+ // had failed to load rather than one that is unplugged.
+ const offline = c.state === 'unmounted';
const badge = o.system
? 'system'
- : (refused ? 'can\u2019t be used'
- : (c.verdict === 'warn' ? 'needs care' : ''));
+ : (offline ? 'not connected'
+ : (refused ? 'can\u2019t be used'
+ : (c.verdict === 'warn' ? 'needs care' : '')));
// A registered location has a name the user chose; an unregistered
// candidate only has a path.
@@ -623,8 +634,10 @@ class SetupWizard {
${this.escapeHtml(title)} ${badge}
- ${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' · removable' : ''}
- ${this._storageMeter(c)}
+ ${offline
+ ? `Not mounted right now · ${this.escapeHtml(c.path || '')}`
+ : `${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' · removable' : ''}
+ ${this._storageMeter(c)}`}
@@ -635,8 +648,14 @@ class SetupWizard {
_storageChoices() {
const opts = [{ value: 'primary', label: this._primaryLabel() }];
this.storageCandidates
- .filter(c => c.verdict !== 'refuse')
- .forEach(c => opts.push({ value: c.path, label: c.path }));
+ // Not an unmounted one: choosing it would put app data on a bare
+ // mountpoint, which is the failure this whole feature exists to avoid.
+ .filter(c => c.verdict !== 'refuse' && c.state !== 'unmounted')
+ .forEach(c => opts.push({ value: c.path, label: c.name ? `${c.name} (${c.path})` : c.path }));
+ // Somewhere the scan did not find: a NAS mount, an LVM volume, a directory
+ // on a drive already in use. The scan lists whole filesystems, so anything
+ // that is a path rather than a disk had no way in before this.
+ opts.push({ value: CUSTOM_PATH, label: 'Custom path\u2026' });
return opts;
}
@@ -687,14 +706,23 @@ class SetupWizard {
return;
}
+ // A value that is not one of the offered options is a path the user typed,
+ // so the control has to come back up showing Custom path rather than
+ // silently snapping to the first entry.
+ const isCustom = (v) => !!v && v !== 'primary' && !opts.some(o => o.value === v);
const row = (id, label, tip, value) => `
${label}
?
+
+
+
+
`;
box.innerHTML =
@@ -706,14 +734,61 @@ class SetupWizard {
this.storageDefault) +
'';
- box.querySelector('#sw-storage-system').addEventListener('change', (e) => {
- this.storageSystemChoice = e.target.value;
- this.renderStorageSystemMsg();
- });
- box.querySelector('#sw-storage-apps').addEventListener('change', (e) => {
- this.storageDefault = e.target.value;
- });
+ // Choosing "Custom path" reveals the input and holds the value at empty
+ // until something is typed — the sentinel must never reach the payload.
+ const wire = (id, apply) => {
+ const sel = box.querySelector(`#${id}`);
+ const row = box.querySelector(`#${id}-custom-row`);
+ const inp = box.querySelector(`#${id}-custom`);
+ if (!sel) return;
+ sel.addEventListener('change', (e) => {
+ const custom = e.target.value === CUSTOM_PATH;
+ if (row) row.style.display = custom ? '' : 'none';
+ apply(custom ? (inp && inp.value.trim()) || '' : e.target.value);
+ if (custom && inp) inp.focus();
+ this._syncStorageNav();
+ });
+ if (inp) inp.addEventListener('input', () => {
+ if (sel.value === CUSTOM_PATH) apply(inp.value.trim());
+ this._syncStorageNav();
+ });
+ };
+ wire('sw-storage-system', (v) => { this.storageSystemChoice = v; this.renderStorageSystemMsg(); });
+ wire('sw-storage-apps', (v) => { this.storageDefault = v; });
this.renderStorageSystemMsg();
+ this._syncStorageNav();
+ }
+
+ // A half-typed custom path must not be able to reach the payload. The apply
+ // side would refuse it and fall back to the system disk, and a silent
+ // fallback is indistinguishable from having chosen the system disk on
+ // purpose — so block Continue and say why instead.
+ _customPathProblem(v) {
+ if (!v) return 'Enter a full path, starting with /';
+ if (!v.startsWith('/')) return 'Use a full path, starting with /';
+ if (/\s/.test(v)) return 'Paths with spaces are not supported';
+ if (v === '/' || /^\/(boot|dev|etc|proc|run|sys|usr|bin|sbin|lib|lib64|var)(\/|$)/.test(v)) {
+ return 'Pick a path outside the system directories';
+ }
+ return '';
+ }
+
+ // Returns the first problem found, and paints it beside the offending input.
+ _syncStorageNav() {
+ if (!this.container) return '';
+ let blocked = '';
+ ['sw-storage-system', 'sw-storage-apps'].forEach((id) => {
+ const sel = this.container.querySelector('#' + id);
+ const inp = this.container.querySelector('#' + id + '-custom');
+ const err = this.container.querySelector('#' + id + '-custom-err');
+ if (!sel || !inp) return;
+ const problem = sel.value === CUSTOM_PATH
+ ? this._customPathProblem(inp.value.trim()) : '';
+ if (err) err.textContent = problem;
+ inp.classList.toggle('is-invalid', !!problem);
+ if (problem) blocked = problem;
+ });
+ return blocked;
}
// Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd
@@ -1327,6 +1402,12 @@ class SetupWizard {
}
}
}
+ // 3 = Storage. Only a typed custom path can be invalid; the scanned
+ // options all came from the backend.
+ if (idx === 3) {
+ const problem = this._syncStorageNav();
+ if (problem) return problem;
+ }
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along).
if (idx === 6) {
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md
index e24da05..f651c2e 100644
--- a/docs/roadmap/storage-locations.md
+++ b/docs/roadmap/storage-locations.md
@@ -540,6 +540,65 @@ default-layout host onto it means *every* app takes the stage-and-move branch,
and staging (disk 1) and destination (disk 2) are different devices, so
`app-adopt` exercises its copy path rather than `mv`.
+## 12.6 — The Storage step, after watching someone read it
+
+Two things about the wizard's Storage step were wrong in a way only a fresh
+pair of eyes catches.
+
+**A registered drive that is unplugged.** `storage list` reports it as
+`unmounted`, and the step rendered that through the same path as every other
+candidate: a yellow "needs care" badge, "free of" with no numbers either side,
+and an empty meter. To someone installing for the first time that reads as *the
+scan found two broken disks* — there is nothing on the card connecting it to a
+drive they registered earlier and have since unplugged. It now says **not
+connected**, names the path, and draws no meter, because a meter with nothing in
+it is a claim about free space that was never measured.
+
+The same locations are also withheld from the two dropdowns. The wizard cannot
+stat a directory on a drive that is absent, so it cannot promise an app placed
+there would have anywhere to write.
+
+**Custom paths.** The dropdowns offered only what the scan turned up, which is
+wrong for a NAS mount, an LVM volume, or anything else the disk heuristics do
+not rank as a candidate. Both now end in *Custom path…*, revealing a text box.
+
+The validation lives in `validateStep(3)` rather than in a disabled button. The
+apply side already refuses a relative or system path — but its refusal is to
+fall back to the system disk, and *that is indistinguishable from having chosen
+the system disk on purpose*. This is the failure shape this project keeps
+having to fix (§10, and the compose-guard and `app-data-remove` bugs before it),
+so the step blocks and says which rule the path broke.
+
+A path typed here is not a registered location, so `setup_apply.sh` registers it
+through `storageAdd` before writing `CFG_STORAGE_DEFAULT`. Going through
+`storageAdd` rather than writing the path straight into the config is what keeps
+the empty-directory admission rule and the fitness checks in play — the
+alternative silently adopts a directory full of someone else's data. It is named
+after its own basename, so it reads as `nas` in the placement menus rather than
+`location-3`.
+
+`libreportal storage remove` also learned to accept the name the listing prints.
+It matched on id and path only, so `remove location-3` failed against a row the
+table displayed as `location-3`.
+
+### What the test found about the tests
+
+`lp-storage-custom-test` drives the step in a real browser. Two things it caught
+about itself are worth recording, because both are the same shape as the bugs it
+exists to prevent — a check whose failure mode is to not run.
+
+It first counted "how many cards say *not connected*" and asserted things about
+those. Disabling the feature makes that count zero, `every()` over an empty list
+is true, and the whole block passed while asserting nothing. The expected count
+now comes from the feed (`storageCandidates` in state `unmounted`), so removing
+the rendering is a failure rather than an empty set.
+
+And both browser tests skipped — exit 0 — whenever the page returned nothing.
+Run under `sudo`, where chromium refuses to start, they reported PASS having
+checked nothing at all. They now probe the WebUI with `lp-shot --url` and curl:
+if it answers HTTP then the browser is the only thing that can have broken, and
+that is a failure, not a skip.
+
## 13. Open questions
1. ~~**Naming.** "Storage location" vs "backup location" in the same UI~~ — **resolved (2026-08-24):** build the Disks view (§7.1). The device becomes the organising concept and the two registries become *roles* on it, so the user never has to hold the distinction to understand their own hardware. Registries stay separate underneath.
diff --git a/init.sh b/init.sh
index 8fe65c9..5e1ad15 100755
--- a/init.sh
+++ b/init.sh
@@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal"
# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump
# tells the updater the new release needs a root re-install (which re-bakes them).
# Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md.
-footprint_version=10
+footprint_version=11
footprint_marker="$lp_lib_dir/.footprint_version"
# Directories — three independently-relocatable roots (see scripts/source/paths.sh
diff --git a/scripts/dev/lp-backup-dialog-test b/scripts/dev/lp-backup-dialog-test
index b7ee99e..8cbe36f 100755
--- a/scripts/dev/lp-backup-dialog-test
+++ b/scripts/dev/lp-backup-dialog-test
@@ -23,6 +23,16 @@ fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
+# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
+# then the browser is the only thing that can have failed, and that is a
+# failure — skipping there is how this test reported success under sudo, where
+# chromium refuses to start at all.
+lp_reachable() {
+ local u; u=$("$SHOT" --url 2>/dev/null) || return 1
+ [[ -n "$u" ]] || return 1
+ curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
+}
+
read -r -d '' DRIVE <<'JS'
const DUMMY = 'dummy-not-a-real-secret-0000';
const out = {};
@@ -139,7 +149,10 @@ JS
OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null)
if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then
- echo " SKIP no WebUI reachable, or the step did not load"
+ if lp_reachable; then
+ echo " FAIL the WebUI is up but the step returned nothing (browser failed?)"; exit 1
+ fi
+ echo " SKIP no WebUI reachable"
exit 0
fi
if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then
diff --git a/scripts/dev/lp-shot b/scripts/dev/lp-shot
index 395f74b..c9d4be6 100755
--- a/scripts/dev/lp-shot
+++ b/scripts/dev/lp-shot
@@ -14,6 +14,7 @@ Environment:
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
--eval ROUTE JS run JS in the page and print the result; no screenshot
+ --url print the base URL that would be used, and exit
LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a
tab, expand a row. Awaited, so async handlers finish.
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
@@ -369,6 +370,14 @@ def main():
#
# lp-shot --token -> the raw cookie VALUE
# lp-shot --cookie-js -> a document.cookie assignment to paste/eval
+ # --url prints the base URL that would be used, and nothing else. Tests
+ # probe it with curl to tell "the WebUI is down, skip" apart from "the
+ # WebUI is up and the browser broke" — a browser test that skips on both
+ # reports success while asserting nothing.
+ if len(sys.argv) > 1 and sys.argv[1] == "--url":
+ print(base_url())
+ sys.exit(0)
+
if len(sys.argv) > 1 and sys.argv[1] in ("--token", "--cookie-js"):
token, src = mint_token()
if os.environ.get("LP_SHOT_VERBOSE"):
diff --git a/scripts/dev/lp-storage-custom-test b/scripts/dev/lp-storage-custom-test
new file mode 100755
index 0000000..de81110
--- /dev/null
+++ b/scripts/dev/lp-storage-custom-test
@@ -0,0 +1,138 @@
+#!/bin/bash
+# Drive the wizard's Storage step in a real browser.
+#
+# scripts/dev/lp-storage-custom-test # needs a running WebUI
+#
+# Two things this guards.
+#
+# A location whose drive is absent used to render as a warn card — "needs care",
+# "free of" with no numbers, an empty meter. A new user has no way to read that
+# as "this is a drive you registered and it is unplugged", so it looked like the
+# scan had found two broken disks. It must now say it is not connected, name the
+# path, and draw no meter.
+#
+# And a typed custom path must never reach the payload half-typed. The apply
+# side refuses a relative or system path and falls back to the system disk —
+# a fallback that looks exactly like having chosen the system disk on purpose,
+# which is the failure shape this project keeps having to fix. validateStep
+# has to block it at the step instead.
+#
+# One page load: the whole interaction runs in a single `lp-shot --eval`.
+
+REPO="$(cd "$(dirname "$0")/../.." && pwd)"
+SHOT="$REPO/scripts/dev/lp-shot"
+fail=0
+chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
+command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
+
+# A skip must not be able to stand in for a pass. If the WebUI answers HTTP
+# then the browser is the only thing that can have failed, and that is a
+# failure — skipping there is how this test reported success under sudo, where
+# chromium refuses to start at all.
+lp_reachable() {
+ local u; u=$("$SHOT" --url 2>/dev/null) || return 1
+ [[ -n "$u" ]] || return 1
+ curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
+}
+
+read -r -d '' DRIVE <<'JS'
+ const out = {};
+ const w = window.setupWizard;
+ if (!w) return JSON.stringify({ error: 'wizard handle missing' });
+ const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
+
+ const sel = document.querySelector('#sw-storage-apps');
+ const inp = document.querySelector('#sw-storage-apps-custom');
+ const row = document.querySelector('#sw-storage-apps-custom-row');
+ const err = document.querySelector('#sw-storage-apps-custom-err');
+ if (!sel || !inp || !row) return JSON.stringify({ error: 'storage step not rendered' });
+
+ // An unmounted location must not be offered as somewhere to put app data:
+ // the wizard cannot check its free space or write to it.
+ // How many SHOULD be offline comes from the feed, not from the rendering.
+ // Counting the cards that say "not connected" would make every assertion
+ // below vacuous the moment the feature stopped working.
+ const cards = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card'));
+ const expected = (w.storageCandidates || []).filter(c => c.state === 'unmounted');
+ out.expectedOffline = expected.length;
+ const offline = cards.filter(c =>
+ expected.some(e => e.path && c.textContent.includes(e.path)));
+ out.offlineCards = offline.length;
+ out.offlineSayNotConnected = offline.length > 0
+ && offline.every(c => /not connected/i.test(c.textContent));
+ out.offlineSaysNotMounted = offline.length > 0 && offline.every(c => /not mounted right now/i.test(c.textContent));
+ out.offlineNamesPath = offline.length > 0 && offline.every(c => /·\s*\//.test(c.textContent));
+ out.offlineHasNoMeter = offline.length > 0 && offline.every(c => !c.querySelector('.setup-storage-meter'));
+ out.offlineSaysNeedsCare = offline.some(c => /needs care/i.test(c.textContent));
+ out.offlineOffered = Array.from(sel.options)
+ .some(o => offline.some(c => o.value && c.textContent.includes(o.value)));
+
+ out.hasCustomOption = Array.from(sel.options).some(o => o.value === '__custom__');
+ out.rowHiddenAtRest = row.style.display === 'none';
+
+ sel.value = '__custom__'; fire(sel, 'change');
+ out.rowShownOnPick = row.style.display !== 'none';
+ out.emptyBlocks = !!w.validateStep(3);
+
+ const probe = (v) => { inp.value = v; fire(inp, 'input');
+ return { blocks: !!w.validateStep(3),
+ marked: inp.classList.contains('is-invalid'),
+ said: (err && err.textContent) || '' }; };
+ out.relative = probe('mnt/nas');
+ out.systemDir = probe('/etc/libreportal');
+ out.root = probe('/');
+ out.spaces = probe('/mnt/my disk');
+ out.good = probe('/mnt/nas/apps');
+ out.goodStored = w.storageDefault;
+
+ // Back to a scanned option: never blocked, and the error clears.
+ sel.value = 'primary'; fire(sel, 'change');
+ out.primaryBlocks = !!w.validateStep(3);
+ out.primaryStored = w.storageDefault;
+ return JSON.stringify(out);
+JS
+
+J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null | tr "'" '"' | sed 's/\bTrue\b/true/g; s/\bFalse\b/false/g')
+if [[ -z "$J" ]]; then
+ if lp_reachable; then
+ echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
+ fi
+ echo " SKIP no WebUI reachable"; exit 0
+fi
+g(){ echo "$J" | jq -r "$1" 2>/dev/null; }
+
+if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
+
+echo "unmounted locations"
+if [[ "$(g .expectedOffline)" == "0" ]]; then
+ echo " SKIP no unmounted location registered — nothing to check"
+else
+ chk "every one has a card" "$(g .offlineCards)" "$(g .expectedOffline)"
+ chk "badged 'not connected'" "$(g .offlineSayNotConnected)" true
+ chk "say they are not mounted" "$(g .offlineSaysNotMounted)" true
+ chk "name the path" "$(g .offlineNamesPath)" true
+ chk "draw no free-space meter" "$(g .offlineHasNoMeter)" true
+ chk "do not say 'needs care'" "$(g .offlineSaysNeedsCare)" false
+ chk "are not offered as a target" "$(g .offlineOffered)" false
+fi
+
+echo "custom path"
+chk "the option exists" "$(g .hasCustomOption)" true
+chk "input hidden until picked" "$(g .rowHiddenAtRest)" true
+chk "input shown once picked" "$(g .rowShownOnPick)" true
+chk "empty blocks the step" "$(g .emptyBlocks)" true
+chk "relative path blocks" "$(g .relative.blocks)" true
+chk "relative path is marked" "$(g .relative.marked)" true
+chk "relative path says why" "$(g '.relative.said != ""')" true
+chk "system dir blocks" "$(g .systemDir.blocks)" true
+chk "/ blocks" "$(g .root.blocks)" true
+chk "path with spaces blocks" "$(g .spaces.blocks)" true
+chk "an absolute path passes" "$(g .good.blocks)" false
+chk "and is not marked" "$(g .good.marked)" false
+chk "and its error is cleared" "$(g '.good.said == ""')" true
+chk "and it is what gets stored" "$(g .goodStored)" /mnt/nas/apps
+chk "a scanned option never blocks" "$(g .primaryBlocks)" false
+chk "and replaces the custom value" "$(g .primaryStored)" primary
+
+[[ $fail -eq 0 ]] && echo "storage custom-path test: OK"
+exit $fail
diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh
index 16da386..2a777a6 100644
--- a/scripts/setup/setup_apply.sh
+++ b/scripts/setup/setup_apply.sh
@@ -102,6 +102,29 @@ setupApplyConfig()
if declare -f storageLocationName >/dev/null 2>&1; then
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
fi
+ # A path typed into the wizard's "Custom path" box is not a registered
+ # location yet, so the lookup above finds nothing. Register it — through
+ # storageAdd, because that is what enforces the empty-directory
+ # admission rule and the fitness checks; writing the path straight into
+ # the config would skip both and is how an app ends up on a directory
+ # root never agreed to own.
+ if [[ -z "$default_name" || "$default_name" == "default" ]] \
+ && [[ "$storage_default" == /* ]] \
+ && declare -f storageAdd >/dev/null 2>&1; then
+ # Named after the directory, so it reads as "nas" in the app
+ # placement menus rather than "location-3".
+ local custom_name
+ custom_name=$(basename -- "$storage_default" | tr -c 'a-zA-Z0-9-' '-' | sed 's/^-*//; s/-*$//')
+ [[ -z "$custom_name" ]] && custom_name="custom"
+ if storageAdd "$storage_default" "$custom_name" >/dev/null 2>&1; then
+ storageCacheReset 2>/dev/null || true
+ default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
+ [[ -n "$default_name" ]] && isSuccessful "Registered '$storage_default' as a storage location."
+ else
+ isNotice "Could not use '$storage_default' — it must be an empty directory outside the system paths."
+ fi
+ fi
+
if [[ -n "$default_name" && "$default_name" != "default" ]]; then
updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name"
isSuccessful "New apps will store their data on '$default_name'"
diff --git a/scripts/system/libreportal-storage b/scripts/system/libreportal-storage
index ab2062c..4d947f1 100755
--- a/scripts/system/libreportal-storage
+++ b/scripts/system/libreportal-storage
@@ -413,6 +413,16 @@ remove() {
if [[ "$_id" == "$want" || "${_path%/}" == "${want%/}" ]]; then
found_path="${_path%/}"; found_id="$_id"; break
fi
+ # Also match the name the listing prints, since that is what a person
+ # reading the table will type. Includes the "location-N" fallback the
+ # marker records when a location was added without one.
+ if [[ -f "${_path%/}/$MARKER" ]]; then
+ local _name
+ _name=$(sed -n 's/^name=//p' "${_path%/}/$MARKER" 2>/dev/null | head -1)
+ if [[ -n "$_name" && "$_name" == "$want" ]]; then
+ found_path="${_path%/}"; found_id="$_id"; break
+ fi
+ fi
done < "$REGISTRY"
[[ -n "$found_id" ]] || { _err "no such location: $want"; return 1; }