v0.321.0: FileBrowser's stale password and its own banners; one backup section on the Data tab; install address of an installed app
gates / gates (push) Successful in 43s

R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-16 17:17:26 +02:00
parent c02c9d833a
commit 7e791d9b5d
30 changed files with 1262 additions and 363 deletions
@@ -186,7 +186,8 @@ func fbSelfID(do fbHTTPDo, base, token string) (int, error) {
// The password is 16 letters and digits (generateValue "password:16") — never a comma, which the CLI's `user,pw`
// form would split on. It is on the one-shot container's command line for about a second, inside the box.
// ErrFileBrowserNotOperator: the reset is offered only while the box does not know the password.
// ErrFileBrowserNotOperator: the reset is offered only while the box does not know the password (`operator`, or
// `generated` with the stored value refused — R-1101).
var ErrFileBrowserNotOperator = fmt.Errorf("the file manager's password is not in the operator state")
// fileBrowserContainer is the infra container's fixed name (infra.RenderFileBrowserCompose).
@@ -203,7 +204,9 @@ func (m *Manager) ResetFileBrowserAdminPassword(ctx context.Context) error {
// The base-stack tick must not start the file browser while it is stopped for the CLI.
m.infraMu.Lock()
defer m.infraMu.Unlock()
if state, _, _ := m.settings.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator {
// v0.321.0 (R-1101): also when the stored password is refused — the household changed it in FileBrowser.
if state, _, _ := m.settings.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator &&
!(state == settings.FileBrowserAdminGenerated && m.FileBrowserStoredPasswordRefused()) {
return ErrFileBrowserNotOperator
}
if len(m.encKey) == 0 {
@@ -303,3 +306,44 @@ func fileBrowserCLISetPassword(ctx context.Context, pw string) error {
}
return nil
}
// ── v0.321.0 (R-1101): the stored password, checked against FileBrowser ─────────────────────────────────────────────
//
// In the `generated` state the box shows the password it set. If the household changed it in FileBrowser, that value
// is refused, and the page used to keep offering it. The page now asks FileBrowser first (once per page load, the same
// login R-513 uses, the value never logged) and, on a refusal, says so and offers the new-password press.
// fbCheckTimeout bounds the page's check, so a FileBrowser that does not answer never holds the page.
const fbCheckTimeout = 3 * time.Second
// FileBrowserStoredPasswordRefused reports true ONLY when FileBrowser positively refuses the stored password (401/403)
// in the `generated` state. Anything else — another state, no stored value, a decrypt error, FileBrowser not
// answering — is false: an unproven refusal must not tell the household its password changed. Pinned by
// TestFileBrowserStoredPassword_RefusedOnlyOnA401.
func (m *Manager) FileBrowserStoredPasswordRefused() bool {
if m.settings == nil || len(m.encKey) == 0 {
return false
}
state, enc, _ := m.settings.GetFileBrowserAdmin()
if state != settings.FileBrowserAdminGenerated || enc == "" {
return false
}
pw, err := crypto.Decrypt(m.encKey, enc)
if err != nil || strings.TrimSpace(pw) == "" {
return false
}
do := m.fbHTTP
if do == nil {
do = (&http.Client{Timeout: fbCheckTimeout}).Do
}
base := fileBrowserBaseURL
if m.fbBaseURL != "" {
base = m.fbBaseURL
}
_, code, err := fbLogin(do, base, pw)
refused := err == nil && (code == http.StatusUnauthorized || code == http.StatusForbidden)
if refused {
m.logger.Printf("[WARN] [infra] filebrowser: the stored admin password is refused (HTTP %d) — changed in FileBrowser; the page offers a new one (value never logged)", code)
}
return refused
}
@@ -141,3 +141,44 @@ func TestEffectiveLoginKind_DefaultAndUnknown(t *testing.T) {
}
}
}
// R-1101 (v0.321.0): the stored password is called refused ONLY on FileBrowser's own 401.
// RED-PROOF (2026-10-16, REPORT): with `refused := err == nil && (...)` replaced by `refused := code != http.StatusOK`,
// this failed at „FileBrowser down reads as refused".
func TestFileBrowserStoredPassword_RefusedOnlyOnA401(t *testing.T) {
fake := &fakeFB{password: "TESTONLY-stored-1"}
srv := httptest.NewServer(fake.handler())
m, st, key := fbManager(t, srv.URL)
enc, err := crypto.Encrypt(key, "TESTONLY-stored-1")
if err != nil {
t.Fatal(err)
}
if err := st.SetFileBrowserAdmin(settings.FileBrowserAdminGenerated, enc, "2026-10-16T00:00:00Z"); err != nil {
t.Fatal(err)
}
if m.FileBrowserStoredPasswordRefused() {
t.Fatal("the stored password still logs in, and it reads as refused")
}
fake.mu.Lock()
fake.password = "TESTONLY-changed-in-filebrowser"
fake.mu.Unlock()
if !m.FileBrowserStoredPasswordRefused() {
t.Fatal("FileBrowser refuses the stored password (401), and it does not read as refused")
}
// The reset is then offered in the generated state, and replaces it.
calls := 0
m.fbSetPassword = func(_ context.Context, pw string) error {
calls++
fake.mu.Lock()
fake.password = pw
fake.mu.Unlock()
return nil
}
if err := m.ResetFileBrowserAdminPassword(context.Background()); err != nil || calls != 1 {
t.Fatalf("reset with a refused stored password: err %v, apply ran %d times", err, calls)
}
srv.Close()
if m.FileBrowserStoredPasswordRefused() {
t.Fatal("FileBrowser down reads as refused — an unproven refusal must not tell the household its password changed")
}
}