From f30a80841e372abf9d7992dc9eb3eec607078e90 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 16 Oct 2026 15:19:14 +0200 Subject: [PATCH] =?UTF-8?q?v0.320.0:=20FileBrowser=20is=20part=20of=20the?= =?UTF-8?q?=20box=20again;=20every=20app=20says=20where=20its=20login=20is?= =?UTF-8?q?;=20a=20=E2=80=9EF=C3=A1jlok"=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login. A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab. The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now refuse a protected stack. In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp: new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated. Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind (first_visit default, email, none; none behind the family gate says the family sign-in is the login). "Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound into FileBrowser is text, never a link. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 38 + REUSE.md | 4 + controller/README.md | 15 +- controller/internal/api/router.go | 10 + controller/internal/i18n/locales/en.json | 20 + controller/internal/i18n/locales/hu.json | 20 + controller/internal/stacks/deploy.go | 8 + .../internal/stacks/filebrowser_password.go | 139 +++ .../internal/stacks/filebrowser_reset_test.go | 143 ++++ controller/internal/stacks/manager.go | 2 + controller/internal/stacks/metadata.go | 23 + .../web/app_initcreds_exposure_test.go | 8 +- controller/internal/web/app_login.go | 32 + controller/internal/web/app_page.go | 49 +- controller/internal/web/datapath_card.go | 187 +++- .../internal/web/filebrowser_login_test.go | 16 +- controller/internal/web/handlers.go | 54 ++ controller/internal/web/i18n_parity_test.go | 67 ++ controller/internal/web/i18n_web.go | 15 +- controller/internal/web/inframeta.go | 22 + .../internal/web/logins_folders_test.go | 354 ++++++++ controller/internal/web/server.go | 5 + .../internal/web/templates/app_data.html | 5 +- .../internal/web/templates/app_guide.html | 6 + .../internal/web/templates/app_settings.html | 47 +- .../internal/web/templates/app_shell.html | 9 +- controller/internal/web/templates/style.css | 2 +- .../i18n_parity/app_data_deployed.html | 2 + .../i18n_parity/app_data_files_media.html | 661 ++++++++++++++ .../app_guide_infra_filebrowser.html | 651 ++++++++++++++ .../i18n_parity/app_settings_after_start.html | 5 +- .../app_settings_close_signup.html | 5 +- .../app_settings_close_signup_native.html | 5 +- .../i18n_parity/app_settings_deployed.html | 8 +- .../i18n_parity/app_settings_hold_log.html | 5 +- .../app_settings_infra_filebrowser.html | 806 ++++++++++++++++++ .../app_settings_install_hold.html | 5 +- .../app_settings_known_login_changed.html | 5 +- .../app_settings_ladder_steps.html | 5 +- .../i18n_parity/app_settings_login_email.html | 736 ++++++++++++++++ .../app_settings_login_family.html | 737 ++++++++++++++++ .../i18n_parity/app_settings_login_none.html | 736 ++++++++++++++++ .../app_settings_login_not_stored.html | 6 +- .../app_settings_login_text_name.html | 6 +- .../i18n_parity/app_settings_lost_files.html | 5 +- .../i18n_parity/app_settings_not_checked.html | 5 +- .../app_settings_operator_creds.html | 13 +- .../app_settings_restored_login.html | 6 +- .../i18n_parity/app_settings_running.html | 6 +- .../app_settings_setup_gate_button.html | 5 +- .../app_settings_setup_gate_native.html | 5 +- .../app_settings_setup_gate_probe.html | 5 +- .../app_settings_signup_closed.html | 5 +- .../app_settings_signup_native.html | 5 +- ...pp_settings_signup_native_open_failed.html | 5 +- .../app_settings_signup_window.html | 5 +- .../i18n_parity/app_settings_stopped.html | 4 +- .../i18n_parity/app_settings_updating.html | 5 +- controller/scripts/i18n_go_keys.json | 10 +- 59 files changed, 5669 insertions(+), 104 deletions(-) create mode 100644 controller/internal/stacks/filebrowser_reset_test.go create mode 100644 controller/internal/web/logins_folders_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/app_data_files_media.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_guide_infra_filebrowser.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_infra_filebrowser.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_login_email.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_login_family.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_login_none.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ffd44..2ec1e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +## v0.320.0 — FileBrowser is part of the box again, every app says where its login is, a „Fájlok" button (2026-10-16) + +**MinAgent: 0.158.1** — the floor's, carried from v0.319.0. Nothing here calls the agent. No golden (`09` §3 decision +250). Design: felhom.eu `architecture/02-controller-module-map.md` „The app page" (`[DESIGN]`). Evidence: felhom.eu +`audits/logins-folders-2026-10-16/` (P0 diagnosis, P1b spike). + +- **FileBrowser regression (since v0.315.0, `e66d466`).** FileBrowser (and traefik, cloudflared, samba) has no app.yaml, + so `Deployed` is false; the new app page used `Deployed` alone and showed it „not installed" with „Telepítés" and + without its login (the R-513 data only reached the settings tab). A protected infra stack is now installed by + definition on the app page (`Deployed || Protected`, as every other surface already counted): settings, guide and log + tabs (no data tab, no install page — both 302 to the page), Restart only, the curated name and „Megnyitás ↗" to + `files.`, and a two-line guide. +- **The Install press could have deployed the infra stack as an app** — `DeployStack` had no protected guard. Refused now + in the deploy API (403, before the kept-data check) and in `DeployStack` itself (`ErrProtectedStack`). +- **„Új jelszó beállítása" for FileBrowser** in the `operator` state (both demo boxes: the box does not know the + password). The file browser's own CLI, server stopped (`stacks.ResetFileBrowserAdminPassword`; spiked on demo-hp: + new 200, old 401, users/shares/settings unchanged). Verified new=200 and admin/admin refused before the encrypted + value is stored and the state set to `generated`; a failed verify leaves `operator`. POST + `/apps/filebrowser/initial-credentials/reset`, CSRF, confirm first, logged as an act, value never logged. +- **One login answer per installed app** — the „Belépés az alkalmazásba" panel is on every installed app, with exactly + one answer (`web/app_login.go` `loginAnswerFor`): the box's fields (value on demand, or the no-stored-value sentence) · + the login the app wrote / FileBrowser's (InitialCreds) · a sentence from the catalog's new `app_info.login_kind`: + `first_visit` (default), `email`, `none` (behind `family_gate`: the family sign-in is the login). The old separate + „Kezdeti belépési adatok" card is now this panel's „initial" answer. +- **„Fájlok ↗"** in the app header: the library folder, else the first declared folder, else (a media app) the first + present drive's `media` folder, built only with `fileBrowserLink`. The data tab lists each drive's media folders for + the media apps (`media_library`: Sonarr tv, Radarr movies, the players both, a download client its folder), with no + backup sentence (the class there means „not this app's data", not „temporary"). A folder whose drive is not bound + into FileBrowser now is text with one WARN, never a link. Under the list: FileBrowser asks for its own password the + first time, with a link to its page. +- Tests red-proved: `web/logins_folders_test.go` (TestAppPage_InfraAppHasNoInstall, _EveryInstalledAppHasOneLoginAnswer, + _FilesButtonTarget, _UnknownSourceIsTextNotLink, TestFileBrowserResetEndpoint_Refusals), `stacks/filebrowser_reset_test.go` + (TestFileBrowserReset_SetsVerifiesAndStoresEncrypted, _FailedVerifyKeepsOperator, _OnlyInOperatorState, + TestDeployStack_RefusesProtectedStack, TestEffectiveLoginKind_DefaultAndUnknown). + Parity fixtures: 25 app-page fixtures re-captured (the login panel; the data tab's FileBrowser line); 6 new + (`app_settings_infra_filebrowser`, `app_guide_infra_filebrowser`, `app_settings_login_email`, `_login_none`, + `_login_family`, `app_data_files_media`). Every other fixture, the install page's included, byte-identical. + ## v0.319.0 — the app page in panels, the login in one place, a password the box never had is said in words (2026-10-16) **MinAgent: 0.158.1** — the floor's, carried from v0.318.0 (the operator set it there: agent 0.158.0 is superseded by the diff --git a/REUSE.md b/REUSE.md index e972ec7..f8bb198 100644 --- a/REUSE.md +++ b/REUSE.md @@ -205,6 +205,10 @@ | `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative | | `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`. | +| `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.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`. | ### Storage registry + mount detection diff --git a/controller/README.md b/controller/README.md index d8d36e4..5f1f8ea 100644 --- a/controller/README.md +++ b/controller/README.md @@ -371,6 +371,19 @@ the install page's form groups use the same panel rule. **A login field the box eye** — the page and the reveal endpoint share `storedValuePresent`. Design: felhom.eu `architecture/02-controller-module-map.md` „The app page's layout". +**Logins and folders (v0.320.0).** *A protected infra stack is installed by definition* (FileBrowser, traefik, +cloudflared, samba — no app.yaml, so `Deployed` is false): settings, guide and log tabs only, Restart only, no install +page and no data tab (both 302 to the page), the curated name (`inframeta.go`). *Every installed app has „Belépés az +alkalmazásba" with exactly ONE answer* (`app_login.go` `loginAnswerFor`): the box's login fields · the login the app +wrote or FileBrowser's (`InitialCreds`) · a sentence from the catalog's `app_info.login_kind` (`first_visit` default, +`email`, `none`; `none` behind `family_gate` says the family sign-in is the login). FileBrowser in the `operator` state +(the box does not know its password) offers „Új jelszó beállítása": `POST /apps/filebrowser/initial-credentials/reset` +→ `stacks.ResetFileBrowserAdminPassword` (stop, the file browser's own CLI `set -u`, start, verify new=200 and +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. + #### „Még egy lépés" — a value asked AFTER the app starts (v0.315.0) Some values can only be given once the app runs: Plex's claim code is minted on plex.tv and handed to a server that is @@ -1076,7 +1089,7 @@ cloudflared is only deployed when a tunnel token is configured. **Triggers**: a > **Mount prerequisite (Section-G):** the controller writes these stacks under `/opt/docker/stacks` *inside its container*, but `docker compose up` runs on the **guest** Docker daemon. The golden's controller-bootstrap (`felhom-agent` `build-golden.sh`) therefore bind-mounts that path **same-path** (`-v /opt/docker/stacks:/opt/docker/stacks`) so the daemon resolves every relative bind source — without it, all bind-mounted stacks (base infra and customer apps) silently break. -**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) probes `http://filebrowser:80/api/auth/login` with admin/admin: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or „az üzemeltető állította be". +**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) probes `http://filebrowser:80/api/auth/login` with admin/admin: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **Controller routing + the wildcard cert anchor (`wireController` → `RenderControllerRoute`, v0.41.1 / v0.42.1).** filebrowser self-registers with traefik via Docker labels + `traefik-public` membership baked into its compose; the controller can't (it's started by the golden bootstrap *before* `traefik-public` exists, and the v2 `bootstrap.json` carries no domain — that comes from the hub pull). So `EnsureBaseStack` wires the controller **post-pull**: it `docker network connect traefik-public felhom-controller` and writes a traefik file-provider route `dynamic/controller.yml` (`Host(felhom.) → http://felhom-controller:8080`, write-if-changed). When DNS-01 ACME is configured, that route is **also the wildcard-cert anchor**: its router-level `tls.domains: *.` makes traefik **proactively obtain the wildcard `*.` + apex via Cloudflare DNS-01 at startup** (an entrypoint-level `http.tls.domains` does *not* trigger issuance in traefik v3 — only a router-level `tls.domains` does). Every other router then serves that one real wildcard cert by SNI — no per-app `certresolver` labels. This is what lets a LAN client reach the box directly at `*.` with the real cert (the `felhom-agent` split-horizon resolver depends on it). diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 74266ee..f909786 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -437,6 +437,8 @@ func deployStatusFor(err error) int { switch { case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice): return http.StatusConflict + case errors.Is(err, stacks.ErrProtectedStack): + return http.StatusForbidden case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive), errors.Is(err, stacks.ErrNotEnoughMemory): return http.StatusBadRequest @@ -473,6 +475,14 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri return } + // v0.320.0: a protected infra stack is part of the box, never an app to install. Refused here, before the + // kept-data check or anything else that reads the request, and again inside DeployStack for every other caller. + if st, ok := r.stackMgr.GetStack(name); ok && (st.Protected || r.cfg.IsProtectedStack(name)) { + r.logger.Printf("[WARN] [api] Deploy refused for %s: a protected infra stack is part of the box", name) + writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: r.msg(req, "api.deploy.part_of_box")}) + return + } + // Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its // reserved buffer, so customer apps can't fill the volume the infra containers (controller, // traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index a428ec0..6b7cea0 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -34,6 +34,7 @@ "api.deploy.hdd_path_inside_drive": "The data location of an app must be a drive, not a folder inside a drive. Choose this one: %s.", "api.deploy.no_space": "There is not enough free space to install: only %.0f GB is free, and the system keeps %.0f GB in reserve to protect the core services (controller, proxy). Free some space, or add more storage.", "api.deploy.not_installable": "This app cannot be installed right now.", + "api.deploy.part_of_box": "This is part of the box; it is not installed.", "api.deploy.started": "The install has started – the card shows how it is going", "api.geo.app_removed": "App country limit removed", "api.geo.app_set": "App country limit set", @@ -122,6 +123,17 @@ "app_page.login_desc": "For the first sign-in. If you change the password in the app, the old one stays here.", "app_page.login_no_password": "The box saved no password for this install. Sign in with the one you set in the app.", "app_page.login_no_name": "The box saved no user name for this install. Sign in with the one you set in the app.", + "app_page.login_kind_first_visit": "You create the admin account on the first visit. The box does not know the password — keep it in your password manager.", + "app_page.login_kind_email": "This app signs you in with a link by e-mail. It needs the app's e-mail sending (Settings tab).", + "app_page.login_kind_none": "This app needs no sign-in.", + "app_page.login_kind_family": "You sign in to this app with your family name and password. You add family members on the Family card of the Settings, Security page.", + "app_page.fb_operator_set": "You set this password in the File manager; the box does not know it.", + "app_page.fb_reset_btn": "Set a new password", + "app_page.fb_reset_confirm": "The old password will stop working.", + "app_page.files_btn": "Files ↗", + "app_page.files_login_note": "The File manager asks for its own password the first time. You see the password on the File manager's page.", + "app_page.files_login_link": "The File manager's page", + "app_page.infra_guide": "It is part of the box: Felhom installs it, updates it and looks after it. You do not install it, and you cannot remove it.", "app_page.value_no": "No", "app_page.after_start_btn": "Save", "app_page.after_start_title": "One more step", @@ -935,6 +947,12 @@ "datapath.consequence.import_excluded": "Copy the files to be processed in here. The app reads them and then deletes them from here — this folder is temporary and is not backed up.", "datapath.consequence.kept": "This is where your files are kept. It is backed up.", "datapath.free_space": "%.1f GB free", + "datapath.unavailable": "(the drive is not available now)", + "datapath.media.row": "%s — %s", + "datapath.media.root": "Media", + "datapath.media.movies": "Movies", + "datapath.media.tv": "TV shows", + "datapath.media.downloads": "Downloads", "debug.night_chain": "Tonight's chain now (dump → second drive → off-site → update)", "debug.aktiv": "Active", "debug.aktiv_feladat": "Active job", @@ -1420,6 +1438,8 @@ "escrow.app_settings_unreadable": "The app’s settings cannot be read.", "escrow.field_not_revealable": "This field cannot be revealed.", "escrow.filebrowser_not_ours": "Felhom did not set the File manager password on this machine, so we cannot show it.", + "filebrowser.reset_not_operator": "The box already knows the password of the File manager — the Show button shows it.", + "filebrowser.reset_failed": "The new password could not be set. Try again in a few minutes.", "escrow.initial_pw_read_failed": "The first password could not be read.", "escrow.initial_pw_unavailable": "The first password cannot be read right now — the app has to be running, and the file may have been deleted after the first sign-in.", "escrow.missing_field": "A field is missing.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index f00d7d9..9907a5d 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -30,6 +30,7 @@ "api.deploy.hdd_path_inside_drive": "Az alkalmazás adatainak helye egy meghajtó lehet, nem egy mappa a meghajtón belül. Válaszd ezt: %s.", "api.deploy.no_space": "Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadíts fel helyet, vagy bővítsd a tárhelyet.", "api.deploy.not_installable": "Ez az alkalmazás jelenleg nem telepíthető.", + "api.deploy.part_of_box": "Ez a doboz része, nem kell telepíteni.", "api.deploy.started": "Telepítés elindítva – az állapot a kártyán követhető", "api.geo.app_removed": "Alkalmazás geo-korlátozás eltávolítva", "api.geo.app_set": "Alkalmazás geo-korlátozás beállítva", @@ -118,6 +119,17 @@ "app_page.login_desc": "Az első bejelentkezéshez. Ha az alkalmazásban megváltoztatod a jelszót, itt a régi marad.", "app_page.login_no_password": "A doboz ennél a telepítésnél nem mentett jelszót. A belépéshez azt használd, amit az alkalmazásban beállítottál.", "app_page.login_no_name": "A doboz ennél a telepítésnél nem mentett felhasználónevet. A belépéshez azt használd, amit az alkalmazásban beállítottál.", + "app_page.login_kind_first_visit": "Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.", + "app_page.login_kind_email": "Ez az alkalmazás e-mailben küld belépési linket. Ehhez az alkalmazás e-mail küldése kell (Beállítások fül).", + "app_page.login_kind_none": "Ehhez az alkalmazáshoz nem kell belépés.", + "app_page.login_kind_family": "Ehhez az alkalmazáshoz a családi neveddel és jelszavaddal lépsz be. A családtagokat a Beállítások, Biztonság oldal Család kártyáján adod hozzá.", + "app_page.fb_operator_set": "Ezt a jelszót te állítottad be a Fájlkezelőben; a doboz nem ismeri.", + "app_page.fb_reset_btn": "Új jelszó beállítása", + "app_page.fb_reset_confirm": "A régi jelszó nem fog működni.", + "app_page.files_btn": "Fájlok ↗", + "app_page.files_login_note": "A Fájlkezelő a saját jelszavát kéri az első alkalommal. A jelszót a Fájlkezelő oldalán látod.", + "app_page.files_login_link": "A Fájlkezelő oldala", + "app_page.infra_guide": "A doboz része: a Felhom telepíti, frissíti és felügyeli. Nem kell telepíteni, és nem lehet eltávolítani.", "app_page.value_no": "Nem", "app_page.after_start_btn": "Mentés", "app_page.after_start_title": "Még egy lépés", @@ -930,6 +942,12 @@ "datapath.consequence.import_excluded": "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — ez a mappa átmeneti, és nem készül róla biztonsági mentés.", "datapath.consequence.kept": "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla.", "datapath.free_space": "%.1f GB szabad", + "datapath.unavailable": "(a tárhely most nem érhető el)", + "datapath.media.row": "%s — %s", + "datapath.media.root": "Média", + "datapath.media.movies": "Filmek", + "datapath.media.tv": "Sorozatok", + "datapath.media.downloads": "Letöltések", "debug.night_chain": "Az éjszakai lánc most (mentés → 2. meghajtó → távoli → frissítés)", "debug.aktiv": "Aktív", "debug.aktiv_feladat": "Aktív feladat", @@ -1411,6 +1429,8 @@ "escrow.app_settings_unreadable": "Az alkalmazás beállításai nem olvashatók.", "escrow.field_not_revealable": "Ez a mező nem kérhető le.", "escrow.filebrowser_not_ours": "A Fájlkezelő jelszavát nem a Felhom állította be ezen a gépen, ezért nem tudjuk megmutatni.", + "filebrowser.reset_not_operator": "A Fájlkezelő jelszavát a doboz már ismeri — a Megjelenítés gomb mutatja.", + "filebrowser.reset_failed": "Az új jelszót nem sikerült beállítani. Próbáld újra pár perc múlva.", "escrow.initial_pw_read_failed": "A kezdeti jelszó beolvasása nem sikerült.", "escrow.initial_pw_unavailable": "A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.", "escrow.missing_field": "Hiányzó mező.", diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 829a83a..51339c4 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -269,6 +269,14 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { m.mu.Unlock() return "", util.KindErrorf(ErrAlreadyDeployed, "stack %q is already deployed; use update instead", req.StackName) } + // v0.320.0: a protected infra stack (traefik, cloudflared, filebrowser, samba, the controller) is + // part of the box. It has no app.yaml, so it reads as not deployed — and until this check an + // Install press on its page would have deployed the infra compose as an app. Refused before + // anything is written. Pinned by TestDeployStack_RefusesProtectedStack. + if sPtr.Protected || m.cfg.IsProtectedStack(req.StackName) { + m.mu.Unlock() + return "", util.KindErrorf(ErrProtectedStack, "stack %q is part of the box and cannot be installed", req.StackName) + } sPtr.Deploying = true sPtr.DeployError = "" m.mu.Unlock() diff --git a/controller/internal/stacks/filebrowser_password.go b/controller/internal/stacks/filebrowser_password.go index 65a42c2..efc76d2 100644 --- a/controller/internal/stacks/filebrowser_password.go +++ b/controller/internal/stacks/filebrowser_password.go @@ -2,6 +2,7 @@ package stacks import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -10,6 +11,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" + "gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) @@ -164,3 +166,140 @@ func fbSelfID(do fbHTTPDo, base, token string) (int, error) { } return u.ID, nil } + +// ── v0.320.0: a new password when the box does not know FileBrowser's (`operator`) ─────────────────────────────────── +// +// R-513 left a password it found set by hand alone and recorded `operator`. Nobody remembers that password on either +// demo box, so the household was locked out of the file manager and every folder link led to a login it could not +// pass. The household's press now replaces it. +// +// THE METHOD, spiked first (felhom.eu audits/logins-folders-2026-10-16/P1b, gtstef/filebrowser:1.5.6-stable): +// - FileBrowser's own CLI, `filebrowser set -u admin, -c ` (backend/cmd/cli.go:206-269 at the tag), +// sets an existing user's password with NO old password. Measured: new=200, old=401, admin/admin=401, and the +// other users, the shares and the settings unchanged. +// - The CLI opens the database with no lock timeout (backend/database/storage/storage.go:26), so the server is +// STOPPED while it runs, in a one-shot container with the running container's volumes; then started again. +// The file browser is away for ~7 s. +// - Not FILEBROWSER_ADMIN_PASSWORD: it re-applies on every start, so it would need a compose change and two +// recreates, and the value would sit in the container's environment. +// +// 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. +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). +const fileBrowserContainer = "filebrowser" + +// ResetFileBrowserAdminPassword replaces a FileBrowser admin password the box does not know with a generated one. +// Only in the `operator` state. The new password is applied, then VERIFIED (new=200, admin/admin refused) before +// anything is recorded; a failed verify leaves the state `operator` and returns an error that says so. The value is +// never logged. Pinned by TestFileBrowserReset_*. +func (m *Manager) ResetFileBrowserAdminPassword(ctx context.Context) error { + if m.settings == nil { + return fmt.Errorf("settings unavailable") + } + // 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 { + return ErrFileBrowserNotOperator + } + if len(m.encKey) == 0 { + return fmt.Errorf("encryption key unavailable — nothing changed") + } + pw, err := generateValue("password:16") + if err != nil { + return fmt.Errorf("generate password: %w", err) + } + set := m.fbSetPassword + if set == nil { + set = fileBrowserCLISetPassword + } + m.logger.Printf("[INFO] [infra] filebrowser: the household asked for a new admin password — stopping the file browser for its CLI") + start := time.Now() + if err := set(ctx, pw); err != nil { + return fmt.Errorf("apply the new password: %w — state left operator", err) + } + do := m.fbDo() + base := fileBrowserBaseURL + if m.fbBaseURL != "" { + base = m.fbBaseURL + } + // Wait for the file browser to answer, then verify the consequence both ways. + code, lerr := 0, error(nil) + for i := 0; i < 30; i++ { + _, code, lerr = fbLogin(do, base, pw) + if lerr == nil && (code == http.StatusOK || code == http.StatusUnauthorized) { + break + } + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for the file browser: %w — state left operator", ctx.Err()) + case <-time.After(m.fbWaitStep()): + } + } + if lerr != nil || code != http.StatusOK { + return fmt.Errorf("the new password does not log in (HTTP %d, err %v) — state left operator", code, lerr) + } + if _, c, err := fbLogin(do, base, "admin"); err != nil || c == http.StatusOK { + return fmt.Errorf("admin/admin logs in after the reset (HTTP %d, err %v) — state left operator", c, err) + } + enc, err := crypto.Encrypt(m.encKey, pw) + if err != nil { + return fmt.Errorf("encrypt the new password: %w — the password IS changed; state left operator", err) + } + now := time.Now().UTC().Format(time.RFC3339) + if err := m.settings.SetFileBrowserAdmin(settings.FileBrowserAdminGenerated, enc, now); err != nil { + m.logger.Printf("[ERROR] [infra] filebrowser: password CHANGED but settings save FAILED: %v — press „new password” again", err) + return err + } + m.logger.Printf("[INFO] [infra] filebrowser: admin password replaced at the household's press (value never logged); verified new=200 admin=refused in %s", + time.Since(start).Round(time.Second)) + return nil +} + +// fbWaitStep is the pause between two readiness probes (short in tests, where the fake answers at once). +func (m *Manager) fbWaitStep() time.Duration { + if m.fbSetPassword != nil { + return time.Millisecond + } + return 2 * time.Second +} + +// fileBrowserCLISetPassword is the production apply: stop the server, run its CLI in a one-shot container with the +// same volumes, start it again — always, even when the CLI failed. Every docker call has a deadline. +func fileBrowserCLISetPassword(ctx context.Context, pw string) error { + run := func(timeout time.Duration, args ...string) (string, error) { + // Detached from the caller's cancel: a stop that ran must be followed by its start, whatever the request does. + c, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer cancel() + out, err := dockerexec.CommandContext(c, "docker", args...).CombinedOutput() + return strings.TrimSpace(string(out)), err + } + image, err := run(15*time.Second, "inspect", "--format", "{{.Config.Image}}", fileBrowserContainer) + if err != nil || image == "" { + return fmt.Errorf("read the file browser's image: %v", err) + } + if out, err := run(60*time.Second, "stop", fileBrowserContainer); err != nil { + return fmt.Errorf("stop the file browser: %s: %v", out, err) + } + defer func() { + // Never leave it stopped. A failure here is logged by the caller's verify (the login will not answer). + _, _ = run(60*time.Second, "start", fileBrowserContainer) + }() + out, err := run(90*time.Second, "run", "--rm", "--volumes-from", fileBrowserContainer, "--user", "1000:1000", + "--workdir", "/home/filebrowser", "--entrypoint", "/home/filebrowser/filebrowser", image, + "set", "-u", "admin,"+pw, "-c", "/home/filebrowser/config.yaml") + // The CLI prints only „successfully updated user: admin" or an error — never the password; still, the output is + // reduced to a verdict before it can reach a log. + if err != nil || !strings.Contains(out, "successfully") { + verdict := "no success line" + if err != nil { + verdict = err.Error() + } + return fmt.Errorf("the file browser's CLI did not confirm (%s)", verdict) + } + return nil +} diff --git a/controller/internal/stacks/filebrowser_reset_test.go b/controller/internal/stacks/filebrowser_reset_test.go new file mode 100644 index 0000000..fa95382 --- /dev/null +++ b/controller/internal/stacks/filebrowser_reset_test.go @@ -0,0 +1,143 @@ +package stacks + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// v0.320.0 — „Uj jelszo beallitasa": the box replaces a FileBrowser admin password it does not know. The apply +// (stop / CLI / start) is seamed by fbSetPassword; the verify runs against the fake FileBrowser API, as R-513's does. + +const fbUnknownOld = "TESTONLY-set-by-hand-9q" + +func fbResetManager(t *testing.T, state string, apply func(f *fakeFB, pw string)) (*Manager, *settings.Settings, []byte, *fakeFB, *int) { + t.Helper() + fake := &fakeFB{password: fbUnknownOld} + srv := httptest.NewServer(fake.handler()) + t.Cleanup(srv.Close) + m, st, key := fbManager(t, srv.URL) + if state != "" { + if err := st.SetFileBrowserAdmin(state, "", "2026-10-16T00:00:00Z"); err != nil { + t.Fatal(err) + } + } + calls := 0 + m.fbSetPassword = func(_ context.Context, pw string) error { + calls++ + fake.mu.Lock() + apply(fake, pw) + fake.mu.Unlock() + return nil + } + return m, st, key, fake, &calls +} + +// RED-PROOF (2026-10-16, REPORT): with the `crypto.Encrypt` line replaced by storing `pw` as it is, this failed at +// „the stored value is the plaintext password". +func TestFileBrowserReset_SetsVerifiesAndStoresEncrypted(t *testing.T) { + m, st, key, fake, calls := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, pw string) { f.password = pw }) + if err := m.ResetFileBrowserAdminPassword(context.Background()); err != nil { + t.Fatalf("reset: %v", err) + } + if *calls != 1 { + t.Fatalf("apply ran %d times", *calls) + } + state, enc, _ := st.GetFileBrowserAdmin() + if state != settings.FileBrowserAdminGenerated { + t.Fatalf("state = %q, want generated", state) + } + if enc == "" || enc == fake.password { + t.Fatal("the stored value is the plaintext password (or empty)") + } + if got, err := crypto.Decrypt(key, enc); err != nil || got != fake.password { + t.Fatalf("the stored value does not decrypt to the password FileBrowser now has (err %v)", err) + } + if fake.password == fbUnknownOld || len(fake.password) != 16 { + t.Fatalf("FileBrowser's password was not replaced by a generated 16-character one") + } +} + +// RED-PROOF (2026-10-16, REPORT): with the `code != http.StatusOK` verify deleted, this failed at „a failed verify +// recorded generated" — the box would offer a password that does not log in. +func TestFileBrowserReset_FailedVerifyKeepsOperator(t *testing.T) { + // The CLI „succeeded" but FileBrowser still has another password. + m, st, _, _, _ := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, _ string) { f.password = "TESTONLY-something-else" }) + err := m.ResetFileBrowserAdminPassword(context.Background()) + if err == nil { + t.Fatal("a reset whose new password does not log in reported success") + } + if state, enc, _ := st.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator || enc != "" { + t.Fatalf("a failed verify recorded %q (enc set: %v), want operator and nothing stored", state, enc != "") + } + // admin/admin still logging in after the apply is a failure too. + m2, st2, _, _, _ := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, _ string) { f.password = "admin" }) + if err := m2.ResetFileBrowserAdminPassword(context.Background()); err == nil { + t.Fatal("a reset that left admin/admin working reported success") + } + if state, _, _ := st2.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator { + t.Fatalf("state = %q, want operator", state) + } +} + +// RED-PROOF (2026-10-16, REPORT): with the state check removed, this failed at „generated: the apply ran" — the +// press would have replaced a password the household can already see. +func TestFileBrowserReset_OnlyInOperatorState(t *testing.T) { + for _, state := range []string{"", settings.FileBrowserAdminGenerated} { + m, st, _, _, calls := fbResetManager(t, state, func(f *fakeFB, pw string) { f.password = pw }) + err := m.ResetFileBrowserAdminPassword(context.Background()) + if !errors.Is(err, ErrFileBrowserNotOperator) { + t.Errorf("state %q: err = %v, want ErrFileBrowserNotOperator", state, err) + } + if *calls != 0 { + t.Errorf("state %q: the apply ran", state) + } + if got, _, _ := st.GetFileBrowserAdmin(); got != state { + t.Errorf("state %q changed to %q", state, got) + } + } +} + +// RED-PROOF (2026-10-16, REPORT): with the protected check removed from DeployStack, this failed at „filebrowser +// (Protected=true): err = went on into the deploy". +func TestDeployStack_RefusesProtectedStack(t *testing.T) { + cfg := config.Default() + cfg.Stacks.Protected = []string{"filebrowser"} + for _, c := range []struct { + name string + protected bool + }{{"filebrowser", true}, {"filebrowser", false}, {"samba", false}} { + m := &Manager{cfg: cfg, stacks: map[string]*Stack{c.name: {Name: c.name, Protected: c.protected}}} + var err error + func() { + // This bare manager has nothing a real deploy needs, so a call that gets PAST the guard panics — which + // is itself the proof that it went on into the deploy. + defer func() { + if r := recover(); r != nil { + err = errors.New("went on into the deploy (panicked on the bare manager)") + } + }() + _, err = m.DeployStack(DeployRequest{StackName: c.name}) + }() + if !errors.Is(err, ErrProtectedStack) { + t.Errorf("%s (Protected=%v): err = %v, want ErrProtectedStack", c.name, c.protected, err) + } + if m.stacks[c.name].Deploying { + t.Errorf("%s: left Deploying set", c.name) + } + } +} + +func TestEffectiveLoginKind_DefaultAndUnknown(t *testing.T) { + for in, want := range map[string]string{"": "first_visit", "first_visit": "first_visit", "email": "email", + "none": "none", "magic": "first_visit", "EMAIL": "first_visit"} { + if got := (AppInfo{LoginKind: in}).EffectiveLoginKind(); got != want { + t.Errorf("login_kind %q → %q, want %q", in, got, want) + } + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 0e979af..fe2fadc 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -279,6 +279,8 @@ type Manager struct { // R-513: FileBrowser admin-password seams (filebrowser_password.go); nil/"" in production. fbHTTP fbHTTPDo fbBaseURL string + // fbSetPassword replaces the stop / CLI / start sequence of ResetFileBrowserAdminPassword in tests; nil in production. + fbSetPassword func(ctx context.Context, password string) error // execFn replaces execCommand's process boundary in tests; nil in production. execFn func(name string, args ...string) (string, error) // composeExecFn replaces the initial deploy's compose call (composeExecWithEnv) in tests; nil in production. diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 406e432..089475a 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -169,6 +169,29 @@ type AppInfo struct { // Guide (v0.315.0): the app's own user guide, shown on the app page's „Utmutato" tab. Optional — // an app with no guide block renders the tab's existing content and says a guide is still to come. Guide AppGuide `yaml:"guide,omitempty" json:"guide,omitempty"` + // LoginKind (v0.320.0): how the household signs in when the box holds no login for the app — + // `first_visit` (the default), `email` or `none`. An enum, not copy: the sentence is the controller's, + // so it is not in the overlay. Read only through EffectiveLoginKind. + LoginKind string `yaml:"login_kind,omitempty" json:"login_kind,omitempty"` +} + +// The three values of app_info.login_kind. Anything else (a typo, a value a newer catalog adds) reads +// as LoginKindFirstVisit — the sentence that promises least: „the box does not know the password". +const ( + LoginKindFirstVisit = "first_visit" + LoginKindEmail = "email" + LoginKindNone = "none" +) + +// EffectiveLoginKind is app_info.login_kind with the default applied. Pinned by +// TestEffectiveLoginKind_DefaultAndUnknown. +func (a AppInfo) EffectiveLoginKind() string { + switch a.LoginKind { + case LoginKindEmail, LoginKindNone: + return a.LoginKind + default: + return LoginKindFirstVisit + } } // AppGuide is the catalog's user guide for one app: numbered sections and a FAQ, both plain text. diff --git a/controller/internal/web/app_initcreds_exposure_test.go b/controller/internal/web/app_initcreds_exposure_test.go index 6bf483f..432b289 100644 --- a/controller/internal/web/app_initcreds_exposure_test.go +++ b/controller/internal/web/app_initcreds_exposure_test.go @@ -46,6 +46,9 @@ func renderAppInfo(t *testing.T, creds *stacks.ExtractedCreds, hasPassword bool) data["InitialCreds"] = creds data["InitialCredsHasPassword"] = hasPassword } + // v0.320.0: the settings tab's handler picks the login panel's ONE answer from this same data. + meta := data["Meta"].(stacks.Metadata) + data["LoginAnswer"] = loginAnswerFor(data, &meta) var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, "app_settings", data); err != nil { t.Fatalf("render app_info: %v", err) @@ -79,7 +82,8 @@ func TestAppInfoPage_DoesNotContainTheInitialPassword(t *testing.T) { t.Error("the old hidden-value element is back in the markup") } // The feature must survive: the fix removes the VALUE, not the customer's access (Scenario B). - if !strings.Contains(html, "Kezdeti belépési adatok") { + // v0.320.0: the card is the login panel's „initial" answer (it was its own „Kezdeti belepesi adatok" card). + if !strings.Contains(html, `data-login-answer="initial"`) { t.Error("the initial-credentials card vanished — the fix must not take the password away " + "from the person whose app it is") } @@ -99,7 +103,7 @@ func TestAppInfoPage_DoesNotContainTheInitialPassword(t *testing.T) { func TestAppInfoPage_NoCredentialsCard_WhenAppHasNone(t *testing.T) { html := renderAppInfo(t, nil, false) - if strings.Contains(html, "Kezdeti belépési adatok") { + if strings.Contains(html, `data-login-answer="initial"`) || strings.Contains(html, `id="initcred-reveal"`) { t.Error("the initial-credentials card rendered for an app with no generated credentials") } if strings.Contains(html, "initial-credentials/reveal") { diff --git a/controller/internal/web/app_login.go b/controller/internal/web/app_login.go index c92ff0d..7898b87 100644 --- a/controller/internal/web/app_login.go +++ b/controller/internal/web/app_login.go @@ -157,3 +157,35 @@ func appSettingRows(meta *stacks.Metadata, env map[string]string, yes, no string } return out } + +// THE LOGIN ANSWER (v0.320.0) — every installed app's settings tab says where its login is, in ONE panel with ONE +// answer. The box can show a password only if it knows it, so the answers are, in this order: +// +// fields a `type: password` field (and its name): the value fetched on demand, or — when the box holds none — +// the v0.319.0 sentence (the empty-value rule above) +// initial the app wrote a login at its first start and the box reads it (InitialCreds); FileBrowser's own +// (R-513) is this answer too, and in its `operator` state it is a sentence and the „new password" press +// first_visit the household makes the admin on the first visit — the catalog's app_info.login_kind, default +// email the app signs in by an e-mailed link +// none the app has no sign-in +// +// An infra stack with no login of its own (traefik, cloudflared, samba) gets no panel: it is not an app anyone signs +// into. Pinned by TestAppPage_EveryInstalledAppHasOneLoginAnswer. +const ( + loginAnswerFields = "fields" + loginAnswerInitial = "initial" +) + +// loginAnswerFor picks the answer from the page data the tab already assembled ("" = no panel). +func loginAnswerFor(data map[string]interface{}, meta *stacks.Metadata) string { + if f, _ := data["LoginFields"].([]AppLoginField); len(f) > 0 { + return loginAnswerFields + } + if ic, _ := data["InitialCreds"].(*stacks.ExtractedCreds); ic != nil { + return loginAnswerInitial + } + if infra, _ := data["Infra"].(bool); infra || meta == nil { + return "" + } + return meta.AppInfo.EffectiveLoginKind() +} diff --git a/controller/internal/web/app_page.go b/controller/internal/web/app_page.go index bc1f8fc..0f4d40a 100644 --- a/controller/internal/web/app_page.go +++ b/controller/internal/web/app_page.go @@ -55,23 +55,42 @@ func (s *Server) appPageHandler(w http.ResponseWriter, r *http.Request, slug, ta return } - if !found.Deployed { + // v0.320.0: a protected infra stack is installed by definition — it is part of the box (appCommonData sets + // Infra). It gets the settings, guide and log tabs; never the data tab (its export, move and backup are not the + // household's), and never the install wizard. Pinned by TestAppPage_InfraAppHasNoInstall. + infra, _ := data["Infra"].(bool) + installed := found.Deployed || infra + if !installed { if tab != "" { http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound) return } tab = appTabGuide } + if infra && tab == appTabData { + http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound) + return + } if tab == "" { tab = appTabSettings } data["ActiveTab"] = tab - data["AppTabs"] = s.appTabLinks(r, slug, found.Deployed, tab) - data["Deployed"] = found.Deployed + tabs := s.appTabLinks(r, slug, installed, tab) + if infra { + kept := tabs[:0] + for _, t := range tabs { + if t.Key != appTabData { + kept = append(kept, t) + } + } + tabs = kept + } + data["AppTabs"] = tabs + data["Deployed"] = installed // The shell's „Megnyitas" shows only for an app that is actually up — a link to a stopped app's // address lands on the catch-all page, which is a worse answer than no link. - data["AppOperational"] = found.Deployed && isOperationalState(found.State) + data["AppOperational"] = installed && isOperationalState(found.State) switch tab { case appTabSettings: @@ -133,12 +152,18 @@ func (s *Server) renderAppSettingsTab(w http.ResponseWriter, r *http.Request, fo // The form half of this tab is deployPageData's — the same keys the install page gets, with // AlreadyDeployed true. Merged OVER the common data, except the four keys that say which page // this is: those belong to the tab, not to the form. - if form, ok := s.deployPageData(w, r, found.Name); ok { - mergeAppPageData(data, form) - } else { - return + // + // v0.320.0: an infra stack has no install form, no app.yaml and no catalog fields — its tab is „Mukodes" + // (restart only) and, for FileBrowser, the login. deployPageData is not asked. + if infra, _ := data["Infra"].(bool); !infra { + if form, ok := s.deployPageData(w, r, found.Name); ok { + mergeAppPageData(data, form) + } else { + return + } + data["AfterStartFields"] = s.afterStartFields(found) } - data["AfterStartFields"] = s.afterStartFields(found) + data["LoginAnswer"] = loginAnswerFor(data, &found.Meta) data["Title"] = found.Meta.DisplayName data["TitleKey"], data["TitleArgs"] = "page.title.app_settings", []interface{}{found.Meta.DisplayName} s.executeTemplate(w, r, "app_settings", data) @@ -214,6 +239,12 @@ func (s *Server) appInstallPageHandler(w http.ResponseWriter, r *http.Request, s http.NotFound(w, r) return } + // v0.320.0: part of the box, never installed — the address answers with the app's page, like a tab of a + // not-installed app does. The deploy POST refuses it too (api deployStack, stacks.DeployStack). + if s.cfg.IsProtectedStack(name) { + http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound) + return + } s.deployHandler(w, r, name) } diff --git a/controller/internal/web/datapath_card.go b/controller/internal/web/datapath_card.go index 660f110..cd7d667 100644 --- a/controller/internal/web/datapath_card.go +++ b/controller/internal/web/datapath_card.go @@ -1,26 +1,32 @@ package web import ( + "path" "path/filepath" + "sort" + "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // „Hova tegyem a fájlokat?" — the app-page folder card (R-75). // -// Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the +// Rendered only for DEPLOYED apps that declare data_paths or media_library (Fork-2: all catalog apps get the // skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed // is the thing that would actually confuse someone). // DataPathCard is one folder row on the app page. type DataPathCard struct { Label string // the catalog's Hungarian label - Link string // FileBrowser deep link + Link string // FileBrowser deep link; "" = the folder's source is not on the box now — shown as text Consequence string // class-DRIVEN copy (never hand-written per app) IsImport bool // drives the free-space line FreeSpace string // system-drive headroom, import rows only ("" when unreadable) + Role stacks.DataPathRole + IsMedia bool // a per-drive media folder (media_library), not a declared data_path } // consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4). @@ -56,7 +62,7 @@ func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole, msg fun // line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead. func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCard { msg := func(key string) string { return s.msgLang(lang, key) } - if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 { + if st == nil || !st.Deployed || (len(st.Meta.DataPaths) == 0 && st.Meta.MediaLibrary == nil) { return nil } domain := s.cfg.Customer.Domain @@ -88,12 +94,15 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar link = importFolderLink(domain, dp.Path) case appbackup.RootUserdata: // A userdata folder lives on the app's OWN drive, so its FileBrowser source is that - // drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot. - src := s.fbSourceNameForApp(st.Name) - if src == "" { - continue + // drive's sidebar entry. Resolve it from the app's HDD_PATH. v0.320.0: a source that is not + // on the box now (no drive resolved, or the drive is not present, so FileBrowser does not + // 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) + } 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) } - link = fileBrowserLink(domain, src, dp.Path) default: // hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it. // Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate @@ -106,6 +115,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar Link: link, Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role, msg), IsImport: isImport, + Role: dp.Role, } if isImport { // The system SSD filling is a different severity from a data drive filling — it can take @@ -115,29 +125,174 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar } cards = append(cards, card) } - return cards + return append(cards, s.mediaFolderCards(st, domain, lang, cards)...) +} + +// mediaLibraryFolders are the `media/` subfolders a whole-media app's library uses — the folders internal/mediawire +// wires into it (Sonarr `tv`, Radarr `movies`, the players both). An app the table does not name gets the `media` +// folder itself. A download client's `media_library.folder` (qBittorrent `torrents`) is its one folder. +var mediaLibraryFolders = map[string][]string{ + "sonarr": {"tv"}, + "radarr": {"movies"}, + "plex": {"movies", "tv"}, + "jellyfin": {"movies", "tv"}, + "emby": {"movies", "tv"}, +} + +// mediaFolderLabelKeys name each media subfolder for the household. A folder with no key is shown by its name. +var mediaFolderLabelKeys = map[string]string{ + "": "datapath.media.root", + "movies": "datapath.media.movies", + "tv": "datapath.media.tv", + "torrents": "datapath.media.downloads", + "transmission": "datapath.media.downloads", +} + +// mediaFolderCards lists, for a media app (`media_library:`), each enrolled drive's media folders the app uses +// (`15-media-library.md` §2). A present drive's folder is a link into that drive's FileBrowser source; a drive that +// is not present is text, with one WARN. No consequence line: the bind's class says the folder is not THIS app's +// data (Sonarr/Radarr/the players declare it `excluded`), which is not the sentence „temporary, not backed up" — +// and the per-drive binds are in no classifier at all (`15` §7). An unverified backup promise is worse than none. +// A folder a declared data_path already links to is not listed twice. +func (s *Server) mediaFolderCards(st *stacks.Stack, domain, lang string, have []DataPathCard) []DataPathCard { + spec := st.Meta.MediaLibrary + if spec == nil || s.settings == nil { + return nil + } + subs := mediaLibraryFolders[st.Meta.Slug] + if f := strings.Trim(path.Clean("/"+spec.Folder), "/"); f != "" { + subs = []string{f} + } else if len(subs) == 0 { + subs = []string{""} + } + seen := map[string]bool{} + for _, c := range have { + if c.Link != "" { + seen[c.Link] = true + } + } + var out []DataPathCard + for _, sp := range s.mediaStoragePaths() { + src := fbSourceName(sp) + present := s.drivePresent(sp.Path) + if !present { + s.logger.Printf("[WARN] [web] app page %s: drive %q is not present — its media folders are shown without a link", st.Name, sp.Path) + } + for _, sub := range subs { + label := sub + if k, ok := mediaFolderLabelKeys[sub]; ok { + label = s.msgLang(lang, k) + } + 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)) + if seen[card.Link] { + continue + } + seen[card.Link] = true + } + out = append(out, card) + } + } + return out +} + +// mediaStoragePaths are the drives a media app binds: enrolled, not network, not decommissioned (the same set +// stacks.mediaDrives starts from), sorted by path so the first drive is stable. +func (s *Server) mediaStoragePaths() []settings.StoragePath { + var out []settings.StoragePath + seen := map[string]bool{} + for _, sp := range s.settings.GetStoragePaths() { + p := filepath.Clean(sp.Path) + if sp.Kind == "network" || sp.Decommissioned || sp.Path == "" || seen[p] { + continue + } + seen[p] = true + sp.Path = p + out = append(out, sp) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// drivePresent reports whether a drive is bound into FileBrowser now: the stack manager's present media drives +// (enrolled, mounted, not dead, not safely removed) — the same gates buildFileBrowserPaths applies. +func (s *Server) drivePresent(drive string) bool { + if drive == "" { + return false + } + var present []string + switch { + case s.presentDrivesFn != nil: + present = s.presentDrivesFn() + case s.stackMgr != nil: + present = s.stackMgr.PresentMediaDrives() + } + for _, p := range present { + if filepath.Clean(p) == filepath.Clean(drive) { + return true + } + } + return false +} + +// filesButtonLink is the target of the app header's „Fajlok" button: the first folder with the library role, else +// the first declared folder, else (a media app) the first present drive's `media` folder. Only folders that are a +// link count. "" = no button (an app with no folder on the box, e.g. Immich's data in a named volume). +func (s *Server) filesButtonLink(st *stacks.Stack, cards []DataPathCard) string { + for _, c := range cards { + if !c.IsMedia && c.Link != "" && c.Role == stacks.RoleLibrary { + return c.Link + } + } + for _, c := range cards { + if !c.IsMedia && c.Link != "" { + return c.Link + } + } + if st == nil || st.Meta.MediaLibrary == nil || s.settings == nil || s.cfg.Customer.Domain == "" { + return "" + } + for _, sp := range s.mediaStoragePaths() { + if s.drivePresent(sp.Path) { + return fileBrowserLink(s.cfg.Customer.Domain, fbSourceName(sp), "media") + } + } + return "" } // fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed // on: the storage path's label when it has one, else the mount basename — exactly what // RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree. func (s *Server) fbSourceNameForApp(stackName string) string { + src, _ := s.fbSourceForApp(stackName) + return src +} + +// fbSourceForApp is fbSourceNameForApp plus the drive path it resolved ("" when none). +func (s *Server) fbSourceForApp(stackName string) (source, drive string) { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { - return "" + return "", "" } hdd := appCfg.Env["HDD_PATH"] if hdd == "" { - return "" + return "", "" } for _, sp := range s.settings.GetStoragePaths() { if sp.Path != hdd || sp.Decommissioned { continue } - if sp.Label != "" { - return sp.Label - } - return filepath.Base(sp.Path) + return fbSourceName(sp), sp.Path } - return "" + return "", hdd +} + +// fbSourceName is a drive's FileBrowser sidebar name: its label, else the mount basename — exactly what +// infra.RenderFileBrowserConfig emits, so a link and the sidebar cannot disagree. +func fbSourceName(sp settings.StoragePath) string { + if sp.Label != "" { + return sp.Label + } + return filepath.Base(sp.Path) } diff --git a/controller/internal/web/filebrowser_login_test.go b/controller/internal/web/filebrowser_login_test.go index 2f3968f..a1fe640 100644 --- a/controller/internal/web/filebrowser_login_test.go +++ b/controller/internal/web/filebrowser_login_test.go @@ -34,6 +34,9 @@ func renderFBPage(t *testing.T, operatorSet bool) string { } else { data["InitialCredsHasPassword"] = true } + meta := data["Meta"].(stacks.Metadata) + data["Infra"] = true + data["LoginAnswer"] = loginAnswerFor(data, &meta) // v0.320.0: what the settings tab's handler sets var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, "app_settings", data); err != nil { t.Fatalf("render: %v", err) @@ -46,8 +49,8 @@ func TestFileBrowserLoginCard_Generated(t *testing.T) { if !strings.Contains(html, "initial-credentials/reveal") || !strings.Contains(html, "admin") { t.Fatal("generated state: the login card has no reveal call or no username") } - if strings.Contains(html, "zemeltet") { // „üzemeltető" — ASCII fragment - t.Fatal("generated state shows the operator-set text") + if strings.Contains(html, "data-fb-operator") || strings.Contains(html, "data-fb-reset") { + t.Fatal("generated state shows the operator-set text or the new-password button") } } @@ -56,8 +59,13 @@ func TestFileBrowserLoginCard_Generated(t *testing.T) { // "operator state still offers a reveal". func TestFileBrowserLoginCard_OperatorSet(t *testing.T) { html := renderFBPage(t, true) - if !strings.Contains(html, "zemeltet") { - t.Fatal("operator state does not say the operator set the password") + // v0.320.0: the sentence is the household's („te allitottad be... a doboz nem ismeri") and the + // „new password" press sits under it. + if !strings.Contains(html, "data-fb-operator") || !strings.Contains(html, "doboz nem ismeri") { + t.Fatal("operator state does not say the box does not know the password") + } + if !strings.Contains(html, `data-fb-reset="1"`) || !strings.Contains(html, "/apps/filebrowser/initial-credentials/reset") { + t.Fatal("operator state offers no new-password button") } if strings.Contains(html, `id="initcred-reveal"`) { t.Fatal("operator state still offers a reveal for a password the Felhom does not hold") diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 5c9efb0..ac5a342 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -768,7 +768,24 @@ func (s *Server) appCommonData(w http.ResponseWriter, r *http.Request, slug stri // without a second decision to get wrong. found = stacks.LocalizeStackPtr(found, s.langFor(r)) + // v0.320.0: a protected infra stack (FileBrowser, traefik, cloudflared, samba) is PART OF THE BOX. It has no + // app.yaml, so Deployed is false; every other surface already counts `Deployed || Protected` as on the box, and + // the app page now does too (appPageHandler). Its name and address are the curated ones (inframeta.go). + infraApp := found.Protected || s.cfg.IsProtectedStack(found.Name) + var infraInfo *InfraMeta + if infraApp { + infraInfo = infraMetaIn(s.bundle(), s.langFor(r), found.Name) + if infraInfo != nil { + found.Meta.DisplayName = infraInfo.DisplayName + } + if effectiveSubdomain == "" { + effectiveSubdomain = protectedStackSubdomains[found.Name] + } + } + data := s.baseData("stacks", found.Meta.DisplayName) + data["Infra"] = infraApp + data["InfraInfo"] = infraInfo data["Stack"] = found data["Meta"] = found.Meta // R-498: the catalog copy says `wiki.DOMAIN`; the household needs its own address. @@ -816,6 +833,10 @@ func (s *Server) appCommonData(w http.ResponseWriter, r *http.Request, slug stri // app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did. if cards := s.buildDataPathCards(found, s.langFor(r)); len(cards) > 0 { data["DataPathCards"] = cards + // v0.320.0: the header's „Fajlok" button — one folder of the app, opened in FileBrowser. + if link := s.filesButtonLink(found, cards); link != "" { + data["FilesLink"] = link + } } // Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first @@ -2700,6 +2721,39 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req escrowJSON(w, http.StatusOK, map[string]any{"password": creds.Password}, "") } +// appFileBrowserResetHandler — POST /apps/filebrowser/initial-credentials/reset (v0.320.0). The household's +// „Új jelszó beállítása" in the `operator` state: the box does not know the file manager's password, so it sets a +// new one (stacks.ResetFileBrowserAdminPassword — stop, the file browser's own CLI, start, verify, record). +// POST + CSRF (router), no-store, logged as an act; the value is never in the response — the page reloads and +// offers the existing reveal. Only for FileBrowser; any other slug is 404. +func (s *Server) appFileBrowserResetHandler(w http.ResponseWriter, r *http.Request, slug string) { + w.Header().Set("Cache-Control", "no-store") + if s.stackMgr == nil { + escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.stack_mgr_unavailable")) + return + } + if s.stackNameForSlug(slug) != fileBrowserStack { + escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) + return + } + // The mount sync must not recreate the file browser while it is stopped for its CLI. + s.fileBrowserMu.Lock() + defer s.fileBrowserMu.Unlock() + s.logger.Printf("[INFO] [web] filebrowser: new admin password requested from %s", clientIP(r)) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + if err := s.stackMgr.ResetFileBrowserAdminPassword(ctx); err != nil { + if errors.Is(err, stacks.ErrFileBrowserNotOperator) { + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "filebrowser.reset_not_operator")) + return + } + s.logger.Printf("[WARN] [web] filebrowser: new admin password FAILED: %v", err) + escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "filebrowser.reset_failed")) + return + } + escrowJSON(w, http.StatusOK, nil, "") +} + // fileBrowserStack is the protected infra stack whose admin password R-513 manages. const fileBrowserStack = "filebrowser" diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 125ddd3..f51532b 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -508,6 +508,62 @@ func i18nCases() []i18nCase { d["HoldLogKept"] = true return d }}) + // v0.320.0 — logins and folders: the infra app page (FileBrowser), the two login sentences a catalog key + // chooses, and the „Fajlok" button with a media folder and a folder whose drive is away. Captured when born. + infraFB := func() map[string]interface{} { + d := i18nLayoutData("stacks", "FileBrowser") + st := stacks.Stack{Name: "filebrowser", Deployed: false, Protected: true, State: stacks.StateRunning} + st.Meta = stacks.Metadata{DisplayName: "FileBrowser", Slug: "filebrowser"} + d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo + d["Infra"] = true + d["InfraInfo"] = &InfraMeta{DisplayName: "FileBrowser", Description: "Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.", Linked: true} + d["EffectiveSubdomain"] = "files" + d["Deployed"], d["AppOperational"] = true, true + return d + } + base = append(base, i18nCase{"app_settings_infra_filebrowser", "app_settings", func() map[string]interface{} { + d := infraFB() + d["AppTabs"] = []AppTabLink{{Key: appTabSettings, Href: "/apps/filebrowser/settings", Label: "Beállítások", Active: true}, + {Key: appTabGuide, Href: "/apps/filebrowser/guide", Label: "Útmutató"}, {Key: appTabLogs, Href: "/apps/filebrowser/logs", Label: "Napló"}} + d["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Note: "A Fájlkezelő belépése."} + d["InitialCredsHasPassword"] = true + return d + }}, i18nCase{"app_guide_infra_filebrowser", "app_guide", func() map[string]interface{} { + d := infraFB() + d["AppTabs"] = []AppTabLink{{Key: appTabSettings, Href: "/apps/filebrowser/settings", Label: "Beállítások"}, + {Key: appTabGuide, Href: "/apps/filebrowser/guide", Label: "Útmutató", Active: true}, {Key: appTabLogs, Href: "/apps/filebrowser/logs", Label: "Napló"}} + return d + }}, i18nCase{"app_settings_login_email", "app_settings", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Rallly") + st := i18nStack("rallly", "Rallly", stacks.StateRunning, true) + st.Meta.AppInfo.LoginKind = stacks.LoginKindEmail + d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo + return d + }}, i18nCase{"app_settings_login_none", "app_settings", func() map[string]interface{} { + d := i18nLayoutData("stacks", "BentoPDF") + st := i18nStack("bentopdf", "BentoPDF", stacks.StateRunning, true) + st.Meta.AppInfo.LoginKind = stacks.LoginKindNone + d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo + return d + }}, i18nCase{"app_settings_login_family", "app_settings", func() map[string]interface{} { + d := i18nLayoutData("stacks", "MeTube") + st := i18nStack("metube", "MeTube", stacks.StateRunning, true) + st.Meta.AppInfo.LoginKind = stacks.LoginKindNone + st.Meta.FamilyGate = true + d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo + return d + }}, i18nCase{"app_data_files_media", "app_data", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Plex") + st := i18nStack("plex", "Plex", stacks.StateRunning, true) + d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo + d["DisplayName"], d["StackName"] = "Plex", "plex" + d["FilesLink"] = "https://files.example.hu/files/HDD%201/media" + d["DataPathCards"] = []DataPathCard{ + {Label: "Filmek — HDD 1", Link: "https://files.example.hu/files/HDD%201/media/movies", IsMedia: true, Role: stacks.RoleLibrary}, + {Label: "Filmek — HDD 2", IsMedia: true, Role: stacks.RoleLibrary}} + return d + }}) + // v0.315.0 — THE APP PAGE'S OWN TABS. Three cases, each for a tab whose content no earlier case // covered: the data tab's folder cards and move offer, the guide tab with a full guide, and the // guide tab's „no guide yet" line. Captured when born. @@ -601,6 +657,17 @@ func fillI18nAppShell(s *Server, lang, tmpl string, d map[string]interface{}) { if _, set := d["AppTabs"]; !set { d["AppTabs"] = s.appTabLinksLang(lang, slug, deployed, tab) } + // v0.320.0: the settings tab's login panel answer, chosen by the handler's own function from the same data. + if _, set := d["LoginAnswer"]; !set && tab == appTabSettings && deployed { + var meta *stacks.Metadata + switch m := d["Meta"].(type) { + case stacks.Metadata: + meta = &m + case *stacks.Metadata: + meta = m + } + d["LoginAnswer"] = loginAnswerFor(d, meta) + } } func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string { diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index 1c870bf..3b4460c 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -360,20 +360,7 @@ func (s *Server) localeFuncs(lang string) template.FuncMap { }, // infraMeta: the protected infra stacks' curated identity (inframeta.go). Names that are product // names stay; descriptions — and samba's display name — come from the bundle. - "infraMeta": func(name string) *InfraMeta { - m := infraMetaFor(name) - if m == nil { - return nil - } - out := *m - if v, _, ok := b.Text(lang, "func.infra."+name+".description"); ok { - out.Description = v - } - if v, _, ok := b.Text(lang, "func.infra."+name+".display"); ok { - out.DisplayName = v - } - return &out - }, + "infraMeta": func(name string) *InfraMeta { return infraMetaIn(b, lang, name) }, // updateBadge / lifecycleBadge (R-589, v0.258.0) — the two *MetaBadge builders. Their // Hungarian forms in templateFuncMap are UNTOUCHED, which is the parity guarantee; these // rebuild the same badge from the bundle, reusing compareInstalledToTemplate and diff --git a/controller/internal/web/inframeta.go b/controller/internal/web/inframeta.go index 537432e..4380519 100644 --- a/controller/internal/web/inframeta.go +++ b/controller/internal/web/inframeta.go @@ -1,5 +1,7 @@ package web +import "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" + // Infra-stack display metadata (v0.126.0 Part B). The protected base stacks (infra.go: // traefik, cloudflared, filebrowser) carry no catalog metadata, so they rendered as bare // directory names on the dashboard and app cards. This map gives them curated Hungarian @@ -48,6 +50,26 @@ func infraMetaFor(stackName string) *InfraMeta { return nil } +// infraMetaIn is infraMetaFor in a language: names that are product names stay; descriptions — and samba's display +// name — come from the bundle. The template func `infraMeta` and the app page (v0.320.0) both read it here. +func infraMetaIn(b *i18n.Bundle, lang, stackName string) *InfraMeta { + m := infraMetaFor(stackName) + if m == nil { + return nil + } + out := *m + if b == nil { + return &out + } + if v, _, ok := b.Text(lang, "func.infra."+stackName+".description"); ok { + out.Description = v + } + if v, _, ok := b.Text(lang, "func.infra."+stackName+".display"); ok { + out.DisplayName = v + } + return &out +} + // InfraLogoSVG is the shared generic infra icon (Lucide-style "server", monochrome, // --text-2 stroke) — the last-resort icon for infra stacks whose logo asset is not // synced. Served at /static/infra-logo.svg; consumed via the app-row FallbackIcon slot. diff --git a/controller/internal/web/logins_folders_test.go b/controller/internal/web/logins_folders_test.go new file mode 100644 index 0000000..0e86ec3 --- /dev/null +++ b/controller/internal/web/logins_folders_test.go @@ -0,0 +1,354 @@ +package web + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// LOGINS AND FOLDERS (v0.320.0) — FileBrowser is part of the box, every installed app says where its login is, and +// a „Fajlok" button opens one of the app's folders. +// +// Every test goes through s.ServeHTTP (the production route switch, appCommonData and the tab handlers), on a REAL +// stack manager that scanned a stacks dir: the defect this release fixes was the page handler reading the wrong +// field (Deployed) for a stack the scan builds, so a test with hand-built page data would assert nothing about it. +// Only docker (a PATH stub) and the drive mount table (presentDrivesFn) are seamed. Markers are ASCII. + +const ( + lfDriveA = "/mnt/felhom-drives/hdd_1" + lfDriveB = "/mnt/felhom-drives/hdd_2" + // A drive label with a space, an ampersand and accents — the FileBrowser source name is the URL identity, and + // it must be PathEscaped (a space is %20, never +). + lfLabelA = "Média & könyvtár" + lfLabelB = "HDD 2" + // Values the box holds; none may appear in any page. + lfSecretPW = "TESTONLY-lf-pw-Qx81" + lfFBPW = "TESTONLY-lf-fb-Zk40" +) + +type lfApp struct { + meta, compose string + env map[string]string // nil = not installed +} + +var lfApps = map[string]lfApp{ + // (a)/(c) the box's own login fields; data_paths: an import folder FIRST, the library second. + "calibre-web": { + meta: "display_name: Calibre-Web\nslug: calibre-web\nsubdomain: books\n" + + "data_paths:\n - path: calibre\n root: import\n role: import\n label: Beolvasando\n" + + " - path: media/books\n root: userdata\n role: library\n label: E-konyvtar\n" + + "deploy_fields:\n - env_var: HDD_PATH\n label: Konyvtar\n type: path\n required: true\n" + + " - env_var: ADMIN_USER\n label: Admin nev\n type: secret\n generate: \"hex:5\"\n" + + " - env_var: ADMIN_PASSWORD\n label: Admin jelszo\n type: password\n generate: \"password:24\"\n", + compose: "services:\n calibre-web:\n image: busybox\n volumes:\n - ${IMPORT_PATH}/calibre:/ingest\n - ${USERDATA_PATH}/media/books:/library\n", + env: map[string]string{"HDD_PATH": lfDriveA, "ADMIN_USER": "TESTONLY-lf-user", "ADMIN_PASSWORD": lfSecretPW}, + }, + // (c) the same fields, no stored password (installed before the template gained it). + "kimai": { + meta: "display_name: Kimai\nslug: kimai\n" + + "deploy_fields:\n - env_var: ADMIN_PASSWORD\n label: Admin jelszo\n type: password\n", + compose: "services:\n kimai:\n image: busybox\n", + env: map[string]string{}, + }, + "romm": { + meta: "display_name: RomM\nslug: romm\n" + + "data_paths:\n - path: roms\n root: userdata\n role: library\n label: ROM gyujtemeny\n" + + " - path: roms-bios\n root: userdata\n role: library\n label: BIOS\n" + + "deploy_fields:\n - env_var: HDD_PATH\n label: Tarhely\n type: path\n required: true\n", + compose: "services:\n romm:\n image: busybox\n volumes:\n - ${USERDATA_PATH}/roms:/roms\n - ${USERDATA_PATH}/roms-bios:/bios\n", + env: map[string]string{"HDD_PATH": lfDriveA}, + }, + // A media app with no data_paths, on a box with two drives. + "plex": { + meta: "display_name: Plex\nslug: plex\nmedia_library:\n read_only: true\n", + compose: "services:\n plex:\n image: busybox\n volumes:\n - ${USERDATA_PATH}/media:/media:ro\n", + env: map[string]string{"HDD_PATH": lfDriveA}, + }, + // (d) first visit — the default; its data is in a named volume, so no folder and no button. + "immich": {meta: "display_name: Immich\nslug: immich\n", compose: "services:\n immich:\n image: busybox\n", env: map[string]string{}}, + // (e) the two catalog keys. + "rallly": {meta: "display_name: Rallly\nslug: rallly\napp_info:\n login_kind: email\n", compose: "services:\n rallly:\n image: busybox\n", env: map[string]string{}}, + "bentopdf": {meta: "display_name: BentoPDF\nslug: bentopdf\napp_info:\n login_kind: none\n", compose: "services:\n bentopdf:\n image: busybox\n", env: map[string]string{}}, + // (e) no login of its own, behind the family gate: the family sign-in is its login. + "metube": {meta: "display_name: MeTube\nslug: metube\nfamily_gate: true\napp_info:\n login_kind: none\n", compose: "services:\n metube:\n image: busybox\n", env: map[string]string{}}, + // FileBrowser: what EnsureBaseStack writes — a compose, a config, NO .felhom.yml and NO app.yaml. + "filebrowser": {compose: "services:\n filebrowser:\n image: gtstef/filebrowser:1.5.6-stable\n"}, +} + +// lfHarness builds the box. fbState is FileBrowser's R-513 state ("" / generated / operator); present lists the +// drives that are mounted now. +func lfHarness(t *testing.T, fbState string, present ...string) *Server { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + var ps strings.Builder + for name := range lfApps { + ps.WriteString(name + "\\tbusybox\\trunning\\tUp 2 hours\\t" + name + "\\n") + } + stub := "#!/bin/sh\nif [ \"$1\" = ps ]; then\n printf '" + ps.String() + "'\nfi\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + + lg := log.New(io.Discard, "", 0) + cfg := config.Default() + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Stacks.Protected = []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"} + for name, a := range lfApps { + d := filepath.Join(cfg.Paths.StacksDir, name) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte(a.compose), 0o644); err != nil { + t.Fatal(err) + } + if a.meta != "" { + if err := os.WriteFile(filepath.Join(d, ".felhom.yml"), []byte(a.meta), 0o644); err != nil { + t.Fatal(err) + } + } + if a.env != nil { + if err := stacks.SaveAppConfig(d, &stacks.AppConfig{Deployed: true, Env: a.env}, nil, nil); err != nil { + t.Fatal(err) + } + } + } + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + for _, sp := range []settings.StoragePath{ + {Path: lfDriveA, Label: lfLabelA, IsDefault: true, Schedulable: true}, + {Path: lfDriveB, Label: lfLabelB, Schedulable: true}, + } { + if err := sett.AddStoragePath(sp); err != nil { + t.Fatal(err) + } + } + key := []byte("0123456789abcdef0123456789abcdef") + switch fbState { + case settings.FileBrowserAdminGenerated: + enc, err := crypto.Encrypt(key, lfFBPW) + if err != nil { + t.Fatal(err) + } + if err := sett.SetFileBrowserAdmin(fbState, enc, "2026-10-16T00:00:00Z"); err != nil { + t.Fatal(err) + } + case settings.FileBrowserAdminOperator: + if err := sett.SetFileBrowserAdmin(fbState, "", "2026-10-16T00:00:00Z"); err != nil { + t.Fatal(err) + } + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + mgr.SetMigrationDeps(sett, func() bool { return false }) // production wires the settings the same way + mgr.SetEncryptionKey(key) + _ = mgr.ScanStacks() + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", encKey: key, + presentDrivesFn: func() []string { return present }} + s.loadTemplates() + return s +} + +func lfGet(t *testing.T, s *Server, path string) (int, string, string) { + t.Helper() + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + return w.Code, w.Body.String(), w.Header().Get("Location") +} + +var lfAnswerRe = regexp.MustCompile(`data-login-answer="([a-z_]+)"`) +var lfFilesRe = regexp.MustCompile(`href="([^"]+)"[^>]*data-app-files="1"`) + +// ── 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 +// line), this failed at „FileBrowser offers Install" — the measured live defect on demo-hp. +func TestAppPage_InfraAppHasNoInstall(t *testing.T) { + s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) + code, html, _ := lfGet(t, s, "/apps/filebrowser") + if code != http.StatusOK { + t.Fatalf("GET /apps/filebrowser = %d", code) + } + if strings.Contains(html, "data-install-cta") || strings.Contains(html, "/apps/filebrowser/install") { + t.Fatal("FileBrowser offers Install — an infrastructure app is part of the box") + } + if strings.Contains(html, `data-run-action="stop"`) || strings.Contains(html, `onclick="removeStack(`) { + t.Fatal("FileBrowser offers Stop or Remove") + } + for _, want := range []string{`data-tab="settings"`, `data-tab="guide"`, `data-tab="logs"`, `data-run-action="restart"`, + `href="https://files.example.hu"`, `data-login-answer="initial"`, `id="initcred-reveal"`, "initcred-user"} { + if !strings.Contains(html, want) { + t.Errorf("the FileBrowser page lacks %s", want) + } + } + if strings.Contains(html, `data-tab="data"`) { + t.Error("FileBrowser has a data tab (export / move / backup are not the household's for a part of the box)") + } + if strings.Contains(html, lfFBPW) { + t.Fatal("R-254: FileBrowser's password is in the page") + } + // The install address and the data tab answer with the page; the deploy POST is refused (stacks + api tests). + for _, p := range []string{"/apps/filebrowser/install", "/apps/filebrowser/data"} { + if c, _, loc := lfGet(t, s, p); c != http.StatusFound || loc != "/apps/filebrowser" { + t.Errorf("GET %s = %d → %q, want 302 → /apps/filebrowser", p, c, loc) + } + } + // The guide says what it is. + if _, g, _ := lfGet(t, s, "/apps/filebrowser/guide"); !strings.Contains(g, `data-infra-guide="1"`) || strings.Contains(g, `data-guide-missing`) { + t.Error("the FileBrowser guide tab does not say what FileBrowser is") + } + + // The `operator` state: a sentence and the new-password press, no reveal. + op := lfHarness(t, settings.FileBrowserAdminOperator, lfDriveA) + _, html, _ = lfGet(t, op, "/apps/filebrowser") + if !strings.Contains(html, `data-fb-operator="1"`) || !strings.Contains(html, `data-fb-reset="1"`) { + t.Fatal("operator state: no sentence or no new-password button") + } + if strings.Contains(html, `id="initcred-reveal"`) { + t.Fatal("operator state offers a reveal for a password the box does not know") + } + // And the button is ONLY there in that state. + if _, gen, _ := lfGet(t, s, "/apps/filebrowser"); strings.Contains(gen, `data-fb-reset`) { + t.Fatal("generated state offers the new-password button") + } +} + +// ── Part 2: one login answer per installed app ───────────────────────────────────────────────────────────────────── + +// RED-PROOF (2026-10-16, REPORT): with loginAnswerFor's EffectiveLoginKind fallback returning "" (the v0.319.0 +// behaviour: no panel for an app with no login fields), this failed at immich „0 answers". +func TestAppPage_EveryInstalledAppHasOneLoginAnswer(t *testing.T) { + s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) + for _, c := range []struct{ slug, answer, marker string }{ + {"calibre-web", "fields", `data-login-reveal="ADMIN_PASSWORD"`}, // (a) box-made + {"kimai", "fields", `data-login-not-stored="ADMIN_PASSWORD"`}, // (c) no stored value + {"filebrowser", "initial", `id="initcred-reveal"`}, // (b) read / held for the app + {"immich", "first_visit", `data-login-kind="first_visit"`}, // (d) default + {"rallly", "email", `data-login-kind="email"`}, // (e) + {"bentopdf", "none", `data-login-kind="none"`}, // (e) + {"metube", "none", `data-login-kind="family"`}, // (e) behind the family gate + } { + code, html, _ := lfGet(t, s, "/apps/"+c.slug) + if code != http.StatusOK { + t.Fatalf("%s: GET = %d", c.slug, code) + } + got := lfAnswerRe.FindAllStringSubmatch(html, -1) + if len(got) != 1 { + t.Errorf("%s: %d login answers, want exactly 1", c.slug, len(got)) + continue + } + if got[0][1] != c.answer { + t.Errorf("%s: answer %q, want %q", c.slug, got[0][1], c.answer) + } + if !strings.Contains(html, c.marker) { + t.Errorf("%s: the %s answer lacks %s", c.slug, c.answer, c.marker) + } + if strings.Contains(html, lfSecretPW) || strings.Contains(html, lfFBPW) { + t.Fatalf("%s: a stored secret is in the page (R-254, R-709)", c.slug) + } + } +} + +// ── Part 3: the „Fajlok" button and the media folders ────────────────────────────────────────────────────────────── + +// RED-PROOF (2026-10-16, REPORT): with filesButtonLink's first loop deleted (no library preference), this failed at +// 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" + 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) + 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) + } + 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) + } + } + // 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"`} { + if !strings.Contains(data, want) { + t.Errorf("plex data tab lacks %s", want) + } + } +} + +// RED-PROOF (2026-10-16, REPORT): with `&& s.drivePresent(drive)` removed from the userdata branch, this failed at +// „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") + } + if !strings.Contains(html, `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) { + 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") { + 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") + } + if _, page, _ := lfGet(t, s, "/apps/plex"); !strings.Contains(page, `href="https://files.example.hu/files/HDD%202/media"`) { + t.Error("plex Files button does not go to the first present drive") + } +} + +// The reset endpoint answers only for FileBrowser, and only in the `operator` state (no docker reached). +func TestFileBrowserResetEndpoint_Refusals(t *testing.T) { + s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) + post := func(path string) int { + w := httptest.NewRecorder() + s.appFileBrowserResetHandler(w, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(nil)), + strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reset")) + return w.Code + } + if c := post("/apps/romm/initial-credentials/reset"); c != http.StatusNotFound { + t.Errorf("another app's reset = %d, want 404", c) + } + if c := post("/apps/filebrowser/initial-credentials/reset"); c != http.StatusConflict { + t.Errorf("reset in the generated state = %d, want 409", c) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index f408059..c09153a 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -110,6 +110,8 @@ type Server struct { fbDoneGen uint64 // syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test). syncFileBrowserHook func() + // presentDrivesFn replaces the stack manager's PresentMediaDrives in tests (the app page's folder links, v0.320.0). + presentDrivesFn func() []string // langSwitchSync replaces the language switch's file-browser sync in tests (R-691). langSwitchSync func() @@ -910,6 +912,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.appSignupWindowHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/signup-window")) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/default-login/changed") && r.Method == http.MethodPost: s.appDefaultLoginChangedHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/default-login/changed")) + // v0.320.0: a new FileBrowser admin password when the box does not know it. POST, so CsrfProtect covers it. + case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reset") && r.Method == http.MethodPost: + s.appFileBrowserResetHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reset")) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost: slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal") s.appInitialCredsRevealHandler(w, r, slug) diff --git a/controller/internal/web/templates/app_data.html b/controller/internal/web/templates/app_data.html index 549207d..4810803 100644 --- a/controller/internal/web/templates/app_data.html +++ b/controller/internal/web/templates/app_data.html @@ -25,13 +25,16 @@ {{range .DataPathCards}}
- {{.Label}} ↗ + {{- /* v0.320.0: a folder whose FileBrowser source is not on the box now is text, never a link. */}} + {{if .Link}}{{.Label}} ↗{{else}}{{.Label}} {{T "datapath.unavailable"}}{{end}} {{if .FreeSpace}}{{.FreeSpace}}{{end}}
{{if .Consequence}}

