From 9bdd8f8fe856b6f85af260b08a73d813507207fe Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 19 Oct 2026 04:56:56 +0200 Subject: [PATCH] =?UTF-8?q?v0.332.0:=20one=20sign-in=20for=20the=20file=20?= =?UTF-8?q?manager=20=E2=80=94=20a=20dashboard=20pass,=20a=20host-only=20f?= =?UTF-8?q?iles=20cookie,=20a=20per-box=20secret=20header=20(R-1100,=20ope?= =?UTF-8?q?rator=20D16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 40 +- REUSE.md | 3 +- controller/README.md | 8 +- controller/internal/infra/filebrowser_sso.go | 126 +++++++ .../internal/infra/filebrowser_sso_test.go | 81 +++++ controller/internal/infra/infra.go | 63 +++- controller/internal/infra/infra_test.go | 2 +- controller/internal/infra/kept_source_test.go | 4 +- controller/internal/stacks/infra.go | 18 +- controller/internal/web/app_page.go | 3 + controller/internal/web/datapath_card.go | 6 +- controller/internal/web/datapath_card_test.go | 3 +- controller/internal/web/filebrowser_link.go | 18 +- .../internal/web/filebrowser_link_test.go | 5 + controller/internal/web/files_sso.go | 314 ++++++++++++++++ controller/internal/web/files_sso_test.go | 342 ++++++++++++++++++ controller/internal/web/handlers.go | 2 +- controller/internal/web/kept_handlers.go | 2 +- .../internal/web/logins_folders_test.go | 79 +++- controller/internal/web/server.go | 8 + .../internal/web/templates/app_data.html | 5 +- 21 files changed, 1078 insertions(+), 54 deletions(-) create mode 100644 controller/internal/infra/filebrowser_sso.go create mode 100644 controller/internal/infra/filebrowser_sso_test.go create mode 100644 controller/internal/web/files_sso.go create mode 100644 controller/internal/web/files_sso_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 051678d..6e8f6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,41 @@ -## Unreleased (2026-10-19) — the new recovery code replaces the old one only after the household typed it back (R-1151, controller half) +## v0.332.0 — one sign-in for the file manager (R-1100, operator D16 = yes); the new recovery code replaces the old one only after the household typed it back (R-1151) (2026-10-19) -Ships in the next release. **No new MinAgent:** it works with every agent — the agent's claim answer says whether a -confirm is needed. +**MinAgent: 0.158.1** — the floor's, carried. Neither change needs a new agent: the file manager's sign-in is the +controller's alone, and R-1151's claim answer itself says whether the agent (≥ 0.160.0) waits for the typed-back words — +an older agent stores the package at the claim, as before (pinned). No golden (`09` §3 decision 250). Task: the day +brief of 2026-10-19 (felhom.eu `REPORT-day-2026-10-19.md`); design: felhom.eu `audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md`. + +- **One sign-in for the file manager (R-1100).** „Fájlok ↗" (and every FileBrowser folder link on the dashboard) now + goes to the dashboard's `GET /__files/open?to=`. With the household's session it mints a pass — 60 s, + one use, bound to `files.` and to that session — and sends the browser to `files./_felhom/sso?t=…`; + without a session, the dashboard's sign-in and back. Every request to `files.` now first asks the controller + (traefik forwardAuth `felhom-files-sso` → `GET /__felhom_gate/files`, the setup gate's pattern, decision 46): + `/_felhom/sso` swaps the pass for the files cookie `felhom_files` (host-only on `files.`, HttpOnly, Secure, + Lax; it carries a value DERIVED from the session's fingerprint for that host — the R-976 shape — never the session) + and clears FileBrowser's own earlier token; a valid files cookie → 200 with the box's secret header naming the + household's FileBrowser user `felhom-household`; no files cookie → 200 without it (FileBrowser's own password sign-in: + `admin`, the fallback and the operator's way — R-513 and the R-1126 gate unchanged); a files cookie that no longer opens + (the dashboard session ended, the dashboard password changed → the gate key rotates) → both cookies cleared and the + browser sent back to the dashboard, which asks for the sign-in again. **The header name is a per-box secret** + (`X-Felhom-User-<32 hex>`, made once, 0600, `/filebrowser-sso-header`, `infra.LoadOrCreateFileBrowserSSOHeader`) + written only into FileBrowser's config (`auth.methods.proxy`, with `logoutRedirectUrl` = the dashboard; password + sign-in stays on) and traefik's route (`authResponseHeaders`, which strips a client's copy) — so an app container that + reaches `filebrowser:80` directly cannot forge it (the spike measured that it could with a fixed name). **The dashboard + cookie is NOT widened** to the parent domain. FileBrowser gives a header user only its userDefaults, so with the sign-in + the defaults are the household's file rights (change, add, delete, share, download, live updates — not admin, not + API); a user the household adds by hand starts from the same ticks. The data tab's „asks for its own password the first + time" note is shown only on a box without the sign-in. Every box's FileBrowser config and route change once, so + FileBrowser is re-created once. Tests (each refusal red-proved, `internal/web/files_sso_test.go`, + `internal/infra/filebrowser_sso_test.go`): `TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin`, + `_ReplayedPassRefused` (used-nonce check off → „a replayed pass set the files cookie again"), `_ExpiredPassRefused` + (expiry off → „a pass 61 s old set the files cookie"), `_PassForAnotherHostRefused` (host check AND host-bound session + key off → „a pass for files.other.hu passed …: 302"; X-Forwarded-Host check off → „the answer named the household on + felhom.example.hu: 200"; a setup-gate pass refused), `_NoSessionNoPass` (validity off → „a browser with no session got + a pass"), `_SignedOutAsksAgain` (session check always true → „after the sign-out the files cookie still named the + household"; also after a dashboard password change), `_DataTabLoginNote`, `TestFileBrowserSSOHeader_R1100_ABoxSecret`, + `TestFileBrowserConfig_R1100_TrustsOnlyTheBoxSecret`, `_HeaderUserGetsTheHouseholdsRights`, + `TestFileBrowserRoute_R1100_AsksTheControllerAndStripsTheHeader` (each red-proved). Link tests now read the folder from + the pass link's `to`. i18n fixtures: none changed (no fixture page shows a folder link or the note). - **The wizard's typed-back words now go to the box, not only to the browser.** The claim asks the agent to keep the new package (`{"defer_upload": true}`); an agent ≥ 0.160.0 answers with `confirm_required` and the two word diff --git a/REUSE.md b/REUSE.md index 95739cd..431f27a 100644 --- a/REUSE.md +++ b/REUSE.md @@ -20,6 +20,7 @@ | `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** | | `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). | | `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. | +| `web.fileBrowserOpenLink` · `web.ServeFilesOpen` / `ServeFilesAuth` · `infra.LoadOrCreateFileBrowserSSOHeader` / `FileBrowserSSOFor` (v0.332.0, R-1100) | controller/internal/web/filebrowser_link.go · controller/internal/web/files_sso.go · controller/internal/infra/filebrowser_sso.go | `(domain, sourceName, relPath)` · handlers · `(dataDir[, domain])` | A dashboard link to a FileBrowser folder that opens it signed in (the one sign-in) | **Every dashboard folder link uses `fileBrowserOpenLink`** (through `/__files/open`), never `fileBrowserLink` raw. The header NAME is the box's secret: only FileBrowser's config and traefik's route carry it — never a log, a page or a report. Never widen the dashboard cookie; the files cookie is derived per host from the session fingerprint (the R-976 shape) | | `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) | | `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert | | `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) | @@ -207,7 +208,7 @@ | `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps//initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks//auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. | | `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. | | `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. | -| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserLink`. Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. | +| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserOpenLink` (R-1100; the folder path built by `fileBrowserLink`). Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. | | `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. | | `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. | | `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. | diff --git a/controller/README.md b/controller/README.md index 5689f4e..cfb8629 100644 --- a/controller/README.md +++ b/controller/README.md @@ -382,7 +382,13 @@ wrote or FileBrowser's (`InitialCreds`) · a sentence from the catalog's `app_in admin/admin refused, store encrypted, state `generated`). *„Fájlok ↗"* in the header opens the library folder, else the first declared folder, else a media app's first present drive's `media` folder (`datapath_card.go` `filesButtonLink`); the data tab lists each drive's media folders, and a folder whose drive is not bound into -FileBrowser is text, never a link. +FileBrowser is text, never a link. **One sign-in (v0.332.0, R-1100):** every folder link goes through the dashboard's +`GET /__files/open`, which mints a 60-second one-use pass bound to `files.` and the session; traefik's +forwardAuth on `files.` (`GET /__felhom_gate/files`, `web/files_sso.go`) swaps it at `/_felhom/sso` for the +host-only `felhom_files` cookie and then sets the box's SECRET header (`X-Felhom-User-<32 hex>`, `/filebrowser-sso-header`) +naming FileBrowser user `felhom-household` — FileBrowser opens at the folder with no login. No files cookie → no header: +FileBrowser's own password (`admin`, the fallback). A dashboard sign-out or password change ends the files cookie at the +next request. The dashboard cookie stays host-only. #### „Még egy lépés" — a value asked AFTER the app starts (v0.315.0) diff --git a/controller/internal/infra/filebrowser_sso.go b/controller/internal/infra/filebrowser_sso.go new file mode 100644 index 0000000..37078eb --- /dev/null +++ b/controller/internal/infra/filebrowser_sso.go @@ -0,0 +1,126 @@ +package infra + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// One sign-in for the file manager (R-1100, operator decision D16 = yes, 2026-10-19; design note +// felhom.eu/documentation/audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md, `02` „Logins and folders"). +// +// FileBrowser trusts its proxy header blindly (`auth.methods.proxy`, „FileBrowser blindly accepts the header value as +// username") and every app container can reach filebrowser:80 directly, without traefik — measured: a forged header +// from the Nextcloud container logged in (200). So the header NAME is a per-box secret: an app that does not know it +// cannot send it, and traefik's forwardAuth strips it from every client request (it is in authResponseHeaders) and sets +// it only for a browser that holds the files pass the dashboard minted. The name is written ONLY into FileBrowser's +// config and traefik's dynamic route — never a log, a page or a report. + +// FileBrowserSSOUser is the FileBrowser user the household is signed in as through the header. A separate user from +// `admin` (a password user, which FileBrowser refuses to reach by a header — ErrWrongLoginMethod), created by +// FileBrowser on the first header sign-in with the config's userDefaults. +const FileBrowserSSOUser = "felhom-household" + +// FilesSSOMiddleware is the traefik forwardAuth middleware on the files. router. +const FilesSSOMiddleware = "felhom-files-sso" + +// FilesAuthURL is the controller's forwardAuth answer for files. (web.ServeFilesAuth). +const FilesAuthURL = "http://felhom-controller:8080/__felhom_gate/files" + +// fileBrowserSSOHeaderFile holds the header name, 0600, in the controller's data dir (like setup-gate.key). +const fileBrowserSSOHeaderFile = "filebrowser-sso-header" + +var reSSOHeader = regexp.MustCompile(`^X-Felhom-User-[0-9a-f]{32}$`) + +// ValidFileBrowserSSOHeader reports whether h has the shape LoadOrCreateFileBrowserSSOHeader makes. +func ValidFileBrowserSSOHeader(h string) bool { return reSSOHeader.MatchString(h) } + +// LoadOrCreateFileBrowserSSOHeader returns this box's secret header name, making it once. Two writers (the stacks +// manager rendering FileBrowser's files, the web server answering forwardAuth) may ask at the same moment: the file is +// created with O_EXCL, and the loser reads the winner's value. A file that does not hold a valid name is an error, not +// a silent new name (a new name would split FileBrowser's config from traefik's route until both are rewritten). +// Pinned by TestFileBrowserSSOHeader_R1100_*. +func LoadOrCreateFileBrowserSSOHeader(dataDir string) (string, error) { + if dataDir == "" { + return "", errors.New("no data dir") + } + p := filepath.Join(dataDir, fileBrowserSSOHeaderFile) + if b, err := os.ReadFile(p); err == nil { + h := strings.TrimSpace(string(b)) + if !ValidFileBrowserSSOHeader(h) { + return "", fmt.Errorf("%s does not hold a valid header name", p) + } + return h, nil + } else if !os.IsNotExist(err) { + return "", err + } + rb := make([]byte, 16) + if _, err := rand.Read(rb); err != nil { + return "", err + } + h := "X-Felhom-User-" + hex.EncodeToString(rb) + f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if os.IsExist(err) { + return LoadOrCreateFileBrowserSSOHeader(dataDir) + } + return "", err + } + if _, err := f.WriteString(h + "\n"); err != nil { + f.Close() + _ = os.Remove(p) + return "", err + } + if err := f.Close(); err != nil { + _ = os.Remove(p) + return "", err + } + return h, nil +} + +// FileBrowserSSO is what FileBrowser's config needs for the header sign-in. Header "" = no header sign-in (the config +// is the pre-R-1100 one, byte for byte). +type FileBrowserSSO struct { + Header string // the box's secret header name + LogoutURL string // where FileBrowser's „Kijelentkezés" goes for the header user: the dashboard +} + +// renderFileBrowserAuth is the config's auth block for the header sign-in. Password sign-in stays on: `admin` is the +// fallback when the pass path fails, and the operator's way in (R-513 and the R-1126 gate unchanged). +func renderFileBrowserAuth(sso FileBrowserSSO) string { + if sso.Header == "" { + return "" + } + return fmt.Sprintf(`auth: + methods: + password: + enabled: true + minLength: 5 + # R-1100: the household's one sign-in. traefik sets this header only for a browser holding the dashboard's files + # pass and strips it from every client request; its name is this box's secret (an app container that reaches + # filebrowser:80 directly does not know it). Pinned by TestFileBrowserConfig_R1100_*. + proxy: + enabled: true + header: %q + logoutRedirectUrl: %q +`, sso.Header, sso.LogoutURL) +} + +// FileBrowserSSOFor is the header sign-in for this box: its secret header name and the dashboard as FileBrowser's +// logout target. An error (no data dir, an unreadable or damaged name file) = no header sign-in: the caller renders +// the pre-R-1100 config and route and says so in its log; FileBrowser then asks for its own password, as before. +func FileBrowserSSOFor(dataDir, domain string) (FileBrowserSSO, error) { + if domain == "" { + return FileBrowserSSO{}, errors.New("no domain") + } + h, err := LoadOrCreateFileBrowserSSOHeader(dataDir) + if err != nil { + return FileBrowserSSO{}, err + } + return FileBrowserSSO{Header: h, LogoutURL: "https://felhom." + domain + "/"}, nil +} diff --git a/controller/internal/infra/filebrowser_sso_test.go b/controller/internal/infra/filebrowser_sso_test.go new file mode 100644 index 0000000..cba9b5f --- /dev/null +++ b/controller/internal/infra/filebrowser_sso_test.go @@ -0,0 +1,81 @@ +package infra + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +const testSSOHeader = "X-Felhom-User-0123456789abcdef0123456789abcdef" + +// With the header sign-in FileBrowser trusts THIS box's secret header name (never the old public X-Felhom-User), keeps +// password sign-in on (admin: the fallback and the operator's way), and sends a header user's logout to the dashboard. +// Without it the config is the pre-R-1100 one. +// +// COMPANION RED-PROOF (observed 2026-10-19): render renderFileBrowserAuth's header as "X-Felhom-User" → this fails with +// `the auth block lacks "header: \"X-Felhom-User-0123…\""`. Restored. +func TestFileBrowserConfig_R1100_TrustsOnlyTheBoxSecret(t *testing.T) { + sso := FileBrowserSSO{Header: testSSOHeader, LogoutURL: "https://felhom.example.hu/"} + c := RenderFileBrowserConfigKept([]settings.StoragePath{{Path: "/mnt/felhom-drives/hdd_1", Label: "HDD"}}, true, "", sso) + block := c[strings.Index(c, "\nauth:\n"):] + block = block[:strings.Index(block, "\nuserDefaults:")] + for _, want := range []string{" password:\n enabled: true", " proxy:\n enabled: true", `header: "` + testSSOHeader + `"`, `logoutRedirectUrl: "https://felhom.example.hu/"`} { + if !strings.Contains(block, want) { + t.Fatalf("the auth block lacks %q:\n%s", want, block) + } + } + if strings.Count(c, "header:") != 1 || strings.Contains(c, `header: "X-Felhom-User"`) { + t.Fatal("FileBrowser trusts a header name other than the box's secret") + } + plain := RenderFileBrowserConfigKept([]settings.StoragePath{{Path: "/mnt/felhom-drives/hdd_1", Label: "HDD"}}, true, "", FileBrowserSSO{}) + if strings.Contains(plain, "auth:") || strings.Contains(plain, "X-Felhom-User") { + t.Fatal("without the header sign-in the config must carry no auth block") + } +} + +// The household's header user is created by FileBrowser from userDefaults, so with the header sign-in the defaults are +// the household's file rights (not FileBrowser's admin or API); without it, the old download-only defaults. +// +// COMPANION RED-PROOF (observed 2026-10-19): return the download-only block for both cases → this fails with "the +// header user could not change files: modify false". Restored. +func TestFileBrowserConfig_R1100_HeaderUserGetsTheHouseholdsRights(t *testing.T) { + with := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{Header: testSSOHeader, LogoutURL: "https://felhom.example.hu/"}) + perms := with[strings.Index(with, " permissions:\n"):strings.Index(with, "frontend:")] + for _, k := range []string{"modify", "share", "delete", "create", "download", "realtime"} { + if !strings.Contains(perms, " "+k+": true\n") { + t.Fatalf("the header user could not change files: %s false\n%s", k, perms) + } + } + for _, k := range []string{"admin", "api"} { + if !strings.Contains(perms, " "+k+": false\n") { + t.Fatalf("the header user must not get %s", k) + } + } + without := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{}) + if !strings.Contains(without, " modify: false\n") || !strings.Contains(without, " download: true\n") { + t.Fatal("without the header sign-in the old download-only defaults must stay") + } +} + +// The route asks the controller before FileBrowser and names the header in authResponseHeaders — which is what makes +// traefik remove a client's copy of it (measured, audits/proxyauth-spike-2026-10-18). Without the header sign-in: the +// old route, no middleware. +// +// COMPANION RED-PROOF (observed 2026-10-19): drop the router's `middlewares:` line in RenderFileBrowserRoute → this fails +// with "the files router does not ask the controller first". Restored. +func TestFileBrowserRoute_R1100_AsksTheControllerAndStripsTheHeader(t *testing.T) { + r := RenderFileBrowserRoute("example.hu", testSSOHeader) + router := r[strings.Index(r, " filebrowser:\n rule:"):strings.Index(r, " services:")] + if !strings.Contains(router, " middlewares:\n - "+FilesSSOMiddleware+"\n") { + t.Fatalf("the files router does not ask the controller first:\n%s", router) + } + for _, want := range []string{" " + FilesSSOMiddleware + ":\n forwardAuth:\n", `address: "` + FilesAuthURL + `"`, "authResponseHeaders:\n - \"" + testSSOHeader + "\""} { + if !strings.Contains(r, want) { + t.Fatalf("the route lacks %q:\n%s", want, r) + } + } + if plain := RenderFileBrowserRoute("example.hu", ""); strings.Contains(plain, "middlewares") || strings.Contains(plain, "X-Felhom-User") { + t.Fatalf("without the header sign-in the route must be the old one:\n%s", plain) + } +} diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 4beea48..efb9b2d 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -264,23 +264,35 @@ networks: // v0.327.1). The name resolves only while FileBrowser is on traefik-public, which the box allows only after this run's // default login was checked — so the route can stay in place and still never reach an unchecked FileBrowser (502 then). // TLS as the old Docker label had it (`tls: {}`, the default certificate store / the wildcard). -func RenderFileBrowserRoute(domain string) string { +// +// R-1100: with ssoHeader set, every request to files. first asks the controller (forwardAuth, FilesAuthURL). +// traefik removes ssoHeader from the client's request whatever the answer (it is in authResponseHeaders — measured, +// any case, twice: audits/proxyauth-spike-2026-10-18) and sets it only from the controller's 200, which carries it only +// for a browser holding the dashboard's files pass. The same answer handles /_felhom/sso (the pass → the files cookie). +// ssoHeader "" = the route as before, byte for byte. Pinned by TestFileBrowserRoute_R1100_*. +func RenderFileBrowserRoute(domain, ssoHeader string) string { + mw, mwDef := "", "" + if ssoHeader != "" { + mw = " middlewares:\n - " + FilesSSOMiddleware + "\n" + mwDef = fmt.Sprintf(" middlewares:\n %s:\n forwardAuth:\n address: %q\n authResponseHeaders:\n - %q\n", + FilesSSOMiddleware, FilesAuthURL, ssoHeader) + } return fmt.Sprintf(`# Traefik dynamic route for FileBrowser (files.) — managed by felhom-controller (R-1126). # WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. http: - routers: +%s routers: filebrowser: rule: "Host(`+"`"+`files.%s`+"`"+`)" entryPoints: - websecure service: filebrowser - tls: {} +%s tls: {} services: filebrowser: loadBalancer: servers: - url: "http://filebrowser:80" -`, domain) +`, mwDef, domain, mw) } // RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own @@ -420,12 +432,13 @@ http: // storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported // verbatim from internal/web/handlers.go. func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string { - return RenderFileBrowserConfigKept(paths, importSource, "") + return RenderFileBrowserConfigKept(paths, importSource, "", FileBrowserSSO{}) } // RenderFileBrowserConfigKept is RenderFileBrowserConfig plus the read-only kept-data source, LAST, -// named keptLabel ("" = no such source: nothing is kept, or the caller predates it). -func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string) string { +// named keptLabel ("" = no such source: nothing is kept, or the caller predates it), and the household's header +// sign-in (R-1100; a zero sso = none, the config byte for byte as before). +func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool, keptLabel string, sso FileBrowserSSO) string { var sources string // The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate // bind of /userdata/import. Separate rather than nested inside a drive source: @@ -471,7 +484,7 @@ server: logging: - levels: "info|warning|error" sources: -%suserDefaults: +%s%suserDefaults: stickySidebar: true darkMode: true viewMode: "normal" @@ -490,7 +503,23 @@ server: autoplayMedia: true folder: true permissions: - api: false +%sfrontend: + # No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0; + # structs.go:294, sent to the page at backend/http/static.go:300). + disablePWAInstall: true +`, sources, renderFileBrowserAuth(sso), fileBrowserDefaultPermissions(sso)) +} + +// fileBrowserDefaultPermissions is userDefaults.permissions — what FileBrowser gives a user it creates. With the header +// sign-in, the one user it creates on its own is the household's header user (FileBrowserSSOUser, on its first sign-in), +// and that user must be able to do with the files what the household does as `admin`: change, add, delete, share, +// download — but not run FileBrowser itself (admin) or its API keys (api). FileBrowser has no per-user setting for a +// header user; the defaults are the only place the box can set its rights (R-1100). A user the household adds by hand +// in FileBrowser starts from the same ticks and the household can change them there. Without the header sign-in: the +// old download-only defaults, unchanged. Pinned by TestFileBrowserConfig_R1100_HeaderUserGetsTheHouseholdsRights. +func fileBrowserDefaultPermissions(sso FileBrowserSSO) string { + if sso.Header == "" { + return ` api: false admin: false modify: false share: false @@ -498,9 +527,15 @@ server: delete: false create: false download: true -frontend: - # No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0; - # structs.go:294, sent to the page at backend/http/static.go:300). - disablePWAInstall: true -`, sources) +` + } + return ` api: false + admin: false + modify: true + share: true + realtime: true + delete: true + create: true + download: true +` } diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index 8add926..2337ed4 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -296,7 +296,7 @@ func TestFileBrowserRender_R1126_NotPublicAtStart(t *testing.T) { if strings.Contains(compose, "traefik.") { t.Fatalf("FileBrowser must carry no Traefik labels (the route is the file route by name):\n%s", compose) } - route := RenderFileBrowserRoute("demo-felhom.eu") + route := RenderFileBrowserRoute("demo-felhom.eu", "") for _, w := range []string{"Host(`files.demo-felhom.eu`)", `url: "http://filebrowser:80"`, "websecure", "tls: {}"} { if !strings.Contains(route, w) { t.Errorf("the file route misses %q:\n%s", w, route) diff --git a/controller/internal/infra/kept_source_test.go b/controller/internal/infra/kept_source_test.go index e370db2..7224791 100644 --- a/controller/internal/infra/kept_source_test.go +++ b/controller/internal/infra/kept_source_test.go @@ -7,10 +7,10 @@ import ( // `09` §3 decision 36 — the kept-data source appears only when named, at /srv/megorzott. func TestFileBrowser_KeptSourceOnlyWhenNamed(t *testing.T) { - if s := RenderFileBrowserConfigKept(nil, false, ""); strings.Contains(s, FileBrowserKeptMount) { + if s := RenderFileBrowserConfigKept(nil, false, "", FileBrowserSSO{}); strings.Contains(s, FileBrowserKeptMount) { t.Fatal("a kept source rendered with no label") } - s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok") + s := RenderFileBrowserConfigKept(nil, false, "Megőrzött adatok", FileBrowserSSO{}) if !strings.Contains(s, `path: "/srv/`+FileBrowserKeptMount+`"`) || !strings.Contains(s, `name: "Megőrzött adatok"`) { t.Fatalf("kept source missing:\n%s", s) } diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index 70655db..4100e76 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -274,7 +274,7 @@ func (m *Manager) ensureFileBrowser(dir string) error { // import source here without its bind would produce a source with no path behind it — a broken // sidebar entry — so both halves are deliberately deferred to the same place. compose := infra.RenderFileBrowserCompose(m.cfg.Customer.Domain, nil) - config := infra.RenderFileBrowserConfig(nil, false) + config := infra.RenderFileBrowserConfigKept(nil, false, "", m.fileBrowserSSO()) if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil { return fmt.Errorf("write docker-compose.yml: %w", err) } @@ -296,16 +296,28 @@ func (m *Manager) ensureFileBrowserRoute(traefikDir string) error { return fmt.Errorf("mkdir dynamic: %w", err) } path := filepath.Join(dynDir, "filebrowser.yml") - want := infra.RenderFileBrowserRoute(domain) + sso := m.fileBrowserSSO() + want := infra.RenderFileBrowserRoute(domain, sso.Header) if cur, err := os.ReadFile(path); err != nil || string(cur) != want { if err := os.WriteFile(path, []byte(want), 0o644); err != nil { return fmt.Errorf("write filebrowser route: %w", err) } - m.logger.Printf("[INFO] [infra] wrote filebrowser route → %s (Host files.%s → filebrowser:80 by name, R-1126)", path, domain) + m.logger.Printf("[INFO] [infra] wrote filebrowser route → %s (Host files.%s → filebrowser:80 by name, R-1126; one sign-in %v, R-1100)", path, domain, sso.Header != "") } return nil } +// fileBrowserSSO is this box's header sign-in for FileBrowser (R-1100): the same secret name for its config and for +// traefik's route. An error = none (the pre-R-1100 files) and a WARN; the name itself is never logged. +func (m *Manager) fileBrowserSSO() infra.FileBrowserSSO { + sso, err := infra.FileBrowserSSOFor(m.cfg.Paths.DataDir, m.cfg.Customer.Domain) + if err != nil { + m.logger.Printf("[WARN] [infra] filebrowser: no one sign-in (R-1100): %v — FileBrowser asks for its own password", err) + return infra.FileBrowserSSO{} + } + return sso +} + // ensureServersTransports writes the traefik file-provider dynamic config defining named backend // transports (the insecure-skip-verify transport for self-signed HTTPS backends like Crafty). Like // wireController, it writes only when the content changes so the traefik file watcher doesn't reload diff --git a/controller/internal/web/app_page.go b/controller/internal/web/app_page.go index 1907018..5d3ede5 100644 --- a/controller/internal/web/app_page.go +++ b/controller/internal/web/app_page.go @@ -193,6 +193,9 @@ func (s *Server) renderAppDataTab(w http.ResponseWriter, r *http.Request, found if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" { data["FlashError"] = flashErr } + // R-1100: with the one sign-in, the folder links open FileBrowser without its own login, so the „asks for its own + // password" note is said only on a box without it. Pinned by TestFilesSSO_R1100_DataTabLoginNote. + data["FilesSSO"] = s.filesHeader() != "" data["Title"] = found.Meta.DisplayName data["TitleKey"], data["TitleArgs"] = "page.title.app_data", []interface{}{found.Meta.DisplayName} s.executeTemplate(w, r, "app_data", data) diff --git a/controller/internal/web/datapath_card.go b/controller/internal/web/datapath_card.go index cd7d667..2020f1f 100644 --- a/controller/internal/web/datapath_card.go +++ b/controller/internal/web/datapath_card.go @@ -99,7 +99,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar // bind it) is TEXT, never a link — a link to a missing source opens an empty or broken view. src, drive := s.fbSourceForApp(st.Name) if src != "" && s.drivePresent(drive) { - link = fileBrowserLink(domain, src, dp.Path) + link = fileBrowserOpenLink(domain, src, dp.Path) } else { s.logger.Printf("[WARN] [web] app page %s: folder %q has no FileBrowser source on this box now (drive %q) — shown without a link", st.Name, dp.Path, drive) } @@ -185,7 +185,7 @@ func (s *Server) mediaFolderCards(st *stacks.Stack, domain, lang string, have [] } card := DataPathCard{Label: s.msgLang(lang, "datapath.media.row", label, src), Role: stacks.RoleLibrary, IsMedia: true} if present { - card.Link = fileBrowserLink(domain, src, path.Join("media", sub)) + card.Link = fileBrowserOpenLink(domain, src, path.Join("media", sub)) if seen[card.Link] { continue } @@ -255,7 +255,7 @@ func (s *Server) filesButtonLink(st *stacks.Stack, cards []DataPathCard) string } for _, sp := range s.mediaStoragePaths() { if s.drivePresent(sp.Path) { - return fileBrowserLink(s.cfg.Customer.Domain, fbSourceName(sp), "media") + return fileBrowserOpenLink(s.cfg.Customer.Domain, fbSourceName(sp), "media") } } return "" diff --git a/controller/internal/web/datapath_card_test.go b/controller/internal/web/datapath_card_test.go index a77e166..7d826e3 100644 --- a/controller/internal/web/datapath_card_test.go +++ b/controller/internal/web/datapath_card_test.go @@ -1,6 +1,7 @@ package web import ( + "net/url" "strings" "testing" @@ -42,7 +43,7 @@ func TestAppInfo_DataPathCardRenders(t *testing.T) { } // The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is // what would have caught the v0.150.0 class of bug (a key rendered where another belonged). - if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") { + if !strings.Contains(html, "https://felhom.demo-felhom.eu/__files/open?to="+url.QueryEscape("/files/Beolvas%C3%A1s/paperless")) { t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath")) } if !strings.Contains(html, "nem készül róla biztonsági mentés") { diff --git a/controller/internal/web/filebrowser_link.go b/controller/internal/web/filebrowser_link.go index b35dcb7..30d48df 100644 --- a/controller/internal/web/filebrowser_link.go +++ b/controller/internal/web/filebrowser_link.go @@ -58,7 +58,21 @@ func fileBrowserLink(domain, sourceName, relPath string) string { return b.String() } -// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source. +// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source (through the +// dashboard's pass, R-1100). func importFolderLink(domain, appDir string) string { - return fileBrowserLink(domain, infra.FileBrowserImportLabel, appDir) + return fileBrowserOpenLink(domain, infra.FileBrowserImportLabel, appDir) +} + +// fileBrowserOpenLink is what a dashboard page links to for a FileBrowser folder since R-1100: the dashboard's +// GET /__files/open, which hands the household's signed-in browser a one-time pass to the file manager, so FileBrowser +// opens at the folder without its own login (files_sso.go). The folder is fileBrowserLink's path, carried in `to` +// (query-escaped as a whole; the path keeps its own PathEscape inside). Pinned by TestFileBrowserOpenLink_R1100_*. +func fileBrowserOpenLink(domain, sourceName, relPath string) string { + return filesOpenURL(domain, fileBrowserLink(domain, sourceName, relPath)) +} + +// filesOpenURL turns a files. link into the dashboard's pass link for the same FileBrowser path. +func filesOpenURL(domain, link string) string { + return "https://felhom." + domain + filesOpenPath + "?" + url.Values{"to": {filesPathOf(link, "files."+domain)}}.Encode() } diff --git a/controller/internal/web/filebrowser_link_test.go b/controller/internal/web/filebrowser_link_test.go index 9500ea4..10e403a 100644 --- a/controller/internal/web/filebrowser_link_test.go +++ b/controller/internal/web/filebrowser_link_test.go @@ -82,6 +82,11 @@ func TestImportFolderLink_IsCanonical(t *testing.T) { a := importFolderLink("demo-felhom.eu", "paperless") b := importFolderLink("demo-felhom.eu", "calibre") for _, l := range []string{a, b} { + u, err := url.Parse(l) // R-1100: through the dashboard's pass; the FileBrowser path rides `to` + if err != nil || u.Host != "felhom.demo-felhom.eu" || u.Path != filesOpenPath { + t.Fatalf("import link does not go through the dashboard's pass: %q", l) + } + l = u.Query().Get("to") if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") { t.Errorf("import link must go through the canonical source: %q", l) } diff --git a/controller/internal/web/files_sso.go b/controller/internal/web/files_sso.go new file mode 100644 index 0000000..0f75de6 --- /dev/null +++ b/controller/internal/web/files_sso.go @@ -0,0 +1,314 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" +) + +// ── One sign-in for the file manager (R-1100, operator decision D16 = yes, 2026-10-19) ─────────────────────────────── +// +// Design: felhom.eu audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md (measured) and `02` „Logins and folders". The +// setup gate's handshake (setup_gate.go, decision 46) with one change: the pass names a FileBrowser user. +// +// - „Fájlok ↗" goes to GET /__files/open?to= on the DASHBOARD host. With the household's session it +// mints a pass — 60 s, one use, bound to files. and to that session — and sends the browser to +// https://files./_felhom/sso?t=. Without a session: the dashboard's sign-in, then back here. +// - Every request to files. first asks GET /__felhom_gate/files (traefik forwardAuth). /_felhom/sso swaps the +// pass for the files cookie (`felhom_files`, host-only on files., HttpOnly, Secure, Lax) and goes on to the +// folder. A valid files cookie → 200 WITH the box's secret header naming the household's FileBrowser user. No files +// cookie → 200 WITHOUT it: FileBrowser asks for its own password (`admin` — the fallback, and the operator's way). +// A files cookie that no longer opens (the dashboard session ended, the dashboard password changed) → both cookies +// cleared and the browser sent back to the dashboard, which asks for the sign-in again. +// +// THE RULES. The dashboard cookie is never widened to the parent domain — every app subdomain would receive it (the +// setup gate's measured reason). The files cookie carries a value DERIVED from the session's fingerprint for this one +// host (the R-976 shape), never the session: lifted from FileBrowser's side it opens nothing else, and it dies with +// the session. The header's NAME is the box's secret (infra.LoadOrCreateFileBrowserSSOHeader) and only this answer +// and traefik's route carry it — an app container that reaches filebrowser:80 directly cannot forge it. The answer +// gives the header only for files. (X-Forwarded-Host, which traefik writes; the entrypoint removes a client's). +// Pinned by internal/web/files_sso_test.go. + +const ( + filesAuthPath = "/__felhom_gate/files" + filesCallbackURI = "/_felhom/sso" + filesOpenPath = "/__files/open" + filesCookieName = "felhom_files" + // fileBrowserJWTCookie is FileBrowser's own session cookie (backend/http/auth.go sessionCookieName at + // 1.5.6-stable). It is cleared with the files cookie, so a header user's FileBrowser token does not outlive the + // dashboard session it came from. + fileBrowserJWTCookie = "filebrowser_quantum_jwt" +) + +type filesSSOState struct { + mu sync.Mutex + header string // cached once read: the name never changes once made +} + +// fileBrowserSSO is this box's header sign-in (the same file the stacks manager renders FileBrowser's config and +// traefik's route from). An error = none, logged; the name itself is never logged. +func (s *Server) fileBrowserSSO() infra.FileBrowserSSO { + if s.cfg == nil { + return infra.FileBrowserSSO{} + } + sso, err := infra.FileBrowserSSOFor(s.cfg.Paths.DataDir, s.cfg.Customer.Domain) + if err != nil { + s.logger.Printf("[WARN] [web] files sign-in: none (R-1100): %v — FileBrowser asks for its own password", err) + return infra.FileBrowserSSO{} + } + return sso +} + +// filesHeader is fileBrowserSSO's header name, read once (the forwardAuth answer runs on every FileBrowser request). +func (s *Server) filesHeader() string { + s.filesSSO.mu.Lock() + defer s.filesSSO.mu.Unlock() + if s.filesSSO.header == "" { + s.filesSSO.header = s.fileBrowserSSO().Header + } + return s.filesSSO.header +} + +func (s *Server) filesHost() string { + if s.cfg == nil || s.cfg.Customer.Domain == "" { + return "" + } + return "files." + strings.ToLower(s.cfg.Customer.Domain) +} + +// filesSessionKey is what files. ever sees of a dashboard session: a value derived from its fingerprint for +// this purpose and host (R-976's shape). +func (s *Server) filesSessionKey(fp string) string { + return s.gateMAC("filessess", s.filesHost(), fp) +} + +// filesSessionAlive reports whether a live dashboard session has the key sk. +func (s *Server) filesSessionAlive(sk string) bool { + if sk == "" { + return false + } + now := time.Now() + s.sessionsMu.RLock() + defer s.sessionsMu.RUnlock() + for fp, se := range s.sessions { + if se != nil && now.Before(se.expiresAt) && hmac.Equal([]byte(s.filesSessionKey(fp)), []byte(sk)) { + return true + } + } + return false +} + +type filesToken struct { + Host string `json:"h"` + SK string `json:"s"` + Exp int64 `json:"e"` + Nonce string `json:"n"` + RD string `json:"r"` + MAC string `json:"m"` +} + +func (s *Server) mintFilesToken(sk, rd string) string { + nb := make([]byte, 12) + _, _ = rand.Read(nb) + t := filesToken{Host: s.filesHost(), SK: sk, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} + t.MAC = s.gateMAC("filestoken", t.Host, t.SK, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD) + b, _ := json.Marshal(t) + return base64.RawURLEncoding.EncodeToString(b) +} + +// takeFilesToken checks a pass for host, uses it up, and returns the session key and where the browser was going. +// A setup-gate or family token cannot pass: its MAC is over another kind. +func (s *Server) takeFilesToken(raw, host string) (sk, rd string, err error) { + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return "", "", errors.New("malformed") + } + var t filesToken + if json.Unmarshal(b, &t) != nil { + return "", "", errors.New("malformed") + } + if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("filestoken", t.Host, t.SK, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) { + return "", "", errors.New("bad signature") + } + if t.Host != host { + return "", "", errors.New("for another host") + } + now := s.gateNow() + if now.Unix() > t.Exp { + return "", "", errors.New("expired") + } + s.gateKey() + s.gate.mu.Lock() + defer s.gate.mu.Unlock() + for n, until := range s.gate.used { + if now.After(until) { + delete(s.gate.used, n) + } + } + if _, seen := s.gate.used["fs:"+t.Nonce]; seen { + return "", "", errors.New("already used") + } + s.gate.used["fs:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second) + return t.SK, t.RD, nil +} + +// filesCookieKey: ".." → the session key, if genuine for this host, +// unexpired, and its dashboard session still alive. +func (s *Server) filesCookieKey(r *http.Request) (present, ok bool) { + c, err := r.Cookie(filesCookieName) + if err != nil || c.Value == "" { + return false, false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 3 { + return true, false + } + n, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || s.gateNow().Unix() > n { + return true, false + } + if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("filescookie", s.filesHost(), parts[0], parts[1]))) { + return true, false + } + return true, s.filesSessionAlive(parts[0]) +} + +// safeFilesPath accepts a FileBrowser path to go to after the pass: "/" or under /files/ — never another host. +func safeFilesPath(p string) string { + if p == "" || p == "/" { + return "/" + } + if !strings.HasPrefix(p, "/files/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "\\\r\n") { + return "/" + } + return p +} + +// filesPathOf is the FileBrowser path of an absolute files. link (fileBrowserLink's output). +func filesPathOf(link, host string) string { + u, err := url.Parse(link) + if err != nil || !strings.EqualFold(u.Host, host) { + return "/" + } + p := u.EscapedPath() + if u.RawQuery != "" { + p += "?" + u.RawQuery + } + return safeFilesPath(p) +} + +func (s *Server) clearFilesCookies(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: filesCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: fileBrowserJWTCookie, Value: "", Path: "/", Domain: s.filesHost(), MaxAge: -1, HttpOnly: true, Secure: true}) +} + +func filesRefuse(w http.ResponseWriter, code int) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":"sign in on the dashboard"}`)) +} + +// ServeFilesOpen is GET /__files/open?to= on the dashboard host — where „Fájlok ↗" goes. +func (s *Server) ServeFilesOpen(w http.ResponseWriter, r *http.Request) { + to := safeFilesPath(r.URL.Query().Get("to")) + host := s.filesHost() + w.Header().Set("Cache-Control", "no-store") + if host == "" || r.Method != http.MethodGet { + http.Redirect(w, r, "/", http.StatusFound) + return + } + c, err := r.Cookie(sessionCookieName) + if err != nil || !s.isValidSession(c.Value) { + http.Redirect(w, r, "/login?"+url.Values{"next": {filesOpenPath + "?" + url.Values{"to": {to}}.Encode()}}.Encode(), http.StatusFound) + return + } + if s.filesHeader() == "" { // no header sign-in on this box: the plain link, FileBrowser asks for its password + http.Redirect(w, r, "https://"+host+to, http.StatusFound) + return + } + tok := s.mintFilesToken(s.filesSessionKey(sessionFingerprint(c.Value)), to) + http.Redirect(w, r, "https://"+host+filesCallbackURI+"?"+url.Values{"t": {tok}}.Encode(), http.StatusFound) +} + +// ServeFilesAuth is traefik's forwardAuth answer for files.. It trusts only X-Forwarded-Host/-Uri/-Method, +// which traefik writes from the request it forwards. +func (s *Server) ServeFilesAuth(w http.ResponseWriter, r *http.Request) { + host := strings.ToLower(r.Header.Get("X-Forwarded-Host")) + if i := strings.LastIndex(host, ":"); i != -1 { + host = host[:i] + } + if host == "" || host != s.filesHost() { + s.logger.Printf("[WARN] [web] files sign-in: asked about %q, which is not the file manager — refused", host) + filesRefuse(w, http.StatusForbidden) + return + } + header := s.filesHeader() + if header == "" { + w.WriteHeader(http.StatusOK) // no header sign-in on this box; traefik's route has no middleware then anyway + return + } + uri := r.Header.Get("X-Forwarded-Uri") + if uri == "" { + uri = "/" + } + method := r.Header.Get("X-Forwarded-Method") + browser := (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") + back := "https://felhom." + s.cfg.Customer.Domain + filesOpenPath + "?" + url.Values{"to": {safeFilesPath(uri)}}.Encode() + + if u, err := url.Parse(uri); err == nil && u.Path == filesCallbackURI { + sk, rd, err := s.takeFilesToken(u.Query().Get("t"), host) + if err == nil && !s.filesSessionAlive(sk) { + err = errors.New("the session ended") + } + if err != nil { + s.logger.Printf("[WARN] [web] files sign-in: a pass was refused (%v) — visitor %s", err, clientIP(r)) + if browser { + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+filesOpenPath+"?to=%2F", http.StatusFound) + return + } + filesRefuse(w, http.StatusForbidden) + return + } + exp := strconv.FormatInt(s.gateNow().Add(sessionMaxAge).Unix(), 10) + http.SetCookie(w, &http.Cookie{ + Name: filesCookieName, Value: sk + "." + exp + "." + s.gateMAC("filescookie", host, sk, exp), Path: "/", + MaxAge: int(sessionMaxAge.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + }) + // FileBrowser's own token of an earlier sign-in (admin's, or an old header user's) would win over the header. + http.SetCookie(w, &http.Cookie{Name: fileBrowserJWTCookie, Value: "", Path: "/", Domain: host, MaxAge: -1, HttpOnly: true, Secure: true}) + s.logger.Printf("[INFO] [web] files sign-in: a signed-in household browser passed — visitor %s", clientIP(r)) + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, safeFilesPath(rd), http.StatusFound) + return + } + + present, ok := s.filesCookieKey(r) + switch { + case ok: + w.Header().Set(header, infra.FileBrowserSSOUser) + w.WriteHeader(http.StatusOK) + case !present: + w.WriteHeader(http.StatusOK) // FileBrowser's own sign-in (traefik has removed any client copy of the header) + default: + s.clearFilesCookies(w) + if browser { + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, back, http.StatusFound) + return + } + filesRefuse(w, http.StatusUnauthorized) + } +} diff --git a/controller/internal/web/files_sso_test.go b/controller/internal/web/files_sso_test.go new file mode 100644 index 0000000..534b283 --- /dev/null +++ b/controller/internal/web/files_sso_test.go @@ -0,0 +1,342 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-1100 — one sign-in for the file manager. Driven through CatchAllMiddleware, the entry traefik's forwardAuth and the +// browser both reach: GET /__files/open on the dashboard host, and GET /__felhom_gate/files with traefik's +// X-Forwarded-* for files.. Each refusal below was seen to fail with its guard removed (COMPANION RED-PROOFs). + +func filesHarness(t *testing.T, domain, dataDir string) *Server { + t.Helper() + cfg := config.Default() + cfg.Customer.Domain = domain + cfg.Paths.DataDir = dataDir + return &Server{cfg: cfg, logger: log.New(io.Discard, "", 0), sessions: map[string]*session{}} +} + +func newFilesServer(t *testing.T) *Server { + t.Helper() + d := filepath.Join(t.TempDir(), "data") + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + return filesHarness(t, "example.hu", d) +} + +func (s *Server) filesEntry(r *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + s.CatchAllMiddleware(http.NotFoundHandler()).ServeHTTP(w, r) + return w +} + +// filesOpen is the household's „Fájlok ↗" press: GET /__files/open on the dashboard host. +func filesOpen(s *Server, to string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "https://felhom."+s.cfg.Customer.Domain+filesOpenPath+"?"+url.Values{"to": {to}}.Encode(), nil) + for _, c := range cookies { + r.AddCookie(c) + } + return s.filesEntry(r) +} + +// filesAsk is traefik's forwardAuth question for a request to host+uri. +func filesAsk(s *Server, host, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+filesAuthPath, nil) + r.Header.Set("X-Forwarded-Host", host) + r.Header.Set("X-Forwarded-Method", http.MethodGet) + r.Header.Set("X-Forwarded-Uri", uri) + r.Header.Set("Accept", accept) + for _, c := range cookies { + r.AddCookie(c) + } + return s.filesEntry(r) +} + +const htmlAccept = "text/html,application/xhtml+xml" + +func sessionCookie(s *Server) *http.Cookie { + return &http.Cookie{Name: sessionCookieName, Value: s.createSession()} +} + +// passFrom returns the /_felhom/sso URI a /__files/open answer sends the browser to ("" = none). +func passFrom(t *testing.T, w *httptest.ResponseRecorder, host string) string { + t.Helper() + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil || w.Code != http.StatusFound || loc.Host != host || loc.Path != filesCallbackURI || loc.Query().Get("t") == "" { + return "" + } + return loc.RequestURI() +} + +func filesCookieIn(w *httptest.ResponseRecorder, name string) *http.Cookie { + for _, c := range w.Result().Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +// signIn walks the whole household path and returns the files cookie. +func signIn(t *testing.T, s *Server, sess *http.Cookie, to string) *http.Cookie { + t.Helper() + pass := passFrom(t, filesOpen(s, to, sess), "files.example.hu") + if pass == "" { + t.Fatal("the dashboard minted no pass for a signed-in household") + } + w := filesAsk(s, "files.example.hu", pass, htmlAccept) + fc := filesCookieIn(w, filesCookieName) + if w.Code != http.StatusFound || fc == nil || fc.Value == "" { + t.Fatalf("the pass was not swapped for the files cookie: %d %v", w.Code, w.Header()) + } + return fc +} + +// The household's path: „Fájlok ↗" → the pass → the files cookie → FileBrowser gets the header naming the household's +// user. The cookie is host-only (no Domain), HttpOnly, Secure, Lax; the browser lands on the folder it asked for. +func TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin(t *testing.T) { + s := newFilesServer(t) + header := s.filesHeader() + if !infra.ValidFileBrowserSSOHeader(header) { + t.Fatalf("no secret header name: %q", header) + } + sess := sessionCookie(s) + const folder = "/files/HDD%202/media" + pass := passFrom(t, filesOpen(s, folder, sess), "files.example.hu") + if pass == "" { + t.Fatal("no pass minted") + } + w := filesAsk(s, "files.example.hu", pass, htmlAccept) + fc := filesCookieIn(w, filesCookieName) + if w.Code != http.StatusFound || w.Header().Get("Location") != folder { + t.Fatalf("the pass answer: %d → %q, want 302 → %s", w.Code, w.Header().Get("Location"), folder) + } + if fc == nil || fc.Domain != "" || !fc.HttpOnly || !fc.Secure || fc.SameSite != http.SameSiteLaxMode || fc.Path != "/" { + t.Fatalf("the files cookie must be host-only, HttpOnly, Secure, Lax: %+v", fc) + } + if jwt := filesCookieIn(w, fileBrowserJWTCookie); jwt == nil || jwt.MaxAge >= 0 || jwt.Domain != "files.example.hu" { + t.Fatalf("FileBrowser's own earlier token must be cleared for files.: %+v", jwt) + } + ok := filesAsk(s, "files.example.hu", folder, htmlAccept, fc) + if ok.Code != http.StatusOK || ok.Header().Get(header) != infra.FileBrowserSSOUser { + t.Fatalf("with the files cookie: %d, header %q — want 200 naming %s", ok.Code, ok.Header().Get(header), infra.FileBrowserSSOUser) + } + // No files cookie: FileBrowser's own sign-in (password) — 200 WITHOUT the header. + none := filesAsk(s, "files.example.hu", folder, htmlAccept) + if none.Code != http.StatusOK || none.Header().Get(header) != "" { + t.Fatalf("without the files cookie: %d header %q — want 200 and no header", none.Code, none.Header().Get(header)) + } +} + +// COMPANION RED-PROOF (observed 2026-10-19): drop the `s.gate.used["fs:"+…]` check in takeFilesToken → this fails with +// "a replayed pass set the files cookie again". Restored. +func TestFilesSSO_R1100_ReplayedPassRefused(t *testing.T) { + s := newFilesServer(t) + pass := passFrom(t, filesOpen(s, "/", sessionCookie(s)), "files.example.hu") + if w := filesAsk(s, "files.example.hu", pass, "application/json"); filesCookieIn(w, filesCookieName) == nil { + t.Fatal("the first use must pass") + } + w := filesAsk(s, "files.example.hu", pass, "application/json") + if c := filesCookieIn(w, filesCookieName); c != nil && c.Value != "" { + t.Fatal("a replayed pass set the files cookie again") + } + if w.Code != http.StatusForbidden { + t.Fatalf("a replayed pass: %d, want 403", w.Code) + } +} + +// COMPANION RED-PROOF (observed 2026-10-19): drop the `now.Unix() > t.Exp` check in takeFilesToken → this fails with +// "a pass 61 s old set the files cookie". Restored. +func TestFilesSSO_R1100_ExpiredPassRefused(t *testing.T) { + s := newFilesServer(t) + now := time.Now() + s.gateClock = func() time.Time { return now } + pass := passFrom(t, filesOpen(s, "/", sessionCookie(s)), "files.example.hu") + now = now.Add(gateTokenLife + time.Second) + w := filesAsk(s, "files.example.hu", pass, "application/json") + if c := filesCookieIn(w, filesCookieName); c != nil && c.Value != "" { + t.Fatal("a pass 61 s old set the files cookie") + } + if w.Code != http.StatusForbidden { + t.Fatalf("an expired pass: %d, want 403", w.Code) + } +} + +// A pass minted for another host (another box's files. under the same key; a setup-gate pass for this host) is +// refused, and the answer is refused outright for any host but files.. +// +// Two guards bind a pass to its host: takeFilesToken's `t.Host != host`, and the session key, which is derived for one +// host (filesSessionKey). COMPANION RED-PROOF (observed 2026-10-19): with either one alone removed this still passes; +// with both removed it fails with "a pass for files.other.hu passed on files.example.hu: 302". Drop the +// X-Forwarded-Host check in ServeFilesAuth → it fails with "the answer named the household on felhom.example.hu: 200". +// Restored. +func TestFilesSSO_R1100_PassForAnotherHostRefused(t *testing.T) { + s := newFilesServer(t) + sess := sessionCookie(s) + // Another domain on the same data dir = the same gate key, so only the host binding can refuse it. + other := filesHarness(t, "other.hu", s.cfg.Paths.DataDir) + other.sessions = s.sessions + foreign := passFrom(t, filesOpen(other, "/", sess), "files.other.hu") + if foreign == "" { + t.Fatal("setup: no pass from the other host") + } + w := filesAsk(s, "files.example.hu", foreign, "application/json") + if c := filesCookieIn(w, filesCookieName); (c != nil && c.Value != "") || w.Code != http.StatusForbidden { + t.Fatalf("a pass for files.other.hu passed on files.example.hu: %d", w.Code) + } + gate := filesCallbackURI + "?" + url.Values{"t": {s.mintGateToken("files.example.hu", "/")}}.Encode() + if w := filesAsk(s, "files.example.hu", gate, "application/json"); w.Code != http.StatusForbidden || filesCookieIn(w, filesCookieName) != nil { + t.Fatalf("a setup-gate pass opened the file manager: %d", w.Code) + } + fc := signIn(t, s, sess, "/") + for _, h := range []string{"felhom.example.hu", "app.example.hu", ""} { + w := filesAsk(s, h, "/", htmlAccept, fc) + if w.Header().Get(s.filesHeader()) != "" || w.Code != http.StatusForbidden { + t.Fatalf("the answer named the household on %s: %d", h, w.Code) + } + } +} + +// No dashboard session → no pass: the dashboard's sign-in, then back to /__files/open. +// +// COMPANION RED-PROOF (observed 2026-10-19): drop `!s.isValidSession(c.Value)` from ServeFilesOpen's check → this fails +// with "a browser with no session got a pass" (the invalid-cookie case). Restored. +func TestFilesSSO_R1100_NoSessionNoPass(t *testing.T) { + s := newFilesServer(t) + for _, cs := range [][]*http.Cookie{nil, {{Name: sessionCookieName, Value: "not-a-session"}}} { + w := filesOpen(s, "/files/HDD/x", cs...) + if passFrom(t, w, "files.example.hu") != "" { + t.Fatal("a browser with no session got a pass") + } + loc := w.Header().Get("Location") + if w.Code != http.StatusFound || !strings.HasPrefix(loc, "/login?next=") || !strings.Contains(loc, url.QueryEscape(filesOpenPath)) { + t.Fatalf("no session: %d → %q, want the sign-in with next=/__files/open", w.Code, loc) + } + } + // A `to` that is not a FileBrowser path never leaves files.. + if got := safeFilesPath("https://evil.example/x"); got != "/" { + t.Fatalf("safeFilesPath let %q through", got) + } + for _, bad := range []string{"//evil.example/files/x", "/files/\\evil", "/files/a\r\nSet-Cookie: x"} { + if got := safeFilesPath(bad); got != "/" { + t.Fatalf("safeFilesPath(%q) = %q", bad, got) + } + } +} + +// Signed out on the dashboard → the files cookie stops at the next request: both cookies cleared, the browser sent to +// the dashboard, which asks for the sign-in again. The same after the dashboard password changes (the gate key rotates). +// +// COMPANION RED-PROOF (observed 2026-10-19): make filesSessionAlive return true for any key → this fails with "after the +// sign-out the files cookie still named the household". Restored. +func TestFilesSSO_R1100_SignedOutAsksAgain(t *testing.T) { + s := newFilesServer(t) + sess := sessionCookie(s) + fc := signIn(t, s, sess, "/") + header := s.filesHeader() + s.sessionsMu.Lock() + delete(s.sessions, sessionFingerprint(sess.Value)) // what handleLogout does + s.sessionsMu.Unlock() + w := filesAsk(s, "files.example.hu", "/files/HDD/x", htmlAccept, fc) + if w.Header().Get(header) != "" { + t.Fatal("after the sign-out the files cookie still named the household") + } + if loc := w.Header().Get("Location"); w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://felhom.example.hu"+filesOpenPath) { + t.Fatalf("after the sign-out: %d → %q, want 302 to the dashboard", w.Code, loc) + } + for _, n := range []string{filesCookieName, fileBrowserJWTCookie} { + if c := filesCookieIn(w, n); c == nil || c.MaxAge >= 0 { + t.Fatalf("%s was not cleared after the sign-out", n) + } + } + if api := filesAsk(s, "files.example.hu", "/api/resources", "application/json", fc); api.Code != http.StatusUnauthorized || api.Header().Get(header) != "" { + t.Fatalf("an API call with the stale cookie: %d", api.Code) + } + if passFrom(t, filesOpen(s, "/", sess), "files.example.hu") != "" { + t.Fatal("the dashboard minted a pass for the signed-out session") + } + + // The dashboard password changes → every gate pass and files cookie stops (rotateGateKey). + sess2 := sessionCookie(s) + fc2 := signIn(t, s, sess2, "/") + s.rotateGateKey() + if w := filesAsk(s, "files.example.hu", "/", htmlAccept, fc2); w.Header().Get(header) != "" { + t.Fatal("a files cookie signed with the old gate key still named the household") + } +} + +// The header name is the box's secret: X-Felhom-User-<32 hex>, made once, 0600, the same on every read, different on +// another box; a damaged file is an error (never a silent second name that splits the config from the route). +func TestFileBrowserSSOHeader_R1100_ABoxSecret(t *testing.T) { + d1, d2 := t.TempDir(), t.TempDir() + h1, err := infra.LoadOrCreateFileBrowserSSOHeader(d1) + if err != nil || !infra.ValidFileBrowserSSOHeader(h1) || h1 == "X-Felhom-User" { + t.Fatalf("first read: %q %v", h1, err) + } + if again, _ := infra.LoadOrCreateFileBrowserSSOHeader(d1); again != h1 { + t.Fatalf("a second read made another name: %q vs %q", again, h1) + } + if h2, _ := infra.LoadOrCreateFileBrowserSSOHeader(d2); h2 == h1 { + t.Fatal("two boxes got the same header name") + } + st, err := os.Stat(filepath.Join(d1, "filebrowser-sso-header")) + if err != nil || st.Mode().Perm() != 0o600 { + t.Fatalf("the name file must be 0600: %v %v", st, err) + } + if err := os.WriteFile(filepath.Join(d2, "filebrowser-sso-header"), []byte("X-Felhom-User\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := infra.LoadOrCreateFileBrowserSSOHeader(d2); err == nil { + t.Fatal("a damaged name file must be an error") + } + // Two writers at once (the stacks manager and the web server) get one name. + d3 := t.TempDir() + got := make(chan string, 8) + for i := 0; i < 8; i++ { + go func() { h, _ := infra.LoadOrCreateFileBrowserSSOHeader(d3); got <- h }() + } + first := <-got + for i := 1; i < 8; i++ { + if h := <-got; h != first || h == "" { + t.Fatalf("parallel writers got different names: %q vs %q", h, first) + } + } +} + +// The data tab's „FileBrowser asks for its own password the first time" note is said only on a box without the one +// sign-in — with it the folder links open FileBrowser signed in, and the note would be false. +// +// COMPANION RED-PROOF (observed 2026-10-19): drop `{{if not .FilesSSO}}` from app_data.html → this fails with "the data +// tab still says FileBrowser asks for its own password". Restored. +func TestFilesSSO_R1100_DataTabLoginNote(t *testing.T) { + s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA, lfDriveB) + if err := os.MkdirAll(s.cfg.Paths.DataDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := infra.LoadOrCreateFileBrowserSSOHeader(s.cfg.Paths.DataDir); err != nil { + t.Fatal(err) + } + s.filesSSO.header = "" + if _, data, _ := lfGet(t, s, "/apps/plex/data"); strings.Contains(data, `data-fb-login-note="1"`) { + t.Fatal("the data tab still says FileBrowser asks for its own password") + } + s.filesSSO.header = "" + s.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "missing") // no name file can be made: no one sign-in + if _, data, _ := lfGet(t, s, "/apps/plex/data"); !strings.Contains(data, `data-fb-login-note="1"`) { + t.Fatal("without the one sign-in the note must stay") + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7c24179..469bbf5 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -3807,7 +3807,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { } configPath := stackDir + "/config.yaml" - fbConfig := infra.RenderFileBrowserConfigKept(configPaths, importSource, keptLabel) + fbConfig := infra.RenderFileBrowserConfigKept(configPaths, importSource, keptLabel, s.fileBrowserSSO()) // Capture the current on-disk content BEFORE any writes, so we can detect whether this sync // actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml diff --git a/controller/internal/web/kept_handlers.go b/controller/internal/web/kept_handlers.go index a2a3ee1..9b8396b 100644 --- a/controller/internal/web/kept_handlers.go +++ b/controller/internal/web/kept_handlers.go @@ -120,7 +120,7 @@ func (s *Server) keptPageHandler(w http.ResponseWriter, r *http.Request) { DisplayName: it.DisplayName, App: it.App, Path: it.Path, Drive: it.Drive, Date: fmtLocalTime(lang, it.Date), Size: i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(it.SizeBytes)), Backup: backupName, CanLoad: can && it.App != "", - LookURL: fileBrowserLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)), + LookURL: fileBrowserOpenLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)), } row.DeleteText = s.msgLang(lang, "kept.delete.confirm", it.DisplayName, row.Size) row.TypePrompt = s.msgLang(lang, "kept.delete.type", it.DisplayName) diff --git a/controller/internal/web/logins_folders_test.go b/controller/internal/web/logins_folders_test.go index 7aa3a6d..e603174 100644 --- a/controller/internal/web/logins_folders_test.go +++ b/controller/internal/web/logins_folders_test.go @@ -2,10 +2,12 @@ package web import ( "bytes" + "html" "io" "log" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "regexp" @@ -181,6 +183,34 @@ func lfGet(t *testing.T, s *Server, path string) (int, string, string) { var lfAnswerRe = regexp.MustCompile(`data-login-answer="([a-z_]+)"`) var lfFilesRe = regexp.MustCompile(`href="([^"]+)"[^>]*data-app-files="1"`) +// lfOpenTo is the FileBrowser path a rendered „Fájlok" href opens since R-1100: the href is the dashboard's +// /__files/open, the folder rides `to`. "" = not such a link. +func lfOpenTo(href string) string { + u, err := url.Parse(html.UnescapeString(href)) + if err != nil || u.Host != "felhom.example.hu" || u.Path != "/__files/open" { + return "" + } + return u.Query().Get("to") +} + +// lfOpenHrefs are the FileBrowser paths of every dashboard pass link on a page. +func lfOpenHrefs(page string) []string { + var out []string + for _, m := range regexp.MustCompile(`href="(https://felhom\.example\.hu/__files/open\?[^"]+)"`).FindAllStringSubmatch(page, -1) { + out = append(out, lfOpenTo(m[1])) + } + return out +} + +func lfHasOpen(page, path string) bool { + for _, p := range lfOpenHrefs(page) { + if p == path { + return true + } + } + return false +} + // ── Part 1: FileBrowser is part of the box ───────────────────────────────────────────────────────────────────────── // RED-PROOF (2026-10-16, REPORT): with appPageHandler reading `installed := found.Deployed` again (the v0.315.0 @@ -277,32 +307,38 @@ func TestAppPage_EveryInstalledAppHasOneLoginAnswer(t *testing.T) { // calibre-web — the button opened the import drop-zone, the first declared folder. func TestAppPage_FilesButtonTarget(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA, lfDriveB) - const srcA = "https://files.example.hu/files/M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r" + // R-1100: the button goes through the dashboard's pass (/__files/open); the FileBrowser path rides `to`. + const srcA = "/files/M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r" for _, c := range []struct{ slug, want string }{ {"romm", srcA + "/roms"}, {"calibre-web", srcA + "/media/books"}, {"plex", srcA + "/media"}, // the first drive's media folder {"immich", ""}, } { - _, html, _ := lfGet(t, s, "/apps/"+c.slug) - m := lfFilesRe.FindStringSubmatch(html) + _, page, _ := lfGet(t, s, "/apps/"+c.slug) + m := lfFilesRe.FindStringSubmatch(page) switch { case c.want == "" && m != nil: t.Errorf("%s: a Files button (%s) for an app with no folder", c.slug, m[1]) case c.want != "" && m == nil: t.Errorf("%s: no Files button, want %s", c.slug, c.want) - case c.want != "" && m[1] != c.want: - t.Errorf("%s: Files → %s, want %s", c.slug, m[1], c.want) + case c.want != "" && lfOpenTo(m[1]) != c.want: + t.Errorf("%s: Files → %s (to=%q), want to=%s", c.slug, m[1], lfOpenTo(m[1]), c.want) } - if strings.Contains(html, "M%C3%A9dia+") || strings.Contains(html, "%26") { - t.Errorf("%s: a link built with QueryEscape (+ for a space, %%26 for &)", c.slug) + for _, p := range lfOpenHrefs(page) { + if strings.Contains(p, "M%C3%A9dia+") || strings.Contains(p, "%26") { + t.Errorf("%s: a FileBrowser path built with QueryEscape (+ for a space, %%26 for &): %s", c.slug, p) + } } } // The data tab lists each drive's media folders for Plex, and points at the file manager's login. _, data, _ := lfGet(t, s, "/apps/plex/data") - for _, want := range []string{srcA + "/media/movies", srcA + "/media/tv", - "https://files.example.hu/files/HDD%202/media/movies", "https://files.example.hu/files/HDD%202/media/tv", - `data-fb-login-note="1"`, `href="/apps/filebrowser"`} { + for _, want := range []string{srcA + "/media/movies", srcA + "/media/tv", "/files/HDD%202/media/movies", "/files/HDD%202/media/tv"} { + if !lfHasOpen(data, want) { + t.Errorf("plex data tab lacks a link to %s (has %v)", want, lfOpenHrefs(data)) + } + } + for _, want := range []string{`data-fb-login-note="1"`, `href="/apps/filebrowser"`} { if !strings.Contains(data, want) { t.Errorf("plex data tab lacks %s", want) } @@ -313,25 +349,30 @@ func TestAppPage_FilesButtonTarget(t *testing.T) { // „romm links a folder on a drive that is not on the box". func TestAppPage_UnknownSourceIsTextNotLink(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveB) // drive A (romm's, plex's first) is away - _, html, _ := lfGet(t, s, "/apps/romm/data") - if strings.Contains(html, "/roms") && strings.Contains(html, "files.example.hu/files/M%C3%A9dia") { - t.Fatal("romm links a folder on a drive that is not on the box") + _, page, _ := lfGet(t, s, "/apps/romm/data") + for _, p := range lfOpenHrefs(page) { + if strings.HasPrefix(p, "/files/M%C3%A9dia") { + t.Fatal("romm links a folder on a drive that is not on the box") + } } - if !strings.Contains(html, `data-folder-unavailable="1"`) { + if !strings.Contains(page, `data-folder-unavailable="1"`) { t.Fatal("romm's folder is not shown as text when its source is absent") } - if _, page, _ := lfGet(t, s, "/apps/romm"); lfFilesRe.MatchString(page) { + if _, p, _ := lfGet(t, s, "/apps/romm"); lfFilesRe.MatchString(p) { t.Error("romm has a Files button although no folder of it is on the box") } // Plex: drive A text, drive B links; the button goes to the first PRESENT drive. _, plex, _ := lfGet(t, s, "/apps/plex/data") - if !strings.Contains(plex, `data-folder-unavailable="1"`) || !strings.Contains(plex, "HDD%202/media/movies") { + if !strings.Contains(plex, `data-folder-unavailable="1"`) || !lfHasOpen(plex, "/files/HDD%202/media/movies") { t.Error("plex: absent drive not text, or present drive not linked") } - if strings.Contains(plex, "files.example.hu/files/M%C3%A9dia") { - t.Error("plex links the absent drive") + for _, p := range lfOpenHrefs(plex) { + if strings.HasPrefix(p, "/files/M%C3%A9dia") { + t.Error("plex links the absent drive") + } } - if _, page, _ := lfGet(t, s, "/apps/plex"); !strings.Contains(page, `href="https://files.example.hu/files/HDD%202/media"`) { + _, pp, _ := lfGet(t, s, "/apps/plex") + if m := lfFilesRe.FindStringSubmatch(pp); m == nil || lfOpenTo(m[1]) != "/files/HDD%202/media" { t.Error("plex Files button does not go to the first present drive") } } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 588730d..45261b9 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -62,6 +62,7 @@ type Server struct { fam familyState // v0.287.0: the family gate (family_gate.go) familyStoreOverride *family.Store // test seam: the family list without a data dir gateClock func() time.Time + filesSSO filesSSOState // R-1100: the file manager's header name, read once tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this tmplByLang map[string]*template.Template i18n *i18n.Bundle @@ -983,6 +984,10 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler { s.ServeFamilyGateAuth(w, r) return } + if r.URL.Path == filesAuthPath { // R-1100: the file manager's forwardAuth, any host (it checks X-Forwarded-Host) + s.ServeFilesAuth(w, r) + return + } // R-983 (v0.324.0): no page of ours may be shown inside another page's frame. Every app on *. is // SAME-SITE with the dashboard, so the Lax session cookie goes along into a frame an app page draws — a page // on any app host could lay the signed-in dashboard under its own buttons. Nothing frames the dashboard @@ -1003,6 +1008,9 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler { case familyLogoutPath: s.ServeFamilyLogout(w, r) return + case filesOpenPath: // R-1100: „Fájlok ↗" — the pass to the file manager (it checks the session itself) + s.ServeFilesOpen(w, r) + return } } if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed diff --git a/controller/internal/web/templates/app_data.html b/controller/internal/web/templates/app_data.html index 711c056..0ce2424 100644 --- a/controller/internal/web/templates/app_data.html +++ b/controller/internal/web/templates/app_data.html @@ -33,8 +33,9 @@ {{end}} - {{/* v0.320.0: the file manager asks for its own login the first time — say so, and where it is. */}} -

{{T "app_page.files_login_note"}} {{T "app_page.files_login_link"}}

+ {{/* v0.320.0: the file manager asks for its own login the first time — say so, and where it is. R-1100: not with + the one sign-in (the links open it signed in). */}} + {{if not .FilesSSO}}

{{T "app_page.files_login_note"}} {{T "app_page.files_login_link"}}

{{end}} {{end}}