diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec1e57..565ee21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## v0.321.0 — FileBrowser's stale password and its own banners, one backup section on the Data tab, the install address of an installed app (2026-10-16) + +**MinAgent: 0.158.1** — the floor's, carried from v0.320.0. Nothing here calls the agent. No golden (`09` §3 decision +250). Task: the apps burndown (felhom.eu `REPORT-burndown-apps-2026-10-16.md`). + +- **R-1101 — FileBrowser's stored password goes stale.** The page told the household to change the password after the + first login, and then showed the old one. The line is gone for FileBrowser. In the `generated` state the box now asks + FileBrowser first (`stacks.FileBrowserStoredPasswordRefused`, once per page load, 3 s bound, the value never logged): + only FileBrowser's own 401/403 counts as refused — a FileBrowser that does not answer is not. Refused → „A + Fájlkezelőben megváltoztattad a jelszót, ezért a doboz már nem ismeri." and „Új jelszó beállítása", which the reset + now accepts in that case too. +- **FileBrowser's own banners:** the rendered config sets `server.disableUpdateCheck: true` (no „An update is + available!" — the box updates FileBrowser) and `frontend.disablePWAInstall: true` (no „Install app"). Both read at + `gtstef/filebrowser:1.5.6-stable` (`backend/common/settings/structs.go:51`, `:294`). The config changes once, so + each box recreates FileBrowser once after the update. +- **The Data tab's backup part is ONE section** „Biztonsági mentés": an „Éjszakai mentés" row with „Mentési állapot →", + the 2nd copy's state as rows (plain text, not monospace, not a two-column grid), what it means, its form. Plain words: + no „off-drive", no „PBS", no „DB/konfiguráció". The „Mentési beállítások →" link (it pointed at the same tab) is + gone. The export password toggle is the text „Megjelenítés"/„Elrejtés" with an `aria-label`, like every other reveal + button (it was an eye emoji). +- **R-1095 — `/apps/{slug}/install` of an INSTALLED app** answers 302 to its settings tab (GET only). The installed-only + branches of `app_fields.html` stay: the R-254/R-709 guard tests pin them (filed, R-1103). +- Changed keys (hu + en): `tier2_config.a_2_mentes_egy_masik`, `.csak_db_konfiguracio`, `.jelenleg_csak_a_belso_ssd`, + `.nincs_elerheto_off_drive_cel`, `.nincs_masik_adatmeghajto_automatikus_cel`, `.rendszerlemez.vedett`, + `note.tier2.no_space_ssd`, `note.tier2.reason_no_second` (the last two moved to the go-parity map's reworded list). + New: `app_page.backup_nightly`, `.backup_nightly_text`, `.backup_status_link`, `.fb_password_changed`. +- Tests red-proved: `TestFileBrowserStoredPassword_RefusedOnlyOnA401`, `TestFileBrowserLogin_StoredPasswordRefusedOffersReset`, + `TestAppData_OneBackupSection`, `TestInstallAddress_InstalledRedirectsToSettings`. Parity fixtures: 13 re-captured + (the Data tab's ten, `app_settings_deployed`, `app_settings_operator_creds`, `app_settings_infra_filebrowser`); 1 new + (`app_settings_infra_filebrowser_stale`). Every other fixture, the install page's included, byte-identical. + ## 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 diff --git a/REUSE.md b/REUSE.md index f8bb198..dcb1a7d 100644 --- a/REUSE.md +++ b/REUSE.md @@ -208,6 +208,7 @@ | `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.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. | | `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. | ### Storage registry + mount detection diff --git a/controller/README.md b/controller/README.md index 5f1f8ea..95a5ce4 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1089,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 — `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). +**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). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). **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/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 6b7cea0..027dd59 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -116,6 +116,9 @@ "app_import.titkos": "Encrypted", "app_import.visszaallitas_inditasa": "Start restore", "app_page.second_backup": "Second backup", + "app_page.backup_nightly": "Nightly backup", + "app_page.backup_nightly_text": "The app's database and internal storage go into the backup every night.", + "app_page.backup_status_link": "Backup status →", "app_page.address_and_keys": "Address and keys", "app_page.address": "Address", "app_page.installed_readonly": "These were created at install time and cannot be changed.", @@ -128,6 +131,7 @@ "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_password_changed": "You changed the password in the file manager, so the box no longer knows 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 ↗", @@ -1856,9 +1860,9 @@ "note.restore.volumes_count": "%d data volumes", "note.tier2.label_internal_ssd": "internal SSD (system)", "note.tier2.no_other_drive": "there is no second physical drive — the second backup needs one", - "note.tier2.no_space_ssd": "not enough space on the internal SSD — backing large files off the drive needs a second drive (or remote storage)", + "note.tier2.no_space_ssd": "not enough space on the internal SSD — to copy large files to another drive, a 2nd drive (or remote storage) is needed", "note.tier2.reason_manual": "your own choice", - "note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive", + "note.tier2.reason_no_second": "no 2nd data drive — only the database and the settings fit on the internal SSD; large files need a 2nd drive", "note.tier2.reason_other_drive": "another data drive", "note.tier2.same_disk": "the target you picked is on the same physical disk", "note.backup.single_copy_unproven": "Felhom cannot check that the second drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so it does not count it as a second copy.", @@ -2514,27 +2518,27 @@ "tier2_config.2_mentes": "Backup 2", "tier2_config.2_mentes_beallitasa": "Backup 2 settings — {{.DisplayName}}", "tier2_config.2_mentes_bekapcsolva": "Backup 2 turned on", - "tier2_config.a_2_mentes_egy_masik": "Backup 2 copies the app’s recovery package and data to another physical drive.\n This is the only off-drive protection for your browsable\n files (the full system backup/PBS does not reach them).", + "tier2_config.a_2_mentes_egy_masik": "Backup 2 copies the app’s recovery package and data to another drive. Only this protects your browsable files from a drive failure — the full system backup does not include them.", "tier2_config.automatikus": "Automatic", "tier2_config.automatikus_kisbetu": "automatic", "tier2_config.bekapcsolva": "Turned on", "tier2_config.cel": "Target", "tier2_config.cel_meghajto": "Target drive", - "tier2_config.csak_db_konfiguracio": "— database/configuration only", + "tier2_config.csak_db_konfiguracio": "— the database and the settings only", "tier2_config.jelenleg": "(now: {{.EffectiveLabel}})", - "tier2_config.jelenleg_csak_a_belso_ssd": "Only the internal SSD is available as a 2nd target right now, so only the database and the configuration\n are copied. The internal system disk is small, so an off-drive backup of large files needs a\n 2nd data drive (so the system disk does not fill up).", + "tier2_config.jelenleg_csak_a_belso_ssd": "Only the internal SSD is available as a second place right now, so only the database and the settings are copied. The internal system disk is small: to copy large files to another drive too, a 2nd data drive is needed (so the system disk does not fill up).", "tier2_config.jelenlegi_allapot": "Current state", "tier2_config.kezi_valasztas": "by hand", "tier2_config.kikapcsolva": "Turned off", "tier2_config.kivalasztas_modja": "Chosen", "tier2_config.megjegyzes": "Note", "tier2_config.mentes": "Save", - "tier2_config.nincs_elerheto_off_drive_cel": "No off-drive target available", - "tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "No other data drive — the automatic target is the internal SSD (database/configuration only). Add a 2nd\n data drive to back up all data off-drive too.", + "tier2_config.nincs_elerheto_off_drive_cel": "No other drive to copy to", + "tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "No other data drive — the copy goes to the internal SSD automatically (the database and the settings only). With a 2nd data drive, all the data can be copied to another drive.", "tier2_config.rendszerlemez.hianyzik": "This app’s data is on the internal system disk. The full system backup’s drive\n is not reachable right now — until you reconnect it, no fresh system backup is made of this app either.", "tier2_config.rendszerlemez.ismeretlen": "This app’s data is on the internal system disk and is part of the full system backup.\n Where that backup is being made right now could not be checked — the Backups page shows it.", "tier2_config.rendszerlemez.ugyanaz": "This app’s data is on the internal system disk. The full system backup\n is currently made to that same disk — so it protects against damaged files, not against a failed disk.\n Connect a second drive and choose it as the system backup location on the Backups page, and this app is protected too.", - "tier2_config.rendszerlemez.vedett": "This app’s data is on the internal system disk, which is\n part of the full system backup, and that backup is made to a separate drive. The 2nd (off-drive) copy\n is extra, and is made mainly for apps stored on the external data drive — nothing to do for this\n app.", + "tier2_config.rendszerlemez.vedett": "This app’s data is on the internal system disk, which is part of the full system backup, and that backup is made to a separate drive. Backup 2 (to another drive) is extra, and is made mainly for apps stored on the external data drive — nothing to do for this app.", "tier2_config.vissza_a_mentesekhez": "← Back to backups", "update.error.backup_failed": "The update did not start, because the backup before the update did not succeed: %v. The app keeps running unchanged.", "update.error.backup_no_unit": "The update did not start: the backup before the update ran, but no fresh copy that can be restored was made. The app keeps running unchanged.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 9907a5d..155d939 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -112,6 +112,9 @@ "app_import.titkos": "Titkos", "app_import.visszaallitas_inditasa": "Visszaállítás indítása", "app_page.second_backup": "2. mentés", + "app_page.backup_nightly": "Éjszakai mentés", + "app_page.backup_nightly_text": "Az alkalmazás adatbázisa és belső tárolói minden éjjel bekerülnek a mentésbe.", + "app_page.backup_status_link": "Mentési állapot →", "app_page.address_and_keys": "Cím és kulcsok", "app_page.address": "Cím", "app_page.installed_readonly": "Ezek telepítéskor jöttek létre, és nem módosíthatók.", @@ -124,6 +127,7 @@ "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_password_changed": "A Fájlkezelőben megváltoztattad a jelszót, ezért a doboz már 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 ↗", @@ -1844,9 +1848,9 @@ "note.restore.volumes_count": "%d adatkötet", "note.tier2.label_internal_ssd": "belső SSD (rendszer)", "note.tier2.no_other_drive": "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges", - "note.tier2.no_space_ssd": "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges", + "note.tier2.no_space_ssd": "nincs elég hely a belső SSD-n — hogy a nagy fájlok másik meghajtóra kerüljenek, 2. meghajtó (vagy távoli tárhely) kell", "note.tier2.reason_manual": "kézi választás", - "note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", + "note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis és a beállítások férnek a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", "note.tier2.reason_other_drive": "másik adatmeghajtó", "note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van", "note.backup.single_copy_unproven": "A Felhom nem tudja ellenőrizni, hogy a második meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem számolja második másolatnak.", @@ -2502,27 +2506,27 @@ "tier2_config.2_mentes": "2. mentés", "tier2_config.2_mentes_beallitasa": "2. mentés beállítása — {{.DisplayName}}", "tier2_config.2_mentes_bekapcsolva": "2. mentés bekapcsolva", - "tier2_config.a_2_mentes_egy_masik": "A 2. mentés egy másik fizikai meghajtóra készít másolatot az alkalmazás\n helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető\n felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).", + "tier2_config.a_2_mentes_egy_masik": "A 2. mentés egy másik meghajtóra másolja az alkalmazás helyreállítási csomagját és adatait. A böngészhető fájljaidat csak ez védi meg egy meghajtóhiba ellen — a teljes rendszermentés ezeket nem tartalmazza.", "tier2_config.automatikus": "Automatikus", "tier2_config.automatikus_kisbetu": "automatikus", "tier2_config.bekapcsolva": "Bekapcsolva", "tier2_config.cel": "Cél", "tier2_config.cel_meghajto": "Cél meghajtó", - "tier2_config.csak_db_konfiguracio": "— csak DB/konfiguráció", + "tier2_config.csak_db_konfiguracio": "— csak az adatbázis és a beállítások", "tier2_config.jelenleg": "(jelenleg: {{.EffectiveLabel}})", - "tier2_config.jelenleg_csak_a_belso_ssd": "Jelenleg csak a belső SSD érhető el 2. célként, ezért csak az adatbázis és a konfiguráció\n másolódik. A belső rendszerlemez kicsi, ezért a nagy fájlok off-drive mentéséhez egy\n 2. adatmeghajtó szükséges (hogy a rendszerlemez ne teljen meg).", + "tier2_config.jelenleg_csak_a_belso_ssd": "Most csak a belső SSD érhető el második helyként, ezért csak az adatbázis és a beállítások másolódnak. A belső rendszerlemez kicsi: hogy a nagy fájlok is másik meghajtóra kerüljenek, egy 2. adatmeghajtó kell (így a rendszerlemez nem telik meg).", "tier2_config.jelenlegi_allapot": "Jelenlegi állapot", "tier2_config.kezi_valasztas": "kézi választás", "tier2_config.kikapcsolva": "Kikapcsolva", "tier2_config.kivalasztas_modja": "Kiválasztás módja", "tier2_config.megjegyzes": "Megjegyzés", "tier2_config.mentes": "Mentés", - "tier2_config.nincs_elerheto_off_drive_cel": "Nincs elérhető off-drive cél", - "tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.\n adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.", + "tier2_config.nincs_elerheto_off_drive_cel": "Nincs másik meghajtó, ahová másolhatna", + "tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "Nincs másik adatmeghajtó — automatikusan a belső SSD-re kerül (csak az adatbázis és a beállítások). Egy 2. adatmeghajtóval a teljes adat is másik meghajtóra menthető.", "tier2_config.rendszerlemez.hianyzik": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés meghajtója\n most nem érhető el — amíg vissza nem csatlakoztatod, erről az alkalmazásról sem készül friss rendszermentés.", "tier2_config.rendszerlemez.ismeretlen": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, és a teljes rendszermentés része.\n Hogy a rendszermentés most hová készül, azt nem tudtuk lekérdezni — a Mentések oldalon láthatod.", "tier2_config.rendszerlemez.ugyanaz": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés\n jelenleg ugyanerre a lemezre készül — így hibás fájlok ellen véd, lemezhiba ellen nem.\n Ha csatlakoztatsz egy második meghajtót, és a Mentések oldalon kijelölöd a rendszermentés helyéül, ez az alkalmazás is védett lesz.", - "tier2_config.rendszerlemez.vedett": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek\n szerepelnek a teljes rendszermentésben, és az egy külön meghajtóra készül. A 2. (off-drive) másolat\n kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az\n alkalmazáshoz nincs külön teendő.", + "tier2_config.rendszerlemez.vedett": "Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek szerepelnek a teljes rendszermentésben, és az egy külön meghajtóra készül. A 2. mentés (egy másik meghajtóra) kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az alkalmazáshoz nincs külön teendő.", "tier2_config.vissza_a_mentesekhez": "← Vissza a mentésekhez", "update.error.backup_failed": "A frissítés nem indult el, mert a frissítés előtti biztonsági mentés nem sikerült: %v. Az alkalmazás változatlanul fut tovább.", "update.error.backup_no_unit": "A frissítés nem indult el: a frissítés előtti mentés lefutott, de nem jött létre friss, visszaállítható másolat. Az alkalmazás változatlanul fut tovább.", diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index bcdc30f..8d5d3b6 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -430,6 +430,9 @@ func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool server: port: 80 baseURL: "/" + # The box updates FileBrowser (the pinned image); a household cannot, so its „An update is available!" is noise. + # backend/common/settings/structs.go:51 at gtstef/filebrowser:1.5.6-stable (v0.321.0). + disableUpdateCheck: true database: "/home/filebrowser/data/database.db" logging: - levels: "info|warning|error" @@ -461,5 +464,9 @@ server: delete: false create: false download: true +frontend: + # No „Install app" (PWA) prompt in the sidebar: the file manager is opened from the dashboard (v0.321.0; + # structs.go:294, sent to the page at backend/http/static.go:300). + disablePWAInstall: true `, sources) } diff --git a/controller/internal/stacks/filebrowser_password.go b/controller/internal/stacks/filebrowser_password.go index efc76d2..e8ceb2e 100644 --- a/controller/internal/stacks/filebrowser_password.go +++ b/controller/internal/stacks/filebrowser_password.go @@ -186,7 +186,8 @@ func fbSelfID(do fbHTTPDo, base, token string) (int, error) { // The password is 16 letters and digits (generateValue "password:16") — never a comma, which the CLI's `user,pw` // form would split on. It is on the one-shot container's command line for about a second, inside the box. -// ErrFileBrowserNotOperator: the reset is offered only while the box does not know the password. +// ErrFileBrowserNotOperator: the reset is offered only while the box does not know the password (`operator`, or +// `generated` with the stored value refused — R-1101). var ErrFileBrowserNotOperator = fmt.Errorf("the file manager's password is not in the operator state") // fileBrowserContainer is the infra container's fixed name (infra.RenderFileBrowserCompose). @@ -203,7 +204,9 @@ func (m *Manager) ResetFileBrowserAdminPassword(ctx context.Context) error { // The base-stack tick must not start the file browser while it is stopped for the CLI. m.infraMu.Lock() defer m.infraMu.Unlock() - if state, _, _ := m.settings.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator { + // v0.321.0 (R-1101): also when the stored password is refused — the household changed it in FileBrowser. + if state, _, _ := m.settings.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator && + !(state == settings.FileBrowserAdminGenerated && m.FileBrowserStoredPasswordRefused()) { return ErrFileBrowserNotOperator } if len(m.encKey) == 0 { @@ -303,3 +306,44 @@ func fileBrowserCLISetPassword(ctx context.Context, pw string) error { } return nil } + +// ── v0.321.0 (R-1101): the stored password, checked against FileBrowser ───────────────────────────────────────────── +// +// In the `generated` state the box shows the password it set. If the household changed it in FileBrowser, that value +// is refused, and the page used to keep offering it. The page now asks FileBrowser first (once per page load, the same +// login R-513 uses, the value never logged) and, on a refusal, says so and offers the new-password press. + +// fbCheckTimeout bounds the page's check, so a FileBrowser that does not answer never holds the page. +const fbCheckTimeout = 3 * time.Second + +// FileBrowserStoredPasswordRefused reports true ONLY when FileBrowser positively refuses the stored password (401/403) +// in the `generated` state. Anything else — another state, no stored value, a decrypt error, FileBrowser not +// answering — is false: an unproven refusal must not tell the household its password changed. Pinned by +// TestFileBrowserStoredPassword_RefusedOnlyOnA401. +func (m *Manager) FileBrowserStoredPasswordRefused() bool { + if m.settings == nil || len(m.encKey) == 0 { + return false + } + state, enc, _ := m.settings.GetFileBrowserAdmin() + if state != settings.FileBrowserAdminGenerated || enc == "" { + return false + } + pw, err := crypto.Decrypt(m.encKey, enc) + if err != nil || strings.TrimSpace(pw) == "" { + return false + } + do := m.fbHTTP + if do == nil { + do = (&http.Client{Timeout: fbCheckTimeout}).Do + } + base := fileBrowserBaseURL + if m.fbBaseURL != "" { + base = m.fbBaseURL + } + _, code, err := fbLogin(do, base, pw) + refused := err == nil && (code == http.StatusUnauthorized || code == http.StatusForbidden) + if refused { + m.logger.Printf("[WARN] [infra] filebrowser: the stored admin password is refused (HTTP %d) — changed in FileBrowser; the page offers a new one (value never logged)", code) + } + return refused +} diff --git a/controller/internal/stacks/filebrowser_reset_test.go b/controller/internal/stacks/filebrowser_reset_test.go index fa95382..35475c2 100644 --- a/controller/internal/stacks/filebrowser_reset_test.go +++ b/controller/internal/stacks/filebrowser_reset_test.go @@ -141,3 +141,44 @@ func TestEffectiveLoginKind_DefaultAndUnknown(t *testing.T) { } } } + +// R-1101 (v0.321.0): the stored password is called refused ONLY on FileBrowser's own 401. +// RED-PROOF (2026-10-16, REPORT): with `refused := err == nil && (...)` replaced by `refused := code != http.StatusOK`, +// this failed at „FileBrowser down reads as refused". +func TestFileBrowserStoredPassword_RefusedOnlyOnA401(t *testing.T) { + fake := &fakeFB{password: "TESTONLY-stored-1"} + srv := httptest.NewServer(fake.handler()) + m, st, key := fbManager(t, srv.URL) + enc, err := crypto.Encrypt(key, "TESTONLY-stored-1") + if err != nil { + t.Fatal(err) + } + if err := st.SetFileBrowserAdmin(settings.FileBrowserAdminGenerated, enc, "2026-10-16T00:00:00Z"); err != nil { + t.Fatal(err) + } + if m.FileBrowserStoredPasswordRefused() { + t.Fatal("the stored password still logs in, and it reads as refused") + } + fake.mu.Lock() + fake.password = "TESTONLY-changed-in-filebrowser" + fake.mu.Unlock() + if !m.FileBrowserStoredPasswordRefused() { + t.Fatal("FileBrowser refuses the stored password (401), and it does not read as refused") + } + // The reset is then offered in the generated state, and replaces it. + calls := 0 + m.fbSetPassword = func(_ context.Context, pw string) error { + calls++ + fake.mu.Lock() + fake.password = pw + fake.mu.Unlock() + return nil + } + if err := m.ResetFileBrowserAdminPassword(context.Background()); err != nil || calls != 1 { + t.Fatalf("reset with a refused stored password: err %v, apply ran %d times", err, calls) + } + srv.Close() + if m.FileBrowserStoredPasswordRefused() { + t.Fatal("FileBrowser down reads as refused — an unproven refusal must not tell the household its password changed") + } +} diff --git a/controller/internal/web/app_page.go b/controller/internal/web/app_page.go index 0f4d40a..1907018 100644 --- a/controller/internal/web/app_page.go +++ b/controller/internal/web/app_page.go @@ -245,6 +245,12 @@ func (s *Server) appInstallPageHandler(w http.ResponseWriter, r *http.Request, s http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound) return } + // v0.321.0 (R-1095): an INSTALLED app's install address — typed, bookmarked, or an old link — goes to its + // settings tab, the redirectStackDeployGET shape. It used to render the install form read-only, with no button. + if st := s.stackBySlug(slug); st != nil && st.Deployed { + http.Redirect(w, r, s.cfg.AppPageURL(slug)+"/"+appTabSettings, http.StatusFound) + return + } s.deployHandler(w, r, name) } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ac5a342..0b4336b 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -882,6 +882,12 @@ func (s *Server) appCommonData(w http.ResponseWriter, r *http.Request, slug stri data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Note: s.msg(r, "creds.filebrowser_note")} data["InitialCredsHasPassword"] = enc != "" + // v0.321.0 (R-1101): the stored value is checked against FileBrowser before it is offered. Refused → + // the household changed it there: a sentence and the new-password press instead of the eye. + if enc != "" && s.fbStoredPasswordRefused() { + data["InitialCredsStale"] = true + data["InitialCredsHasPassword"] = false + } case settings.FileBrowserAdminOperator: data["HasAppInfo"] = true data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin"} @@ -2721,6 +2727,15 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req escrowJSON(w, http.StatusOK, map[string]any{"password": creds.Password}, "") } +// fbStoredPasswordRefused asks the stack manager whether FileBrowser refuses the stored password (R-1101); the test +// seam Server.fbStoredRefused replaces it. +func (s *Server) fbStoredPasswordRefused() bool { + if s.fbStoredRefused != nil { + return s.fbStoredRefused() + } + return s.stackMgr != nil && s.stackMgr.FileBrowserStoredPasswordRefused() +} + // 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). diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index f51532b..840e4a7 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -528,6 +528,14 @@ func i18nCases() []i18nCase { d["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Note: "A Fájlkezelő belépése."} d["InitialCredsHasPassword"] = true return d + }}, i18nCase{"app_settings_infra_filebrowser_stale", "app_settings", func() map[string]interface{} { + // v0.321.0 (R-1101): FileBrowser refuses the stored password — the household changed it there. + 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["InitialCredsStale"] = 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"}, diff --git a/controller/internal/web/logins_folders_test.go b/controller/internal/web/logins_folders_test.go index 0e86ec3..16cc678 100644 --- a/controller/internal/web/logins_folders_test.go +++ b/controller/internal/web/logins_folders_test.go @@ -352,3 +352,91 @@ func TestFileBrowserResetEndpoint_Refusals(t *testing.T) { t.Errorf("reset in the generated state = %d, want 409", c) } } + +// R-1101 (v0.321.0): FileBrowser refuses the stored password → a sentence and the new-password press, no eye, and no +// „change it after the first login" line (which is how it went stale). +// RED-PROOF (2026-10-16, REPORT): with the `s.fbStoredPasswordRefused()` branch removed from appCommonData, this failed +// at „stale: the page still offers the reveal". +func TestFileBrowserLogin_StoredPasswordRefusedOffersReset(t *testing.T) { + s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) + s.fbStoredRefused = func() bool { return true } + _, html, _ := lfGet(t, s, "/apps/filebrowser") + if strings.Contains(html, `id="initcred-reveal"`) { + t.Fatal("stale: the page still offers the reveal of a password FileBrowser refuses") + } + if !strings.Contains(html, `data-fb-stale="1"`) || !strings.Contains(html, `data-fb-reset="1"`) { + t.Fatal("stale: no sentence or no new-password button") + } + s.fbStoredRefused = func() bool { return false } + _, ok, _ := lfGet(t, s, "/apps/filebrowser") + if !strings.Contains(ok, `id="initcred-reveal"`) || strings.Contains(ok, `data-fb-stale`) || strings.Contains(ok, `data-fb-reset`) { + t.Fatal("a stored password FileBrowser accepts must show the reveal and no reset") + } + // Positive control first: the line exists in the bundle; then: it is not on the FileBrowser page. + if !strings.Contains(s.msgLang("hu", "app_info.az_elso_bejelentkezes_utan_azonnal"), "hagyd figyelmen") { + t.Fatal("control: the change-it line no longer carries the fragment this test looks for") + } + if strings.Contains(ok, "hagyd figyelmen") { + t.Fatal("FileBrowser still says to change the password after the first login (R-1101)") + } +} + +// ── v0.321.0 — the Data tab's backup part (Part 4) and the install address (R-1095) ──────────────────────────────── + +var lfOffDriveOrPBS = regexp.MustCompile(`(?i)off-drive|\bPBS\b`) + +// The Data tab has ONE „Biztonsagi mentes" section, with no „off-drive" and no „PBS" in either language. +// RED-PROOF (2026-10-16, REPORT): with the old `tier2_config.a_2_mentes_egy_masik` text put back in hu.json, this +// failed at „hu: off-drive/PBS on the Data tab"; with the second
put back, at „2 sections". +func TestAppData_OneBackupSection(t *testing.T) { + if !lfOffDriveOrPBS.MatchString("a teljes rendszermentés/PBS — off-drive") { + t.Fatal("control: the matcher does not find the words it looks for") + } + s := i18nTestServer(t) + var c i18nCase + for _, x := range i18nCases() { + if x.name == "app_data_deployed" { + c = x + } + } + if c.name == "" { + t.Fatal("control: the parity case app_data_deployed (a data tab with the 2nd copy) is gone") + } + for _, lang := range []string{"hu", "en"} { + html := renderI18nCase(t, s, lang, c) + if n := strings.Count(html, `data-backup-section="1"`); n != 1 { + t.Errorf("%s: %d backup sections, want 1", lang, n) + } + if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-backup-row="second"`) || + !strings.Contains(html, `data-second-backup-form="1"`) { + t.Errorf("%s: the nightly row, the 2nd-copy row or its form is missing", lang) + } + if m := lfOffDriveOrPBS.FindString(html); m != "" { + t.Errorf("%s: off-drive/PBS on the Data tab (%q)", lang, m) + } + if strings.Contains(html, "👁") || strings.Contains(html, "\U0001F441") { + t.Errorf("%s: the export password eye is still an emoji", lang) + } + if !strings.Contains(html, `id="exportPwToggle" aria-label=`) { + t.Errorf("%s: the export password toggle has no aria-label", lang) + } + } +} + +// R-1095: an INSTALLED app's /install answers 302 to its settings tab (GET only); a not-installed app keeps its form. +// RED-PROOF (2026-10-16, REPORT): with the Deployed redirect removed from appInstallPageHandler, this failed at +// „installed: 200". +func TestInstallAddress_InstalledRedirectsToSettings(t *testing.T) { + s := tuningHarness(t, tuningEnv()) + if c, _, loc := lfGet(t, s, "/apps/calibre-web/install"); c != http.StatusFound || loc != "/apps/calibre-web/settings" { + t.Fatalf("installed: %d → %q, want 302 → /apps/calibre-web/settings", c, loc) + } + if c, html, _ := lfGet(t, s, "/apps/calibre-new/install"); c != http.StatusOK || !strings.Contains(html, "deploy-form") { + t.Fatalf("not installed: %d, the install form must still render", c) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apps/calibre-web/install", nil)) + if w.Code == http.StatusFound && w.Header().Get("Location") == "/apps/calibre-web/settings" { + t.Fatal("a POST to /install was redirected — the redirect is for reads only") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index c09153a..c59a4f0 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -112,6 +112,8 @@ type Server struct { syncFileBrowserHook func() // presentDrivesFn replaces the stack manager's PresentMediaDrives in tests (the app page's folder links, v0.320.0). presentDrivesFn func() []string + // fbStoredRefused replaces the FileBrowser stored-password check in tests (R-1101, v0.321.0). + fbStoredRefused func() bool // langSwitchSync replaces the language switch's file-browser sync in tests (R-691). langSwitchSync func() diff --git a/controller/internal/web/templates/app_data.html b/controller/internal/web/templates/app_data.html index bfad136..f2b0d07 100644 --- a/controller/internal/web/templates/app_data.html +++ b/controller/internal/web/templates/app_data.html @@ -88,27 +88,43 @@ function appMigrate(btn,app,label){ {{end}} -{{/* The nightly copy, stated as a fact. It was a card on the settings page; the files it is about - live here. */}} -
-

{{T "deploy.biztonsagi_mentes"}}

- -
- - {{T "deploy.az_alkalmazas_adatbazisa_es_docker"}} - -
+{{/* „BIZTONSAGI MENTES" — ONE section (v0.321.0). The nightly backup and the 2nd copy were two sections with + two titles for one subject, and the 2nd copy's state sat in a two-column grid of monospace values. Now: the + nightly backup is a row, the 2nd copy's state is rows, then what it means and the form to change it. The words + are plain: no „off-drive", no „PBS", no „DB/konfiguráció". Pinned by TestAppData_OneBackupSection. */}} +
+

{{T "deploy.biztonsagi_mentes"}}

+
+
{{T "app_page.backup_nightly"}}
+
{{T "app_page.backup_nightly_text"}} {{T "app_page.backup_status_link"}}
- -{{if .Tier2}} -{{/* v0.316.0: a SECTION, not a page title over a card. „2. mentés beállítása — " was the - heading of a page that no longer exists; the tab already says which app this is. */}} -
-

{{T "app_page.second_backup"}}

-{{with .Tier2}} -

- {{T "tier2_config.a_2_mentes_egy_masik"}} -

+{{- if .Tier2}}{{with .Tier2}} + {{- if .IsHDDApp}} +
+
{{T "tier2_config.2_mentes"}}
+
{{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}}
+
+ {{- if .NoTarget}} +
+
{{T "tier2_config.cel"}}
+
{{T "tier2_config.nincs_elerheto_off_drive_cel"}}
+
+
+
{{T "tier2_config.megjegyzes"}}
+
{{.NoTargetReason}}
+
+ {{- else}} +
+
{{T "tier2_config.cel_meghajto"}}
+
{{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}}
+
+
+
{{T "tier2_config.kivalasztas_modja"}}
+
{{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}}
+
+ {{- end}} + {{- end}} +

{{T "tier2_config.a_2_mentes_egy_masik"}}

{{if not .IsHDDApp}} {{if eq $.SystemBackup "protected"}} @@ -129,40 +145,13 @@ function appMigrate(btn,app,label){
{{end}} {{else}} -

{{T "tier2_config.jelenlegi_allapot"}}

-
-
- {{T "tier2_config.2_mentes"}} - {{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}} -
- {{if .NoTarget}} -
- {{T "tier2_config.cel"}} - {{T "tier2_config.nincs_elerheto_off_drive_cel"}} -
-
- {{T "tier2_config.megjegyzes"}} - {{.NoTargetReason}} -
- {{else}} -
- {{T "tier2_config.cel_meghajto"}} - {{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}} -
-
- {{T "tier2_config.kivalasztas_modja"}} - {{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}} -
- {{end}} -
- {{if .EffectiveIsSSD}}
{{T "tier2_config.jelenleg_csak_a_belso_ssd"}}
{{end}} -
+ {{$.CSRFField}}
-{{end}} {{if .ExportOffered}}
@@ -242,7 +230,7 @@ function appMigrate(btn,app,label){

{{T "app_export.jelszo_opcionalis"}}

- +