{{.Consequence}}

{{end}}
{{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"}}

{{end}} diff --git a/controller/internal/web/templates/app_guide.html b/controller/internal/web/templates/app_guide.html index 04ba146..3e80ca1 100644 --- a/controller/internal/web/templates/app_guide.html +++ b/controller/internal/web/templates/app_guide.html @@ -105,6 +105,12 @@ function appShotGone(img) { {{- end}} +{{else if .InfraInfo}} +{{/* v0.320.0: part of the box — what it is, in two lines, instead of „a guide is still to come". */}} +
+

{{.InfraInfo.Description}}

+

{{T "app_page.infra_guide"}}

+
{{else}}

{{T "app_page.guide_missing"}}

{{end}} diff --git a/controller/internal/web/templates/app_settings.html b/controller/internal/web/templates/app_settings.html index 99eaa81..9a3f789 100644 --- a/controller/internal/web/templates/app_settings.html +++ b/controller/internal/web/templates/app_settings.html @@ -51,7 +51,11 @@

{{T "app_page.mukodes"}}

{{stateLabel .Stack.State}}

- {{if isOperational .Stack.State}} + {{if .Infra}} + {{/* v0.320.0: part of the box — Restart only, as the app list offers it; the box starts it + again by itself, and Stop is refused server-side for every protected stack. */}} + {{if isOperational .Stack.State}}{{end}} + {{else if isOperational .Stack.State}} {{else}} @@ -123,10 +127,15 @@
{{/* v0.319.0 — „BELEPES AZ ALKALMAZASBA": the name and the password of the app's first login, side by side. The values are fetched on demand (R-254, R-709); the page learns only whether - the box holds one (app_login.go), and a field it holds none for is a sentence, not an eye. */}} - {{if .LoginFields}} -
+ the box holds one (app_login.go), and a field it holds none for is a sentence, not an eye. + + v0.320.0 — EVERY installed app has this panel, with exactly ONE answer (app_login.go + loginAnswerFor): the box's fields · the login the app wrote (InitialCreds, FileBrowser's too) · + or a sentence for an app the box holds no login for (first visit / e-mail / none). */}} + {{if .LoginAnswer}} +

