feat(web): C2 Parts 2+3 — POST /backup/tier2/restore + "Fájlok visszaállítása" button
- Endpoint next to /backup/restore; handler mirrors backupRestoreHandler (ParseForm → validStackName → backupMgr guard → WARN with RemoteAddr → RestoreTier2Files → flash). Flash strings: "<stack>: N fájl visszaállítva a másodlagos másolatból." / "Nincs hiányzó fájl — minden fájl megvan a helyén." / "Fájl-visszaállítás sikertelen: <err>" (refusals carry the Hungarian reasons from the engine). - backups.html: the button on the healthy Tier-2 layer row only (the Tier2Configured branch already excludes disconnected/inactive; additionally gated on Tier2LastRun), inline POST form with CSRF + confirm dialog naming the additive-only semantics and the last-copy timestamp. Template gates (id + emoji) green. - Handler guard test (C6): traversal/empty → exact Hungarian flash, no work started (nil backupMgr would panic if reached). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -896,6 +896,44 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
|
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
|
||||||
|
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
|
||||||
|
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
|
||||||
|
func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
stackName := r.FormValue("stack_name")
|
||||||
|
|
||||||
|
if stackName == "" {
|
||||||
|
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same F2-defense as the unit restore: a stack name is a single segment, never a path.
|
||||||
|
if !validStackName(stackName) {
|
||||||
|
s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||||||
|
http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.backupMgr == nil {
|
||||||
|
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("[WARN] [web] Tier-2 file restore requested: stack=%s from %s", stackName, r.RemoteAddr)
|
||||||
|
|
||||||
|
n, err := s.backupMgr.RestoreTier2Files(stackName)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed: %v", err)
|
||||||
|
http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Fájl-visszaállítás sikertelen: "+err.Error()), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
|
||||||
|
if n > 0 {
|
||||||
|
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/backups?flash="+url.QueryEscape(msg), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
// settingsBaseData is the shared identity block used by every settings-family subpage
|
// settingsBaseData is the shared identity block used by every settings-family subpage
|
||||||
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
|
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
|
||||||
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
|
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
|
http.Redirect(w, r, "/storage/attach", http.StatusMovedPermanently)
|
||||||
case path == "/backup/restore" && r.Method == http.MethodPost:
|
case path == "/backup/restore" && r.Method == http.MethodPost:
|
||||||
s.backupRestoreHandler(w, r)
|
s.backupRestoreHandler(w, r)
|
||||||
|
// C2: in-place, additive-only file restore from the Tier-2 copy (class-C user files)
|
||||||
|
case path == "/backup/tier2/restore" && r.Method == http.MethodPost:
|
||||||
|
s.backupTier2RestoreHandler(w, r)
|
||||||
// Off-box (NAS) restic-SFTP backup (Part B)
|
// Off-box (NAS) restic-SFTP backup (Part B)
|
||||||
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
|
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
|
||||||
s.offboxConfigHandler(w, r)
|
s.offboxConfigHandler(w, r)
|
||||||
|
|||||||
@@ -436,6 +436,12 @@
|
|||||||
<span class="tier-contents">{{.BackupContents}}</span>
|
<span class="tier-contents">{{.BackupContents}}</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
|
||||||
<div class="layer-actions">
|
<div class="layer-actions">
|
||||||
|
{{if .Tier2LastRun}}
|
||||||
|
<form method="POST" action="/backup/tier2/restore" style="display:inline" onsubmit="return confirm('Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{.Tier2LastRun}}')">{{$.CSRFField}}
|
||||||
|
<input type="hidden" name="stack_name" value="{{.StackName}}">
|
||||||
|
<button type="submit" class="btn btn-xs btn-outline">Fájlok visszaállítása</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
|
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postTier2Restore drives the handler with a form body, as the UI's inline form does.
|
||||||
|
func postTier2Restore(t *testing.T, s *Server, stackName string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"stack_name": {stackName}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.backupTier2RestoreHandler(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTier2RestoreHandler_Guards proves Scenario C6 + the missing-param guard: traversal and empty
|
||||||
|
// names are rejected with the exact Hungarian flash BEFORE any restore work (backupMgr is nil here —
|
||||||
|
// reaching it would panic, so a pass also proves no work started).
|
||||||
|
func TestTier2RestoreHandler_Guards(t *testing.T) {
|
||||||
|
s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose
|
||||||
|
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", // Érvénytelen alkalmazásnév
|
||||||
|
"a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v",
|
||||||
|
"": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek", // Hiányzó paraméterek
|
||||||
|
} {
|
||||||
|
rec := postTier2Restore(t, s, name)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Errorf("%q: status = %d, want 302", name, rec.Code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) {
|
||||||
|
t.Errorf("%q: redirect = %q, want flash %q", name, loc, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid name but no backup manager → the not-configured flash (still no panic, no work).
|
||||||
|
rec := postTier2Restore(t, s, "nextcloud")
|
||||||
|
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
|
||||||
|
t.Errorf("nil backupMgr: redirect = %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user