v0.207.0 — R-249: the retrieval passphrase leaves the page body; R-252/R-253: two refusals learn to say what to do
gates / gates (push) Successful in 18s
gates / gates (push) Successful in 18s
R-249. settings_security.html rendered the passphrase into a display:none span behind a Megjelenit button. That toggle stops a browser DRAWING the value and nothing else — the plaintext was in the response body of every render, so a curl of the page returned it. Found by exactly that: it landed in a session transcript while driving the documented rebuild path. The codebase already stated this rule for the recovery code and this page did not follow it (escrow_handlers.go: 'reveal (claim XHR only — R is NEVER templated server-side into HTML)'). The page now carries only HasRetrievalPassword; the value comes from POST /settings/retrieval-password/reveal — CSRF-covered because POST, no-store, and LOGGED as an act, which reading it off the markup never was. The tests assert the RAW RESPONSE BODY. Every test that asked what the customer sees passed while the bytes carried the secret; that is why this survived. Census: the render-then-hide pattern appears twice more — app_info.html (a real per-install app password in a hidden span) and deploy.html. Filed as R-254, NOT fixed here. R-252. A rebuilt box keeps its drives but loses their REGISTRATION. The restore page now states that before the customer presses anything, says the backups and drives are both still there, and links to Tarhely > Meghajtok. Page and resolver ask ONE question — HasRestoreDestination() reads the same GetSchedulableStoragePaths() the scratch resolver reads. R-253. The list promised 'a visszaallitas elobb ujratelepiti' three lines above a refusal that fired BECAUSE the app was not installed. The promise was the wrong half: reconstitution writes to the app's own GetStackHDDPath, which exists only once the CUSTOMER has chosen a drive at deploy time. Auto-reinstalling would mean the product making that choice for them. Copy now says to install first and routes to /stacks/<app>/deploy. Both notices are conditional — a healthy box renders as before, pinned by a test that fails if either becomes unconditional.
This commit is contained in:
@@ -1030,6 +1030,11 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rows, state := s.offsiteRestoreRows(r.Context())
|
||||
data["OffsiteRestoreRows"] = rows
|
||||
data["OffsiteStoreState"] = string(state)
|
||||
// R-252: a rebuilt box's drives survive but their REGISTRATION does not, and every restore
|
||||
// then refuses with a message that names no next step. Asked through the backup manager's own
|
||||
// predicate so the page and the resolver cannot disagree. FALSE on a healthy box, where the
|
||||
// template renders exactly as before (Scenario E).
|
||||
data["NoRestoreDestination"] = !s.backupMgr.HasRestoreDestination()
|
||||
}
|
||||
s.executeTemplate(w, r, "backups_restore", data)
|
||||
}
|
||||
@@ -1576,8 +1581,23 @@ func (s *Server) notificationsPageData() map[string]interface{} {
|
||||
func (s *Server) securityPageData() map[string]interface{} {
|
||||
data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")
|
||||
|
||||
// Recovery info for emergency section
|
||||
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
||||
// Recovery info for emergency section.
|
||||
//
|
||||
// ⚠ R-249 — THE VALUE DOES NOT GO IN THE PAGE, AND THAT IS THE WHOLE FIX.
|
||||
//
|
||||
// Until v0.207.0 this line put the retrieval passphrase into the template data and
|
||||
// `settings_security.html` rendered it into a `display:none` span behind a „Megjelenít" button.
|
||||
// That toggle stops the browser DRAWING it and nothing else: the plaintext was in the response
|
||||
// body of every render, so a `curl` of the page returned it — which is how it was found, by
|
||||
// landing in a session transcript during the 2026-08-07 walk. It was therefore also in browser
|
||||
// caches, in history, in any saved page and in any screen-share of the page source.
|
||||
//
|
||||
// The product already had this exact rule and this page did not follow it — `escrow_handlers.go`
|
||||
// states it for the recovery code: *"reveal (claim XHR only — R is NEVER templated server-side
|
||||
// into HTML)"*. The passphrase now follows the same shape: the page carries only whether one
|
||||
// EXISTS, and the value comes from POST /settings/retrieval-password/reveal, which is an
|
||||
// explicit authenticated act and is logged as one.
|
||||
data["HasRetrievalPassword"] = strings.TrimSpace(s.settings.GetRetrievalPassword()) != ""
|
||||
data["HubURL"] = s.cfg.Hub.URL
|
||||
data["SupportEmail"] = "support@felhom.eu"
|
||||
data["SupportURL"] = "https://felhom.eu/kapcsolat"
|
||||
@@ -1611,6 +1631,37 @@ func (s *Server) securityPageData() map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
// settingsRetrievalPasswordRevealHandler — POST /settings/retrieval-password/reveal (v0.207.0, R-249).
|
||||
//
|
||||
// The ONLY path by which the retrieval passphrase reaches a browser. It is behind RequireAuth and
|
||||
// CsrfProtect like every other POST on this mux, so reaching it takes a live session AND a token
|
||||
// bound to it — where the old rendering took nothing but the ability to read a page the customer
|
||||
// merely opened.
|
||||
//
|
||||
// WHY A POST FOR A READ, deliberately and not by accident: a GET would be re-fetchable from history,
|
||||
// pre-fetchable by a browser, and loggable in any proxy's access log with the response cached. The
|
||||
// act of revealing a secret is a state change in every sense that matters here, and CsrfProtect only
|
||||
// covers unsafe methods — a GET would have no CSRF cover at all.
|
||||
//
|
||||
// `no-store` matters as much as the method: without it a back-navigation can re-present the response
|
||||
// body from the disk cache, which is the same defect one layer down.
|
||||
func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r *http.Request) {
|
||||
pw := strings.TrimSpace(s.settings.GetRetrievalPassword())
|
||||
if pw == "" {
|
||||
// Not an error: a box that never stored one has nothing to reveal, and saying so is not a
|
||||
// leak. The page does not offer the button in that case (HasRetrievalPassword gates it).
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
escrowJSON(w, http.StatusNotFound, nil, "Ezen a gépen nincs tárolt visszaállítási jelszó.")
|
||||
return
|
||||
}
|
||||
// The reveal is an event, and it was not one before: the same act on the hub's break-glass
|
||||
// credential writes `recovery_credential_revealed`, while reading this value off the page markup
|
||||
// left no trace anywhere. The VALUE is never logged — only that it was asked for, and by whom.
|
||||
s.logger.Printf("[INFO] [web] retrieval passphrase revealed via the security page from %s (value never logged)", clientIP(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"password": pw}, "")
|
||||
}
|
||||
|
||||
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.executeTemplate(w, r, "settings_system", s.systemPageData())
|
||||
}
|
||||
|
||||
@@ -146,9 +146,17 @@ func TestOffsiteRestoreList_RenderedForRebuiltBox(t *testing.T) {
|
||||
if !strings.Contains(html, `href="/backups/restore/app?name=calibre-web"`) {
|
||||
t.Errorf("a rebuilt box must be offered its stored app; the page did not render the entry")
|
||||
}
|
||||
if !strings.Contains(html, "Nincs telepítve — a visszaállítás előbb újratelepíti.") {
|
||||
// v0.207.0 (R-253): the INTENT of this assertion is unchanged — the not-installed consequence
|
||||
// must be stated rather than hidden — but the sentence it pinned was a promise the restore could
|
||||
// not keep ("a visszaállítás előbb újratelepíti"), and the reconstitute path refused on exactly
|
||||
// this state. The copy now states the consequence AND routes to the step that clears it, so this
|
||||
// asserts both halves rather than the old wording.
|
||||
if !strings.Contains(html, "Nincs telepítve") || !strings.Contains(html, "telepítsd újra") {
|
||||
t.Errorf("the not-installed consequence must be stated, not hidden")
|
||||
}
|
||||
if !strings.Contains(html, `href="/stacks/calibre-web/deploy"`) {
|
||||
t.Errorf("the consequence is stated but the customer is not routed to the step that clears it")
|
||||
}
|
||||
if strings.Contains(html, "Nincs távoli mentésre jelölt alkalmazás") {
|
||||
t.Errorf("the old toggle-keyed dead end is still rendered")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-252 / R-253 — A REFUSAL ON THE RECOVERY PATH NAMES A REASON A PERSON CAN ACT ON, AND THE WAY TO
|
||||
// ACT ON IT.
|
||||
//
|
||||
// Both of these were met on the fifth walk (2026-08-07) at the LAST step of a successful recovery —
|
||||
// the customer had entered their recovery code, seen their data listed, and pressed through. Neither
|
||||
// needed a shell to clear, which is why the walk's journey half passed; both required knowing
|
||||
// something the product never said, which is why they are filed.
|
||||
//
|
||||
// These assert the RENDERED page, not the state that feeds it: the defect in both cases was copy
|
||||
// that disagreed with behaviour, and only the rendered bytes can show that.
|
||||
|
||||
// restoreData is the healthy fixture — app in the store, installed here, drives registered. Every
|
||||
// test below overlays exactly one field, so what it proves is attributable.
|
||||
func restoreData() map[string]interface{} {
|
||||
d := splitTestData()
|
||||
d["NoRestoreDestination"] = false
|
||||
d["OffsiteRestoreRows"] = []OffsiteRestoreRow{
|
||||
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true},
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ── SCENARIO C — a refusal for a missing drive says what to do ──────────────────────────────────
|
||||
|
||||
// RED-PROOF: restore the old copy — delete the `{{if .NoRestoreDestination}}` block from
|
||||
// backups_restore.html — and this fails on both assertions: the customer is left with a page that
|
||||
// looks normal and a button that will refuse with a message naming no next step.
|
||||
func TestRestorePage_NoRegisteredDrive_NamesTheReasonAndTheRoute(t *testing.T) {
|
||||
d := restoreData()
|
||||
d["NoRestoreDestination"] = true
|
||||
html := renderBackupPage(t, "backups_restore", d)
|
||||
|
||||
if !strings.Contains(html, "csatold vissza az adatmeghajtót") {
|
||||
t.Error("R-252: the restore page says nothing about the drives being unregistered — the " +
|
||||
"customer presses through and gets a refusal that names no next step")
|
||||
}
|
||||
if !strings.Contains(html, `href="/storage"`) {
|
||||
t.Error("R-252: the notice does not ROUTE to the place that fixes it — a reason without a " +
|
||||
"route is what made this an obstacle rather than a message")
|
||||
}
|
||||
// It must say the data is safe. „nincs elérhető adatmeghajtó" reads like data loss; it is not.
|
||||
if !strings.Contains(html, "megvannak") {
|
||||
t.Error("R-252: the notice does not say the backups and the drives are both still there")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO E — a healthy box is unchanged ─────────────────────────────────────────────────────
|
||||
|
||||
// RED-PROOF: make the notice unconditional (drop the `{{if .NoRestoreDestination}}` guard) and this
|
||||
// fails — a box with every precondition satisfied is warned about a problem it does not have, which
|
||||
// is the R-215 shape (a screen about a situation the customer is not in).
|
||||
func TestRestorePage_HealthyBox_HasNoPreconditionNotice(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_restore", restoreData())
|
||||
|
||||
if strings.Contains(html, "csatold vissza az adatmeghajtót") {
|
||||
t.Error("the drive notice rendered on a box whose drives ARE registered")
|
||||
}
|
||||
if strings.Contains(html, "előbb") && strings.Contains(html, "telepítsd újra") {
|
||||
t.Error("the not-installed copy rendered for an app that IS installed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO D — the page and the handler agree about reinstalling ──────────────────────────────
|
||||
|
||||
// RED-PROOF: restore the old string „Nincs telepítve — a visszaállítás előbb újratelepíti." and this
|
||||
// fails on the first assertion — the page promises a reinstall the reconstitute path refuses to do,
|
||||
// which is the contradiction R-253 filed.
|
||||
func TestRestorePage_NotInstalled_DoesNotPromiseAReinstall(t *testing.T) {
|
||||
d := restoreData()
|
||||
d["OffsiteRestoreRows"] = []OffsiteRestoreRow{
|
||||
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: false, Enabled: true},
|
||||
}
|
||||
html := renderBackupPage(t, "backups_restore", d)
|
||||
|
||||
if strings.Contains(html, "a visszaállítás előbb újratelepíti") {
|
||||
t.Error("R-253: the page still promises that the restore reinstalls the app — the " +
|
||||
"reconstitute path refuses precisely because it is not installed, and cannot deploy it " +
|
||||
"itself (the destination is the app's own HDD path, a drive the CUSTOMER chooses)")
|
||||
}
|
||||
if !strings.Contains(html, "telepítsd újra") {
|
||||
t.Error("R-253: the page no longer tells the customer to install the app first")
|
||||
}
|
||||
if !strings.Contains(html, `href="/stacks/calibre-web/deploy"`) {
|
||||
t.Error("R-253: the copy names the step but does not route to it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-249 — THE PASSPHRASE MUST NOT BE IN THE RESPONSE BODY OF A PAGE THE CUSTOMER MERELY OPENS.
|
||||
//
|
||||
// WHY THESE TESTS ASSERT ON THE BODY AND NOT ON A RENDERED VIEW, which is the whole reason the
|
||||
// defect survived: the old markup put the value inside `<span style="display:none">`. Every test
|
||||
// that asked "what does the customer SEE" passed, because a browser drew asterisks. The value was
|
||||
// in the bytes the whole time, and a `curl` of the page returned it — which is how it was found, by
|
||||
// landing in a session transcript on 2026-08-07.
|
||||
//
|
||||
// So: render the real page and search the raw HTML. A test that cannot see a display:none span
|
||||
// cannot see this defect at all.
|
||||
|
||||
const testRetrievalPassphrase = "edeni-oshalom-disztok-harul-Zsolna-TESTONLY"
|
||||
|
||||
// securityHarness builds a Server complete enough for securityPageData, which also reads the stack
|
||||
// list for the geo per-app override selector.
|
||||
func securityHarness(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
cfg := config.Default()
|
||||
cfg.Customer.ID = "c1"
|
||||
cfg.Customer.Domain = "example.hu"
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
||||
cfg.Web.SessionSecret = "test-session-secret-abcdef"
|
||||
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
mgr, err := stacks.NewManager(cfg, lg)
|
||||
if err != nil {
|
||||
t.Fatalf("stacks: %v", err)
|
||||
}
|
||||
return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
|
||||
}
|
||||
|
||||
// renderSecurityPage drives the REAL page-data builder and the REAL template, and returns the bytes
|
||||
// a browser would receive.
|
||||
func renderSecurityPage(t *testing.T, passphrase string) string {
|
||||
t.Helper()
|
||||
s := securityHarness(t)
|
||||
if passphrase != "" {
|
||||
if err := s.settings.SetRetrievalPassword(passphrase); err != nil {
|
||||
t.Fatalf("seed retrieval password: %v", err)
|
||||
}
|
||||
}
|
||||
s.loadTemplates()
|
||||
var buf bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&buf, "settings_security", s.securityPageData()); err != nil {
|
||||
t.Fatalf("render settings_security: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// ── SCENARIO A — the password is not in the page ────────────────────────────────────────────────
|
||||
|
||||
// RED-PROOF (run and confirmed failing before the fix was restored): put the value back in the page
|
||||
// data and the template —
|
||||
//
|
||||
// handlers.go: data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
||||
// template: <code class="mono">{{.RetrievalPassword}}</code> inside the display:none span
|
||||
//
|
||||
// — and this test fails on the first assertion, printing the plaintext's presence in the body. That
|
||||
// is the defect, reproduced. The mutation was applied and observed failing; see the session report.
|
||||
func TestSecurityPage_DoesNotContainTheRetrievalPassphrase(t *testing.T) {
|
||||
html := renderSecurityPage(t, testRetrievalPassphrase)
|
||||
|
||||
if strings.Contains(html, testRetrievalPassphrase) {
|
||||
t.Error("R-249: the retrieval passphrase is in the response body of the security page — " +
|
||||
"a fetch of this page returns the plaintext, and the reveal toggle only stops a " +
|
||||
"browser DRAWING it (this is the defect, and it is invisible to any test that asserts " +
|
||||
"on what is displayed)")
|
||||
}
|
||||
// The card must still be there — the fix is to remove the VALUE, not the feature (Scenario B).
|
||||
if !strings.Contains(html, "Visszaállítási jelszó") {
|
||||
t.Error("the recovery-info card vanished — the fix must not remove the customer's access, " +
|
||||
"only the value from the markup")
|
||||
}
|
||||
if !strings.Contains(html, `id="retrieval-pw-btn"`) {
|
||||
t.Error("no reveal control rendered, so the customer has no way to obtain the passphrase at all")
|
||||
}
|
||||
}
|
||||
|
||||
// The card is gated on EXISTENCE, and existence is not the value. A box with no stored passphrase
|
||||
// must not render the card — the old gate was `{{if .RetrievalPassword}}`, which read the secret to
|
||||
// decide whether to show the secret.
|
||||
func TestSecurityPage_NoCardWhenNoPassphraseStored(t *testing.T) {
|
||||
html := renderSecurityPage(t, "")
|
||||
if strings.Contains(html, "Visszaállítási jelszó") {
|
||||
t.Error("the recovery-info card rendered on a box with no stored retrieval passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO B — the customer can still get it ──────────────────────────────────────────────────
|
||||
|
||||
// RED-PROOF: delete the `/settings/retrieval-password/reveal` case from server.go (or make the
|
||||
// handler return 404 unconditionally) and this fails — the customer is shown unable to obtain the
|
||||
// passphrase at all, which is the wrong fix for Scenario A.
|
||||
func TestRevealEndpoint_ReturnsThePassphraseToAnAuthenticatedCaller(t *testing.T) {
|
||||
s := securityHarness(t)
|
||||
if err := s.settings.SetRetrievalPassword(testRetrievalPassphrase); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil))
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own passphrase", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), testRetrievalPassphrase) {
|
||||
t.Error("the reveal endpoint did not return the passphrase — Scenario A's fix must not " +
|
||||
"protect the secret by removing the customer's access to it")
|
||||
}
|
||||
// A cached reveal is the same defect one layer down: a back-navigation would re-present the body.
|
||||
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Errorf("reveal response Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A box with nothing stored answers cleanly rather than leaking the distinction as a 500.
|
||||
func TestRevealEndpoint_404sWhenNothingStored(t *testing.T) {
|
||||
s := securityHarness(t)
|
||||
rr := httptest.NewRecorder()
|
||||
s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil))
|
||||
if rr.Code != 404 {
|
||||
t.Errorf("reveal on a box with no passphrase returned %d, want 404", rr.Code)
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), testRetrievalPassphrase) {
|
||||
t.Error("the empty-case response carried a passphrase")
|
||||
}
|
||||
}
|
||||
@@ -493,6 +493,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.settingsSecurityPageHandler(w, r)
|
||||
case path == "/settings/password" && r.Method == http.MethodPost:
|
||||
s.settingsPasswordHandler(w, r)
|
||||
// R-249: the retrieval passphrase is fetched by an explicit authenticated act, never templated
|
||||
// into the security page. POST (not GET) so it is CSRF-covered and uncacheable — see the handler.
|
||||
case path == "/settings/retrieval-password/reveal" && r.Method == http.MethodPost:
|
||||
s.settingsRetrievalPasswordRevealHandler(w, r)
|
||||
case path == "/settings/notifications" && r.Method == http.MethodPost:
|
||||
s.settingsNotificationsHandler(w, r)
|
||||
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
||||
|
||||
@@ -69,6 +69,16 @@
|
||||
<!-- R-237: driven by what is IN THE STORE, not by what is deployed and toggled. A rebuilt box has
|
||||
neither and used to be told there was nothing to restore, while its snapshots sat in the
|
||||
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
|
||||
<!-- R-252: the precondition a rebuilt box fails, said BEFORE the customer presses a button that
|
||||
would refuse. Rendered only when it is true — a healthy box sees nothing new here. -->
|
||||
{{if .NoRestoreDestination}}
|
||||
<p class="form-hint" style="border-left:2px solid var(--amber);padding-left:.75rem">
|
||||
<strong>Előbb csatold vissza az adatmeghajtót.</strong> A mentéseid megvannak, és a meghajtók is
|
||||
megvannak — újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová
|
||||
visszaállítani. Ez két kattintás: <a href="/storage" style="color:var(--blue)">Tárhely →
|
||||
Meghajtók</a>, „Meglévő meghajtó csatolása". Utána gyere vissza ide.
|
||||
</p>
|
||||
{{end}}
|
||||
{{if eq .OffsiteStoreState "unreadable"}}
|
||||
<p class="form-hint">Nem tudjuk elolvasni a távoli tárolót, ezért <strong>nem tudjuk, mi van benne</strong>. Ez nem azt jelenti, hogy üres — próbáld újra később, vagy jelezd az üzemeltetőnek.</p>
|
||||
{{else if eq .OffsiteStoreState "no-target"}}
|
||||
@@ -79,8 +89,16 @@
|
||||
{{range .OffsiteRestoreRows}}
|
||||
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
|
||||
{{if .Restorable}}
|
||||
<!-- R-253: this row used to promise that the restore would reinstall the app first.
|
||||
It cannot: reconstitution writes to the app's OWN data path (GetStackHDDPath),
|
||||
which exists only once the customer has chosen a drive during deploy — the
|
||||
restore has no answer to that question and must not invent one. The copy now
|
||||
says what the handler does and routes to the place that does it. (The old
|
||||
sentence is deliberately NOT quoted here: an HTML comment ships in the response
|
||||
body, so quoting it would keep the contradiction on the page and would make the
|
||||
test that forbids it unfailable.) -->
|
||||
{{if and .InStore (not .Installed)}}
|
||||
<span class="form-hint" style="margin-right:.5rem">Nincs telepítve — a visszaállítás előbb újratelepíti.</span>
|
||||
<span class="form-hint" style="margin-right:.5rem">Nincs telepítve — előbb <a href="/stacks/{{.App}}/deploy" style="color:var(--blue)">telepítsd újra</a>, utána hozhatod vissza az adatait.</span>
|
||||
{{end}}
|
||||
{{if .StoreUnknown}}
|
||||
<span class="form-hint" style="margin-right:.5rem">Nem tudjuk, van-e mentése — a tárolót nem sikerült elolvasni.</span>
|
||||
|
||||
@@ -405,7 +405,7 @@ function openDialog(opts){
|
||||
</script>
|
||||
|
||||
<!-- Section: Recovery Info -->
|
||||
{{if .RetrievalPassword}}
|
||||
{{if .HasRetrievalPassword}}
|
||||
<div class="settings-card">
|
||||
<h3>Vészhelyzeti információk</h3>
|
||||
<p class="settings-card-desc">
|
||||
@@ -422,14 +422,13 @@ function openDialog(opts){
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Visszaállítási jelszó</span>
|
||||
<!-- R-249: the value is NOT in this page. The old markup rendered it into a
|
||||
display:none span, which hid it from the eye and from nothing else — a fetch of the
|
||||
page returned the plaintext. „Megjelenít" now asks the server for it. -->
|
||||
<span class="settings-value">
|
||||
<span id="retrieval-pw-hidden">••••••••••••••••
|
||||
<button type="button" class="btn btn-xs btn-outline" onclick="document.getElementById('retrieval-pw-hidden').style.display='none';document.getElementById('retrieval-pw-visible').style.display='inline';">Megjelenít</button>
|
||||
</span>
|
||||
<span id="retrieval-pw-visible" style="display:none">
|
||||
<code class="mono">{{.RetrievalPassword}}</code>
|
||||
<button type="button" class="btn btn-xs btn-outline" onclick="document.getElementById('retrieval-pw-visible').style.display='none';document.getElementById('retrieval-pw-hidden').style.display='inline';">Elrejt</button>
|
||||
</span>
|
||||
<span id="retrieval-pw-slot" class="mono">••••••••••••••••</span>
|
||||
<button type="button" id="retrieval-pw-btn" class="btn btn-xs btn-outline" onclick="revealRetrievalPw()">Megjelenít</button>
|
||||
<span id="retrieval-pw-err" class="form-hint" style="display:none;color:var(--red)"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
@@ -442,6 +441,42 @@ function openDialog(opts){
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// R-249: fetch the passphrase on demand. The page itself never contains it, so this is the only
|
||||
// way it reaches a browser — and it takes a live session plus the CSRF token bound to it.
|
||||
function revealRetrievalPw() {
|
||||
var slot = document.getElementById('retrieval-pw-slot');
|
||||
var btn = document.getElementById('retrieval-pw-btn');
|
||||
var err = document.getElementById('retrieval-pw-err');
|
||||
err.style.display = 'none';
|
||||
if (btn.dataset.shown === '1') { // „Elrejt" — drop the value out of the DOM again
|
||||
slot.textContent = '••••••••••••••••';
|
||||
btn.textContent = 'Megjelenít';
|
||||
btn.dataset.shown = '';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
fetch('/settings/retrieval-password/reveal', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||
btn.disabled = false;
|
||||
if (!j.ok) {
|
||||
err.textContent = j.error || 'A visszaállítási jelszó lekérése nem sikerült.';
|
||||
err.style.display = 'inline';
|
||||
return;
|
||||
}
|
||||
slot.textContent = j.data.password;
|
||||
btn.textContent = 'Elrejt';
|
||||
btn.dataset.shown = '1';
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
err.textContent = 'A visszaállítási jelszó lekérése nem sikerült.';
|
||||
err.style.display = 'inline';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user