{{T "app_page.login_title"}}

+ {{- if eq .LoginAnswer "fields"}}

{{T "app_page.login_desc"}}

{{- end}}
-
- {{end}} - - {{if .InitialCreds}} -
-

{{T "app_info.kezdeti_belepesi_adatok"}}

+ {{- else if eq .LoginAnswer "initial"}} {{if .InitialCreds.Username}} @@ -163,7 +167,7 @@ {{if .InitialCredsOperatorSet}} - + {{else}} @@ -182,6 +186,25 @@
{{T "common.jelszo"}}{{T "app_info.az_uzemelteto_allitotta_be"}}{{T "app_page.fb_operator_set"}}
{{if .InitialCreds.Note}}

{{.InitialCreds.Note}}

{{end}} {{if not .InitialCredsOperatorSet}}

{{T "app_info.az_elso_bejelentkezes_utan_azonnal"}}

{{end}} + {{- if .InitialCredsOperatorSet}} + {{/* v0.320.0: the box does not know this password, so it offers to set a new one (stacks + ResetFileBrowserAdminPassword — the file browser's own CLI, spiked on demo-hp). POST + CSRF, + confirmed first: the old password stops working. */}} +
+ + +
+ {{- end}} + {{- else if eq .LoginAnswer "email"}} +

{{T "app_page.login_kind_email"}}

+ {{- else if and (eq .LoginAnswer "none") .Meta.FamilyGate}} + {{/* No login of its own, behind the family gate (decision 64): the family sign-in IS its login. */}} +

{{T "app_page.login_kind_family"}}

+ {{- else if eq .LoginAnswer "none"}} +

{{T "app_page.login_kind_none"}}

+ {{- else}} +

{{T "app_page.login_kind_first_visit"}}

+ {{- end}}
{{end}} @@ -575,6 +598,7 @@ cannot be changed sits under the title it is about, not at the bottom of the tab. The domain, the subdomain and the data folder are the „Cim" and „Tarhely" rows; the login is the panel above; everything else is a row here (app_login.go appSettingRows). */}} +{{- if not .Infra}}

{{T "app_page.address_and_keys"}}

{{T "app_page.installed_readonly"}}

@@ -604,6 +628,7 @@
{{- end}}
+{{- end}}
{{if .InitialCreds}} diff --git a/controller/internal/web/templates/app_shell.html b/controller/internal/web/templates/app_shell.html index e14bf36..beba358 100644 --- a/controller/internal/web/templates/app_shell.html +++ b/controller/internal/web/templates/app_shell.html @@ -19,7 +19,7 @@ onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">

{{.Meta.DisplayName}}

- {{if .AppInfo.Tagline}}

{{.AppInfo.Tagline}}

{{else if .Meta.Description}}

{{.Meta.Description}}

{{end}} + {{if .InfraInfo}}

{{.InfraInfo.Description}}

{{else if .AppInfo.Tagline}}

{{.AppInfo.Tagline}}

{{else if .Meta.Description}}

{{.Meta.Description}}

{{end}}
{{if .Deployed}} ● {{stateLabel .Stack.State}} @@ -40,9 +40,12 @@ {{end}}
- {{if and .Deployed .AppOperational .EffectiveSubdomain}} + {{if and .Deployed (or (and .AppOperational .EffectiveSubdomain) .FilesLink)}}
- {{T "app_info.megnyitas"}} + {{if and .AppOperational .EffectiveSubdomain}}{{T "app_info.megnyitas"}}{{end}} + {{/* v0.320.0: one of the app's folders in FileBrowser (datapath_card.go filesButtonLink). FileBrowser may + ask for its own login first; the button never promises one click. */}} + {{if .FilesLink}}{{T "app_page.files_btn"}}{{end}}
{{end}} {{if and .Deployed .Stack.Orphaned}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 0da2fbc..ab547e9 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1360,7 +1360,7 @@ a.stat-card:hover { .app-hero-text { flex: 1 1 320px; min-width: 0; } .app-hero-name { font-size: 26px; font-weight: 700; color: var(--text-1); margin: 0; line-height: 1.2; } .app-hero-tagline { font-size: 15px; color: var(--text-2); margin: 4px 0 10px; } -.app-hero-actions { margin-left: auto; } +.app-hero-actions { margin-left: auto; display: flex; gap: .5rem; flex-wrap: wrap; } .app-chips { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .app-chip { diff --git a/controller/internal/web/testdata/i18n_parity/app_data_deployed.html b/controller/internal/web/testdata/i18n_parity/app_data_deployed.html index 50c77e6..2d6e289 100644 --- a/controller/internal/web/testdata/i18n_parity/app_data_deployed.html +++ b/controller/internal/web/testdata/i18n_parity/app_data_deployed.html @@ -237,6 +237,8 @@ + +

A Fájlkezelő a saját jelszavát kéri az első alkalommal. A jelszót a Fájlkezelő oldalán látod. A Fájlkezelő oldala

diff --git a/controller/internal/web/testdata/i18n_parity/app_data_files_media.html b/controller/internal/web/testdata/i18n_parity/app_data_files_media.html new file mode 100644 index 0000000..38b98ba --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_data_files_media.html @@ -0,0 +1,661 @@ + + + + + + + + Plex — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

Plex

+

Egy teszt alkalmazás leírása

+
+ + ● Fut + + + + + + + +
+
+ +
+ + + Fájlok ↗ +
+ + +
+ + + + + + + + + + + + +
+ + + + +
+

Hova tegyem a fájlokat?

+

Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe.

+
+ +
+ + +
+ +
+
+ Filmek — HDD 2 (a tárhely most nem érhető el) + +
+ +
+ +
+ +

A Fájlkezelő a saját jelszavát kéri az első alkalommal. A jelszót a Fájlkezelő oldalán látod. A Fájlkezelő oldala

+
+ + + + + +
+

Biztonsági mentés

+ +
+ + Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe. + Mentési állapot → · Mentési beállítások → + +
+
+ + + + +
+ + + + +
+ +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_guide_infra_filebrowser.html b/controller/internal/web/testdata/i18n_parity/app_guide_infra_filebrowser.html new file mode 100644 index 0000000..7b142a4 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_guide_infra_filebrowser.html @@ -0,0 +1,651 @@ + + + + + + + + FileBrowser — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

FileBrowser

+

Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.

+
+ + ● Fut + + + + + files.example.hu + + +
+
+ +
+ Megnyitás ↗ + + +
+ + +
+ + + + + + + + + + + + + + +
+
+ + + +
+
+ + + +
+ + + + + +
+ + + + +
+

Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.

+

A doboz része: a Felhom telepíti, frissíti és felügyeli. Nem kell telepíteni, és nem lehet eltávolítani.

+
+ + +
+
+ + + + +
+ +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_after_start.html b/controller/internal/web/testdata/i18n_parity/app_settings_after_start.html index 7a568c6..92ed989 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_after_start.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_after_start.html @@ -261,7 +261,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_close_signup.html b/controller/internal/web/testdata/i18n_parity/app_settings_close_signup.html index 8362b98..7b56e51 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_close_signup.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_close_signup.html @@ -248,7 +248,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_close_signup_native.html b/controller/internal/web/testdata/i18n_parity/app_settings_close_signup_native.html index 6981ba3..2f9d8c9 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_close_signup_native.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_close_signup_native.html @@ -249,7 +249,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_deployed.html b/controller/internal/web/testdata/i18n_parity/app_settings_deployed.html index 7d54eef..82a36e7 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_deployed.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_deployed.html @@ -197,6 +197,8 @@ @@ -253,10 +255,8 @@
- - -
-

Kezdeti belépési adatok

+
+

Belépés az alkalmazásba

diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_hold_log.html b/controller/internal/web/testdata/i18n_parity/app_settings_hold_log.html index 5281143..65e9d5b 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_hold_log.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_hold_log.html @@ -243,7 +243,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_infra_filebrowser.html b/controller/internal/web/testdata/i18n_parity/app_settings_infra_filebrowser.html new file mode 100644 index 0000000..0d60b33 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_settings_infra_filebrowser.html @@ -0,0 +1,806 @@ + + + + + + + + FileBrowser — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

FileBrowser

+

Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.

+
+ + ● Fut + + + + + files.example.hu + + +
+
+ +
+ Megnyitás ↗ + + +
+ + +
+ + + + + + + + + + + + +
+ + + + + + +
+

Működés

+

Fut

+
+ + + + +
+
+ + + + +
+ + +
+

Belépés az alkalmazásba

+
+ + + + + + + + + + + + + +
Felhasználónévadmin
Jelszó + •••••••••••• + + + +
+

A Fájlkezelő belépése.

+

Az első bejelentkezés után azonnal változtasd meg! Ez a kezdeti, automatikusan generált jelszó — ha már megváltoztattad, hagyd figyelmen kívül.

+
+ + +
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_install_hold.html b/controller/internal/web/testdata/i18n_parity/app_settings_install_hold.html index 6795d99..801976a 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_install_hold.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_install_hold.html @@ -246,7 +246,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_known_login_changed.html b/controller/internal/web/testdata/i18n_parity/app_settings_known_login_changed.html index db04cdf..a441976 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_known_login_changed.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_known_login_changed.html @@ -242,7 +242,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_ladder_steps.html b/controller/internal/web/testdata/i18n_parity/app_settings_ladder_steps.html index 48044d3..42b1ceb 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_ladder_steps.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_ladder_steps.html @@ -242,7 +242,10 @@
- +
+

Belépés az alkalmazásba

+

Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.

+
diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_login_email.html b/controller/internal/web/testdata/i18n_parity/app_settings_login_email.html new file mode 100644 index 0000000..9d09433 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_settings_login_email.html @@ -0,0 +1,736 @@ + + + + + + + + Rallly — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

Rallly

+

Egy teszt alkalmazás leírása

+
+ + ● Fut + + + + + + + +
+
+ + +
+ + + + + + + + + + + + +
+ + + + + + +
+

Működés

+

Fut

+
+ + + + +
+
+ + + + +
+ + +
+

Belépés az alkalmazásba

+

Ez az alkalmazás e-mailben küld belépési linket. Ehhez az alkalmazás e-mail küldése kell (Beállítások fül).

+
+ + +
+ + + + + + + + + + + + +
+

Cím és kulcsok

+

Ezek telepítéskor jöttek létre, és nem módosíthatók.

+ + +
+
+ + + + + + + + +
+ +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_login_family.html b/controller/internal/web/testdata/i18n_parity/app_settings_login_family.html new file mode 100644 index 0000000..4470aa1 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_settings_login_family.html @@ -0,0 +1,737 @@ + + + + + + + + MeTube — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

MeTube

+

Egy teszt alkalmazás leírása

+
+ + ● Fut + + + + + + + +
+
+ + +
+ + + + + + + + + + + + +
+ + + + + + +
+

Működés

+

Fut

+
+ + + + +
+
+ + + + +
+ + +
+

Belépés az alkalmazásba

+ +

Ehhez az alkalmazáshoz a családi neveddel és jelszavaddal lépsz be. A családtagokat a Beállítások, Biztonság oldal Család kártyáján adod hozzá.

+
+ + +
+ + + + + + + + + + + + +
+

Cím és kulcsok

+

Ezek telepítéskor jöttek létre, és nem módosíthatók.

+ + +
+
+ + + + + + + + +
+ +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_login_none.html b/controller/internal/web/testdata/i18n_parity/app_settings_login_none.html new file mode 100644 index 0000000..c7dce81 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_settings_login_none.html @@ -0,0 +1,736 @@ + + + + + + + + BentoPDF — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + +
+ +← Alkalmazások + +
+ +
+

BentoPDF

+

Egy teszt alkalmazás leírása

+
+ + ● Fut + + + + + + + +
+
+ + +
+ + + + + + + + + + + + +
+ + + + + + +
+

Működés

+

Fut

+
+ + + + +
+
+ + + + +
+ + +
+

Belépés az alkalmazásba

+

Ehhez az alkalmazáshoz nem kell belépés.

+
+ + +
+ + + + + + + + + + + + +
+

Cím és kulcsok

+

Ezek telepítéskor jöttek létre, és nem módosíthatók.

+ + +
+
+ + + + + + + + +
+ +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_settings_login_not_stored.html b/controller/internal/web/testdata/i18n_parity/app_settings_login_not_stored.html index f507fea..009bd45 100644 --- a/controller/internal/web/testdata/i18n_parity/app_settings_login_not_stored.html +++ b/controller/internal/web/testdata/i18n_parity/app_settings_login_not_stored.html @@ -197,6 +197,8 @@ @@ -246,7 +248,7 @@
-
+

Belépés az alkalmazásba

Az első bejelentkezéshez. Ha az alkalmazásban megváltoztatod a jelszót, itt a régi marad.