kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased
gates / gates (push) Successful in 1m20s

This commit is contained in:
2026-10-21 15:46:10 +02:00
parent 8f5bea0148
commit 5929b8e345
34 changed files with 2152 additions and 44 deletions
@@ -53,6 +53,9 @@ type ExportStackProvider interface {
// SaveEncryptedAppConfig saves app.yaml with re-encrypted sensitive fields.
// env is the plaintext env map from the bundle; encryption uses the current server key.
SaveEncryptedAppConfig(stackDir string, env map[string]string) error
// PrepareImportedApp gives the imported app its family gate and sign-up lock BEFORE it starts (R-1191): called
// after the config step (the compose file is in place) and before StartStack. An error means „do not start".
PrepareImportedApp(stackDir string, env map[string]string) error
// RefreshStacks rescans all stacks and refreshes container state.
RefreshStacks() error
// RemoveStackVolumes stops the stack and removes its named Docker volumes.
+5
View File
@@ -459,6 +459,11 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) {
job.setStep(step, "running", "")
stepStart = time.Now()
// R-1191: the doors first — a family-only app must never start open (the import used to drop its gate).
if err := e.provider.PrepareImportedApp(stackDir, env); err != nil {
e.failJob(job, step, fmt.Sprintf("Alkalmazás előkészítése sikertelen: %v", err))
return
}
e.debugf("starting stack %s", manifest.AppName)
if err := e.provider.StartStack(manifest.AppName); err != nil {
e.failJob(job, step, fmt.Sprintf("Alkalmazás indítása sikertelen: %v", err))
@@ -188,3 +188,5 @@ func TestFabRoundTrip_ExportImportContentEquality(t *testing.T) {
t.Error("import must start the restored app")
}
}
func (p *rtProvider) PrepareImportedApp(string, map[string]string) error { return nil }
@@ -0,0 +1,168 @@
package backup
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// R-1175 + D25 (operator ruling 2026-10-21), scenario B. The first two tests are the data-loss hunt's C3 cases
// (felhom.eu audits/dataloss-hunt-2026-10-20/C3-D1-shares-tier2/), now asserting instead of logging.
func sharesCopy(env *sharesEnv, target, srcDrive, share string) string {
return filepath.Join(NamespaceRoot(env.drives[target], true), "backups", "secondary", SharesPseudoStack,
sharesDriveKey(env.drives[srcDrive]), share)
}
func mustExist(t *testing.T, p, why string) {
t.Helper()
if _, err := os.Stat(p); err != nil {
t.Fatalf("%s: %s is gone (%v)", why, p, err)
}
}
// Shares on hdd_2 and hdd_3 both copy to hdd_1: after EVERY run both groups' copies exist. Before the fix each group's
// reconcile walked every drive key and removed the other group's copy.
func TestR1175_TwoDrivesSharingOneTargetKeepBothCopies(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
env.addShare(t, "hdd_2", "b_share", true)
env.addShare(t, "hdd_3", "c_share", true)
for run := 1; run <= 2; run++ {
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "run "+string(rune('0'+run))+": hdd_2's share copy")
mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_3", "c_share"), "c_share.txt"), "run "+string(rune('0'+run))+": hdd_3's share copy")
}
}
// The night a drive is away its copy is NOT deleted: it stays, and it is listed as kept (drive_away).
func TestR1175_AnAwayDrivesShareCopyIsKeptAndListed(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
env.addShare(t, "hdd_2", "b_share", true)
env.addShare(t, "hdd_3", "csaladi_fotok", true)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
cp := sharesCopy(env, "hdd_1", "hdd_3", "csaladi_fotok")
mustExist(t, filepath.Join(cp, "csaladi_fotok.txt"), "the away drive's only second copy")
kept := env.m.KeptCopies()
if len(kept) != 1 || kept[0].Source != "csaladi_fotok" || kept[0].Reason != "drive_away" || kept[0].Path != filepath.Clean(cp) {
t.Fatalf("kept copies = %+v, want csaladi_fotok (drive_away) at %s", kept, cp)
}
// The drive comes back: the copy is live again and its record goes.
if err := env.sett.SetDisconnected(env.drives["hdd_3"], false, nil); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
if k := env.m.KeptCopies(); len(k) != 0 {
t.Fatalf("after the drive came back, kept copies = %+v, want none", k)
}
}
// A registered share whose FOLDER is missing (drive present) keeps its copy; a share the household DELETED is pruned.
func TestR1175_MissingFolderKeptDeletedSharePruned(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
gone := env.addShare(t, "hdd_1", "eltunt", true)
env.addShare(t, "hdd_1", "torolt", true)
env.addShare(t, "hdd_1", "marad", true)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(gone); err != nil {
t.Fatal(err)
}
if err := env.sett.RemoveSMBShare("torolt"); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "eltunt"), "eltunt.txt"), "a missing folder's copy")
if _, err := os.Stat(sharesCopy(env, "hdd_2", "hdd_1", "torolt")); !os.IsNotExist(err) {
t.Fatalf("a deleted share's copy was not pruned (stat err %v)", err)
}
if k := env.m.KeptCopies(); len(k) != 1 || k[0].Source != "eltunt" || k[0].Reason != "folder_missing" {
t.Fatalf("kept copies = %+v, want eltunt (folder_missing)", k)
}
}
// An EMPTIED present folder is not mirrored over a copy that holds files: the copy stays and the run warns.
func TestR1181_AnEmptiedShareDoesNotEmptyItsCopy(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2")
if _, err := exec.LookPath("rsync"); err == nil {
env.m.tier2Mirror = rsyncMirror // the production mirror: `rsync -a --delete` is what empties a copy
}
p := env.addShare(t, "hdd_1", "fotok", true)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(p, "fotok.txt")); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
mustExist(t, filepath.Join(sharesCopy(env, "hdd_2", "hdd_1", "fotok"), "fotok.txt"), "an emptied folder's copy")
st := env.m.SharesTier2Status()
if st == nil || !strings.Contains(st.LastWarning, "fotok") {
t.Fatalf("the run did not warn about the emptied folder: %+v", st)
}
}
// „Eltávolítás" removes ONE kept copy by its record, and refuses a directory that is not the recorded one.
func TestD25_RemovingAKeptCopyIsByRecordAndIdentity(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
env.addShare(t, "hdd_2", "b_share", true)
env.addShare(t, "hdd_3", "c_share", true)
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
if err := env.sett.SetDecommissioned(env.drives["hdd_3"], ""); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
k := env.m.KeptCopies()
if len(k) != 1 || k[0].Reason == "" {
t.Fatalf("kept = %+v", k)
}
// Swap the directory for another one at the same path: same name, different identity → refused, nothing removed.
cp := k[0].Path
if err := os.Rename(cp, cp+".old"); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(cp, 0o755); err != nil {
t.Fatal(err)
}
if err := env.m.RemoveKeptCopy(k[0].ID); err == nil {
t.Fatal("a directory that is not the recorded one was removed")
}
mustExist(t, cp, "the refused removal")
// Put the recorded one back → removed, and only it.
_ = os.Remove(cp)
if err := os.Rename(cp+".old", cp); err != nil {
t.Fatal(err)
}
if err := env.m.RemoveKeptCopy(k[0].ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cp); !os.IsNotExist(err) {
t.Fatalf("the kept copy is still there: %v", err)
}
mustExist(t, filepath.Join(sharesCopy(env, "hdd_1", "hdd_2", "b_share"), "b_share.txt"), "the other drive's copy")
if len(env.m.KeptCopies()) != 0 {
t.Fatal("the record outlived the removal")
}
}
@@ -0,0 +1,52 @@
package backup
import (
"os"
"path/filepath"
"strings"
"testing"
)
// R-1179: a drive marked AWAY is never the second-drive target. Seen in the R-1175 unit test of the data-loss hunt
// (a share copied to a drive marked disconnected). COMPANION RED-PROOF: drop the Disconnected skip → the copy lands
// on hdd_1.
func TestR1179_AnAwayDriveIsNotChosenAsTarget(t *testing.T) {
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
env.addShare(t, "hdd_2", "b_share", true)
if err := env.sett.SetDisconnected(env.drives["hdd_1"], true, nil); err != nil {
t.Fatal(err)
}
if err := env.m.RunSharesTier2(); err != nil {
t.Fatal(err)
}
for _, call := range env.mirrored {
if strings.Contains(call, "=>"+env.drives["hdd_1"]+string(os.PathSeparator)) {
t.Fatalf("a copy went to the AWAY drive hdd_1: %s", call)
}
}
if _, err := os.Stat(filepath.Join(sharesCopy(env, "hdd_3", "hdd_2", "b_share"), "b_share.txt")); err != nil {
t.Fatalf("no copy on the present drive hdd_3: %v", err)
}
}
// R-1188 (c): the data-loss hunt's C3 case (felhom.eu audits/dataloss-hunt-2026-10-20/C3-R12-leftover-temp/): a power
// cut mid-copy leaves `<file>.felhom-restoring`; the retry must bring the file back instead of failing „file exists".
// COMPANION RED-PROOF: drop the leftover removal in copyFilePreserving → the retry fails and p1.jpg stays missing.
func TestR1188c_ALeftoverTempDoesNotBlockTheRetry(t *testing.T) {
f, _, liveLeg := wholeFixture(t)
if err := os.MkdirAll(liveLeg, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(liveLeg, "p1.jpg.felhom-restoring"), []byte("half"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := f.m.RestoreTier2Whole("app"); err != nil {
t.Fatalf("the retry failed: %v", err)
}
if _, err := os.Stat(filepath.Join(liveLeg, "p1.jpg")); err != nil {
t.Fatalf("p1.jpg is not back: %v", err)
}
if _, err := os.Stat(filepath.Join(liveLeg, "p1.jpg.felhom-restoring")); !os.IsNotExist(err) {
t.Fatalf("the leftover temp is still there: %v", err)
}
}
@@ -0,0 +1,61 @@
package backup
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// R-1181 + D25 for app folders — the data-loss hunt's C3 case (felhom.eu audits/dataloss-hunt-2026-10-20/
// C3-D2-missing-leg/), asserting: a MISSING declared folder keeps its second-drive copy and is listed as kept; an
// EMPTIED folder is not mirrored over its full copy (the production `rsync -a --delete` mirror is used when present).
func TestR1181_MissingAndEmptiedAppFoldersKeepTheirCopies(t *testing.T) {
m, src, target, prov := newTier2V2(t, "app")
if _, err := exec.LookPath("rsync"); err == nil {
m.tier2Mirror = rsyncMirror
}
prov.has["app"] = true
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/photos"), mHDD("appdata/app/docs")}
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
mustWrite(t, filepath.Join(src, "appdata", "app", "docs", "contract.pdf"), "PDF")
if err := m.RunTier2("app"); err != nil {
t.Fatal(err)
}
destBase := filepath.Join(target, "backups", "secondary", "app")
photo := filepath.Join(destBase, "hdd", "appdata", "app", "photos", "family.jpg")
doc := filepath.Join(destBase, "hdd", "appdata", "app", "docs", "contract.pdf")
if !exists(photo) || !exists(doc) {
t.Fatal("precondition: run 1 did not copy both folders")
}
if err := os.RemoveAll(filepath.Join(src, "appdata", "app", "photos")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(src, "appdata", "app", "docs", "contract.pdf")); err != nil {
t.Fatal(err)
}
if err := m.RunTier2("app"); err != nil {
t.Fatal(err)
}
if !exists(photo) {
t.Fatal("the MISSING folder's second-drive copy was deleted (R-1181)")
}
if !exists(doc) {
t.Fatal("the EMPTIED folder was mirrored over its full copy (R-1181)")
}
kept := m.KeptCopies()
if len(kept) != 1 || kept[0].Kind != "app" || kept[0].Reason != "folder_missing" {
t.Fatalf("kept copies = %+v, want the photos folder", kept)
}
if cd := m.settings.GetCrossDriveConfig("app"); cd == nil || cd.LastWarning == "" {
t.Fatalf("the run did not warn: %+v", cd)
}
// The folder comes back: mirrored again, the record goes.
mustWrite(t, filepath.Join(src, "appdata", "app", "photos", "family.jpg"), "PHOTO")
if err := m.RunTier2("app"); err != nil {
t.Fatal(err)
}
if len(m.KeptCopies()) != 0 {
t.Fatalf("the record outlived the folder's return: %+v", m.KeptCopies())
}
}
+21 -2
View File
@@ -152,6 +152,12 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
if sp.Path == sourceDrive {
continue
}
// R-1179: a drive marked away is never a target. Its mount point is an empty folder on the guest's root
// disk (or the host's), so the night's copy would fill the wrong disk and not be where the household
// thinks. Decommissioned drives are already left out by GetSchedulableStoragePaths.
if sp.Disconnected {
continue
}
if sp.IsNetwork() {
sawNetworkCandidate = true // F-6C-1: never a tier-2 target
continue
@@ -350,7 +356,7 @@ func (m *Manager) RunTier2(stackName string) error {
}
// Class-driven legs (classified) / resolver legs (legacy) + loud gap warnings (§7-H).
legs, warns := m.tier2CaptureSet(stackName, sourceNsRoot)
legs, missingLegs, warns := m.tier2CaptureSetFull(stackName, sourceNsRoot)
// Two sizes: full = unit + all legs; state-only = unit + mandatory legs (the SSD ceiling).
unitSize := dirSizeBytes(unitDir)
@@ -440,7 +446,13 @@ func (m *Manager) RunTier2(stackName string) error {
legRels := make([]string, 0, len(legs))
mirroredSize := unitSize
for _, lg := range legs {
if err := mirror(lg.Src, filepath.Join(destBase, filepath.FromSlash(lg.DestRel))); err != nil {
dst := filepath.Join(destBase, filepath.FromSlash(lg.DestRel))
if m.emptiedSourceRefused(stackName+" "+lg.DestRel, lg.Src, dst) {
legRels = append(legRels, lg.DestRel) // kept as it is (R-1181)
warns = append(warns, m.note("note.tier2.emptied_kept", lg.DestRel))
continue
}
if err := mirror(lg.Src, dst); err != nil {
m.recordTier2Failure(stackName, target, err)
if m.tier2Notify != nil {
m.tier2Notify(stackName, target.Label, time.Since(start), err)
@@ -449,6 +461,13 @@ func (m *Manager) RunTier2(stackName string) error {
}
legRels = append(legRels, lg.DestRel)
mirroredSize += dirSizeBytes(lg.Src)
m.releaseKeptCopy(dst)
}
// D25 / R-1181: a declared folder that is MISSING keeps its copy (kept + listed), never reconciled away.
for _, lg := range missingLegs {
legRels = append(legRels, lg.DestRel)
m.keepCopy("app", stackName+": "+lg.DestRel, m.storageLabel(sourceDrive), "folder_missing", destBase,
filepath.Join(destBase, filepath.FromSlash(lg.DestRel)))
}
// Reconcile stale dest dirs (a bind removed / re-classed excluded), then write the marker LAST.
+13 -3
View File
@@ -40,6 +40,14 @@ func tier2DestRel(root appbackup.BindRoot, rel string) string {
// footprint promise), mapped into the same v2 layout so restore has ONE reader. Missing/refused
// mandatory paths are stat-filtered + surfaced loudly (§7-H).
func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns []string) {
legs, _, warns = m.tier2CaptureSetFull(stack, nsRoot)
return legs, warns
}
// tier2CaptureSetFull is tier2CaptureSet plus the DECLARED legs that are missing on disk (mandatory and optional).
// D25 / R-1181 (2026-10-21): a missing declared folder keeps its second-drive copy — RunTier2 adds these to the
// reconcile's keep set and lists them as kept, instead of pruning the copy that may be the only one left.
func (m *Manager) tier2CaptureSetFull(stack, nsRoot string) (legs, missing []tier2Leg, warns []string) {
// Classified path.
if m.stackProvider != nil {
if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has {
@@ -58,7 +66,8 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
m.logger.Printf("[WARN] [backup] Tier 2 %s: mandatory data path missing on disk, skipped: %s", stack, p.Abs)
gaps = append(gaps, p.RelPath)
}
continue // optional-missing is silent (not a gap)
missing = append(missing, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class})
continue // optional-missing is silent (not a gap); its copy is kept (D25)
}
legs = append(legs, tier2Leg{Src: p.Abs, DestRel: tier2DestRel(p.Root, p.RelPath), Class: p.Class})
}
@@ -66,7 +75,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a másodlagos mentésbe: %s.",
stack, strings.Join(gaps, ", ")))
}
return legs, warns
return legs, missing, warns
}
}
// Legacy path: the resolver appdata dir(s) → hdd/appdata/<name> legs (mandatory-equivalent for SSD
@@ -79,6 +88,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
if declared {
m.logger.Printf("[WARN] [backup] Tier 2 %s: compose declares appdata dir %q but it is absent at %s — leg skipped",
stack, name, src)
missing = append(missing, tier2Leg{Src: src, DestRel: filepath.ToSlash(filepath.Join("hdd", "appdata", name)), Class: appbackup.ClassMandatory})
}
continue
}
@@ -88,5 +98,5 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
Class: appbackup.ClassMandatory,
})
}
return legs, warns
return legs, missing, warns
}
+174
View File
@@ -0,0 +1,174 @@
package backup
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── D25 (operator ruling 2026-10-21; R-1175 + R-1181): a second-drive copy whose SOURCE is gone is KEPT ─────────────
//
// A source is gone when its drive is away or decommissioned, or a declared folder is missing. The night job never
// removes such a copy: it records it (settings.KeptCopy), the backups page lists it as kept, and only the household's
// „Eltávolítás" removes it — by its recorded path, identity-checked (device + inode), never by size or a name pattern.
// A share the household DELETED or RENAMED on a present drive is still pruned (that is the household's own act).
//
// 07 §8.2 chose „data legs are not guarded" for a leg that LEAVES the class set; a gone source is a different case,
// and D25 decides it the other way: the copy may be the only one left.
// keepCopy records path (an existing directory inside destBase) as a kept copy. A missing path records nothing — there
// is nothing to keep.
func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) {
if m.settings == nil {
return
}
st, err := os.Stat(path)
if err != nil || !st.IsDir() {
return
}
dev, ino := statIdentity(st)
k := settings.KeptCopy{Kind: kind, Source: source, Drive: drive, Reason: reason, DestBase: filepath.Clean(destBase),
Path: filepath.Clean(path), Dev: dev, Ino: ino, SizeBytes: dirSizeBytes(path)}
if err := m.settings.UpsertKeptCopy(k); err != nil {
m.logger.Printf("[WARN] [backup] kept copy %s could not be recorded (the copy itself is kept): %v", path, err)
return
}
m.logger.Printf("[INFO] [backup] second-drive copy KEPT (source gone: %s): %s %q at %s", reason, kind, source, path)
}
// releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched.
func (m *Manager) releaseKeptCopy(path string) {
if m.settings == nil {
return
}
id := settings.KeptCopyID(filepath.Clean(path))
for _, k := range m.settings.GetKeptCopies() {
if k.ID == id {
_ = m.settings.DropKeptCopy(id)
m.logger.Printf("[INFO] [backup] kept copy %s is live again (its source is back) — record dropped", path)
return
}
}
}
// KeptCopies lists the kept copies for the backups page.
func (m *Manager) KeptCopies() []settings.KeptCopy {
if m.settings == nil {
return nil
}
return m.settings.GetKeptCopies()
}
// ErrKeptCopyNotFound / ErrKeptCopyChanged are the refusals of RemoveKeptCopy.
var (
ErrKeptCopyNotFound = errors.New("kept copy not found")
ErrKeptCopyChanged = errors.New("kept copy is not the recorded directory any more")
)
// RemoveKeptCopy is the household's „Eltávolítás": it removes ONE kept copy by its record — the recorded path, still
// strictly inside the recorded copy tree, and still the same directory (device + inode) the night recorded. Anything
// else refuses and removes nothing. A copy already gone just drops its record.
func (m *Manager) RemoveKeptCopy(id string) error {
if m.settings == nil {
return ErrKeptCopyNotFound
}
var rec *settings.KeptCopy
for _, k := range m.settings.GetKeptCopies() {
if k.ID == id {
kk := k
rec = &kk
break
}
}
if rec == nil {
return ErrKeptCopyNotFound
}
st, err := os.Lstat(rec.Path)
if errors.Is(err, fs.ErrNotExist) {
m.logger.Printf("[INFO] [backup] kept copy %s is already gone — record dropped", rec.Path)
return m.settings.DropKeptCopy(id)
}
if err != nil {
return fmt.Errorf("kept copy %s: %w", rec.Path, err)
}
if !st.IsDir() {
return fmt.Errorf("%w: %s is not a directory", ErrKeptCopyChanged, rec.Path)
}
if dev, ino := statIdentity(st); dev != rec.Dev || ino != rec.Ino {
m.logger.Printf("[WARN] [backup] kept copy removal REFUSED: %s is not the recorded directory (identity changed)", rec.Path)
return fmt.Errorf("%w: %s", ErrKeptCopyChanged, rec.Path)
}
rel, err := filepath.Rel(rec.DestBase, rec.Path)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
return fmt.Errorf("%w: %s is not inside %s", ErrKeptCopyChanged, rec.Path, rec.DestBase)
}
if err := tier2SafeRemove(rec.DestBase, rec.Path); err != nil {
return err
}
m.logger.Printf("[INFO] [backup] kept copy REMOVED by the household: %s %q (%s) at %s", rec.Kind, rec.Source, humanizeBytes(rec.SizeBytes), rec.Path)
return m.settings.DropKeptCopy(id)
}
func statIdentity(st os.FileInfo) (dev, ino uint64) {
if s, ok := st.Sys().(*syscall.Stat_t); ok {
return uint64(s.Dev), s.Ino
}
return 0, 0
}
// ── R-1181 second half: an EMPTIED present folder is not mirrored over a full copy ────────────────────────────────
// hasRegularFile reports whether dir holds at least one regular file anywhere below it (stops at the first).
func hasRegularFile(dir string) bool {
found := false
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.Type().IsRegular() {
found = true
return fs.SkipAll
}
return nil
})
return found
}
// countRegularFiles counts the regular files below dir (for the log line only).
func countRegularFiles(dir string) int {
n := 0
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err == nil && d.Type().IsRegular() {
n++
}
return nil
})
return n
}
// emptiedSourceRefused reports whether mirroring src over dst would turn a copy that holds files into an empty one
// (`rsync --delete` from an emptied folder). Such a leg is refused for this run: the copy stays, the run warns.
func (m *Manager) emptiedSourceRefused(what, src, dst string) bool {
if hasRegularFile(src) || !hasRegularFile(dst) {
return false
}
m.logger.Printf("[WARN] [backup] Tier 2: %s — the source folder is EMPTY (0 files) and its second-drive copy holds %d file(s); NOT mirrored, the copy is kept: %s",
what, countRegularFiles(dst), dst)
return true
}
// storageLabel is the label the pages show for a registered drive path (its path when it has none).
func (m *Manager) storageLabel(path string) string {
if m.settings != nil {
if l := m.settings.GetStorageLabel(path); l != "" {
return l
}
}
return path
}
+88 -6
View File
@@ -128,6 +128,18 @@ func (m *Manager) RunSharesTier2() error {
}
g.shares = append(g.shares, sh)
}
// D25: every REGISTERED share per source drive, available or not — a registered share whose folder is missing
// keeps its copy (and is listed as kept); only a share the household deleted or renamed is pruned.
registeredByDrive := map[string][]settings.SMBShare{}
if m.settings != nil {
for _, sh := range m.settings.GetSMBShares() {
if d := m.shareSourceDrive(sh.Path); d != "" {
registeredByDrive[d] = append(registeredByDrive[d], sh)
}
}
}
touched := map[string]map[string]bool{} // destBase → the drive keys handled there this run
var groups []*sharesTier2Group
for _, g := range byDrive {
groups = append(groups, g)
@@ -202,7 +214,13 @@ func (m *Manager) RunSharesTier2() error {
legRels := make([]string, 0, len(legs)+1)
for _, sh := range legs {
rel := g.key + "/" + sh.Name
if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil {
dst := filepath.Join(destBase, filepath.FromSlash(rel))
if m.emptiedSourceRefused("share "+sh.Name, sh.Path, dst) {
legRels = append(legRels, rel) // kept as it is
warns = append(warns, m.note("note.tier2.emptied_kept", sh.Name))
continue
}
if err := mirror(sh.Path, dst); err != nil {
m.recordTier2Failure(SharesPseudoStack, target, err)
if m.tier2Notify != nil {
// DISPLAY BOUNDARY: the notifier feeds Hungarian customer/operator copy, so the
@@ -215,6 +233,16 @@ func (m *Manager) RunSharesTier2() error {
legRels = append(legRels, rel)
totalSize += dirSizeBytes(sh.Path)
mirrored++
m.releaseKeptCopy(dst)
}
// D25: a registered share of this drive whose folder is missing keeps its copy (and is listed as kept).
for _, sh := range registeredByDrive[g.sourceDrive] {
if m.shareBackupAvailable(sh.Path) {
continue
}
rel := g.key + "/" + sh.Name
legRels = append(legRels, rel)
m.keepCopy("share", sh.Name, m.storageLabel(g.sourceDrive), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel)))
}
// The payload rides every target so each one is independently restorable.
if payloadDir != "" {
@@ -227,7 +255,15 @@ func (m *Manager) RunSharesTier2() error {
// Prune dest dirs no share covers any more (a share deleted or renamed since the last run
// stops occupying the secondary drive within one run), then the marker LAST — a half-written
// dest therefore has no marker and the restore path refuses it until the next good run.
m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels)
//
// R-1175: ONLY this group's own drive key. Walking every key on disk with only this group's legs removed
// every OTHER group's copies on a shared target (after each run only the last group had a copy) and, on the
// night a drive died, its only second copy. Keys no group handled this run are dealt with once, below.
m.tier2ReconcileRoots(destBase, []string{g.key}, legRels)
if touched[destBase] == nil {
touched[destBase] = map[string]bool{}
}
touched[destBase][g.key] = true
if err := m.writeTier2Marker(destBase); err != nil {
m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err)
}
@@ -236,6 +272,10 @@ func (m *Manager) RunSharesTier2() error {
len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize))
}
for destBase, done := range touched {
m.sharesTier2OtherKeys(destBase, done, registeredByDrive)
}
dur := time.Since(start)
if lastTarget == nil {
reason := strings.Join(noTargetWhy, "; ")
@@ -260,9 +300,7 @@ func (m *Manager) RunSharesTier2() error {
return nil
}
// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may
// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that
// is no longer registered still gets visited (and pruned) instead of lingering forever.
// sharesTier2DestRoots lists the top-level drive-key dirs under a shares destBase (the payload dir is not one).
func sharesTier2DestRoots(destBase string) []string {
entries, err := os.ReadDir(destBase)
if err != nil {
@@ -270,7 +308,7 @@ func sharesTier2DestRoots(destBase string) []string {
}
var roots []string
for _, e := range entries {
if e.IsDir() {
if e.IsDir() && e.Name() != sharesPayloadDestRel {
roots = append(roots, e.Name())
}
}
@@ -278,6 +316,50 @@ func sharesTier2DestRoots(destBase string) []string {
return roots
}
// sharesTier2OtherKeys handles the drive keys on destBase that no group mirrored this run (D25, R-1175):
// - the key of a registered drive that is PRESENT: its shares were deleted, renamed or have missing folders — a
// registered share's copy is kept (listed), a deleted/renamed share's copy is pruned (the household's own act);
// - the key of a drive that is AWAY or DECOMMISSIONED, or of no registered drive at all: every copy under it is
// kept and listed. Before 2026-10-21 these were pruned on the first run without them.
func (m *Manager) sharesTier2OtherKeys(destBase string, done map[string]bool, registeredByDrive map[string][]settings.SMBShare) {
byKey := map[string]settings.StoragePath{}
if m.settings != nil {
for _, sp := range m.settings.GetStoragePaths() {
byKey[sharesDriveKey(sp.Path)] = sp
}
}
for _, key := range sharesTier2DestRoots(destBase) {
if done[key] {
continue
}
keyDir := filepath.Join(destBase, key)
sp, known := byKey[key]
if known && !sp.Disconnected && !sp.Decommissioned {
var keep []string
for _, sh := range registeredByDrive[sp.Path] {
rel := key + "/" + sh.Name
keep = append(keep, rel)
m.keepCopy("share", sh.Name, m.storageLabel(sp.Path), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel)))
}
m.tier2ReconcileRoots(destBase, []string{key}, keep)
continue
}
reason, drive := "drive_gone", key
if known {
drive = m.storageLabel(sp.Path)
if sp.Disconnected {
reason = "drive_away"
}
}
entries, _ := os.ReadDir(keyDir)
for _, e := range entries {
if e.IsDir() {
m.keepCopy("share", e.Name(), drive, reason, destBase, filepath.Join(keyDir, e.Name()))
}
}
}
}
// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its
// presence means "every leg and the reconcile completed".
func (m *Manager) writeTier2Marker(destBase string) error {
@@ -84,6 +84,14 @@ func copyFilePreserving(src, dst string, fi fs.FileInfo) error {
}
defer in.Close()
tmp := dst + ".felhom-restoring"
// R-1188 (c): an interrupted earlier restore (a power cut mid-copy) leaves this exact temp behind, and O_EXCL then
// failed EVERY retry with „file exists" — the file never came back. The temp is ours by name; a leftover REGULAR
// file of that one name is removed before the copy. Anything else at that name (a directory, a link) still refuses.
if st, lerr := os.Lstat(tmp); lerr == nil && st.Mode().IsRegular() {
if rerr := os.Remove(tmp); rerr != nil {
return fmt.Errorf("leftover %s from an interrupted restore could not be removed: %w", filepath.Base(tmp), rerr)
}
}
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fi.Mode().Perm())
if err != nil {
return err
+17 -3
View File
@@ -1138,7 +1138,6 @@
"deploy.az_alkalmazas_elindult_de_meg": "The app started, but it is still initialising.",
"deploy.az_alkalmazas_email_jelenleg_ki": "App e-mail is turned off globally right now. Turn it on on the\n <a href=\"/settings\">Settings</a> page for it to work here too.",
"deploy.az_alkalmazas_sikeresen_telepitve_es": "The app is installed and running!",
"deploy.az_alkalmazasok_csucsterhelese_meghaladh": "The apps’ peak load can go above the available memory.\n In normal use this causes no problem.",
"deploy.beallitasok": "Settings",
"deploy.beallitasok_es_jelszavak_megtekintese": "View settings and passwords",
"deploy.bejelentkezes": "Sign-in",
@@ -1419,7 +1418,6 @@
"err.stacks.ismeretlen_migracios_hatokor": "unknown migration scope: %s",
"err.stacks.masolas_sikertelen_appdata": "the copy failed (%s app data): %s",
"err.stacks.masolas_sikertelen_mentes": "the copy failed (%s backup): %s",
"err.stacks.memory_overcommit_warning": "At peak, the apps together may need more memory than there is. In normal use this causes no trouble.",
"err.stacks.migracio_mar_folyamatban": "a migration is already running",
"err.stacks.migracios_naplo_irasa": "writing the migration log: %s",
"err.stacks.nincs_eleg_hely_a_celtarolon_kb": "not enough space on the target storage (about %d GB needed, %.1f GB free)",
@@ -2804,5 +2802,21 @@
"host_storage.pbs.purpose": "Encrypted backups kept away from your home.",
"host_storage.nfs.label": "Network backup (NFS)",
"host_storage.cifs.label": "Network backup (SMB)",
"host_storage.network.purpose": "Backups kept on a network storage."
"host_storage.network.purpose": "Backups kept on a network storage.",
"backups_restore.megorzott_masolatok": "Kept copies",
"backups_restore.megorzott_masolatok_hint": "These second-drive copies belong to folders that cannot be reached now: the drive is not connected, is no longer in the box, or the folder is missing. We keep them until you remove them.",
"backups_restore.eltavolitas": "Remove",
"backups_restore.megorzott_eltavolitas_megerosites": "Remove this kept copy? It is deleted from the second drive for good.",
"kept.from_drive": "a copy kept from the %s drive",
"kept.reason.drive_away": "the drive is not connected",
"kept.reason.drive_gone": "the drive is no longer in the box",
"kept.reason.folder_missing": "the folder is missing",
"note.tier2.emptied_kept": "The folder %s is empty, so its second-drive copy was not overwritten — the earlier copy is kept.",
"flash.kept.removed": "The kept copy was removed.",
"flash.kept.needs_confirmation": "Confirm the question to remove it.",
"flash.kept.not_found": "This kept copy is no longer there.",
"flash.kept.changed": "Another folder is in the copy's place now, so it was not removed.",
"flash.kept.remove_failed": "The kept copy could not be removed: %s",
"deploy.memory_overcommit_slow": "The apps' peak load can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app.",
"err.stacks.memory_overcommit_slow": "The apps' peak load can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app."
}
+17 -3
View File
@@ -1133,7 +1133,6 @@
"deploy.az_alkalmazas_elindult_de_meg": "Az alkalmazás elindult, de még inicializálódik.",
"deploy.az_alkalmazas_email_jelenleg_ki": "Az alkalmazás-email jelenleg ki van kapcsolva globálisan. Kapcsold be a\n <a href=\"/settings\">Beállítások</a> oldalon, hogy itt is működjön.",
"deploy.az_alkalmazas_sikeresen_telepitve_es": "Az alkalmazás sikeresen telepítve és fut!",
"deploy.az_alkalmazasok_csucsterhelese_meghaladh": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát.\n Normál használat mellett ez nem okoz problémát.",
"deploy.beallitasok": "Beállítások",
"deploy.beallitasok_es_jelszavak_megtekintese": "Beállítások és jelszavak megtekintése",
"deploy.bejelentkezes": "Bejelentkezés",
@@ -1410,7 +1409,6 @@
"err.stacks.ismeretlen_migracios_hatokor": "ismeretlen migrációs hatókör: %s",
"err.stacks.masolas_sikertelen_appdata": "másolás sikertelen (%s appdata): %s",
"err.stacks.masolas_sikertelen_mentes": "másolás sikertelen (%s mentés): %s",
"err.stacks.memory_overcommit_warning": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Normál használat mellett ez nem okoz problémát.",
"err.stacks.migracio_mar_folyamatban": "migráció már folyamatban",
"err.stacks.migracios_naplo_irasa": "migrációs napló írása: %s",
"err.stacks.nincs_eleg_hely_a_celtarolon_kb": "nincs elég hely a céltárolón (kb. %d GB szükséges, %.1f GB szabad)",
@@ -2792,5 +2790,21 @@
"host_storage.pbs.purpose": "Titkosított, telephelyen kívüli biztonsági mentések.",
"host_storage.nfs.label": "Hálózati mentés (NFS)",
"host_storage.cifs.label": "Hálózati mentés (SMB)",
"host_storage.network.purpose": "Hálózati tárolón őrzött biztonsági mentések."
"host_storage.network.purpose": "Hálózati tárolón őrzött biztonsági mentések.",
"backups_restore.megorzott_masolatok": "Megőrzött másolatok",
"backups_restore.megorzott_masolatok_hint": "Ezek a második meghajtón lévő másolatok olyan mappákhoz tartoznak, amelyek most nem érhetők el: a meghajtó nincs csatlakoztatva, már nincs a gépen, vagy a mappa hiányzik. Megőrizzük őket, amíg te el nem távolítod őket.",
"backups_restore.eltavolitas": "Eltávolítás",
"backups_restore.megorzott_eltavolitas_megerosites": "Biztosan eltávolítod ezt a megőrzött másolatot? A második meghajtóról véglegesen törlődik.",
"kept.from_drive": "a(z) %s meghajtóról megőrzött másolat",
"kept.reason.drive_away": "a meghajtó nincs csatlakoztatva",
"kept.reason.drive_gone": "a meghajtó már nincs a gépen",
"kept.reason.folder_missing": "a mappa hiányzik",
"note.tier2.emptied_kept": "A(z) %s mappa üres, ezért a másodlagos másolatát nem írtuk felül — a korábbi példány megmaradt.",
"flash.kept.removed": "A megőrzött másolat eltávolítva.",
"flash.kept.needs_confirmation": "Az eltávolításhoz erősítsd meg a kérdést.",
"flash.kept.not_found": "Ez a megőrzött másolat már nincs meg.",
"flash.kept.changed": "A másolat helyén már egy másik mappa van, ezért nem távolítottuk el.",
"flash.kept.remove_failed": "A megőrzött másolatot nem sikerült eltávolítani: %s",
"deploy.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást.",
"err.stacks.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást."
}
@@ -0,0 +1,81 @@
package settings
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// KeptCopy is one second-drive copy whose source is gone (D25, operator ruling 2026-10-21). The record is what the
// backups page lists and what „Eltávolítás" removes — by Path, identity-checked against Dev/Ino, never by size or a
// name pattern (07 §8.2 as amended by D25).
type KeptCopy struct {
ID string `json:"id"` // stable: sha256 of Path, first 16 hex
Kind string `json:"kind"` // "share" | "app"
Source string `json:"source"` // the share's name, or "<app>: <folder>"
Drive string `json:"drive"` // the SOURCE drive's label (or its path when the drive is no longer registered)
Reason string `json:"reason"` // "drive_away" | "drive_gone" | "folder_missing"
DestBase string `json:"dest_base"` // the copy tree it lives in; Path must stay inside it
Path string `json:"path"` // the copy's absolute directory
Dev uint64 `json:"dev"` // identity at record time
Ino uint64 `json:"ino"` // identity at record time
SizeBytes int64 `json:"size_bytes"` // at the latest run
Since string `json:"since"` // RFC3339, first time it was kept
}
// KeptCopyID is the record's id for a copy path.
func KeptCopyID(path string) string {
h := sha256.Sum256([]byte(path))
return hex.EncodeToString(h[:8])
}
// GetKeptCopies returns a copy of the kept-copy records.
func (s *Settings) GetKeptCopies() []KeptCopy {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]KeptCopy(nil), s.KeptCopies...)
}
// UpsertKeptCopy records a kept copy (or refreshes its size and identity), keeping the first Since.
func (s *Settings) UpsertKeptCopy(k KeptCopy) error {
s.mu.Lock()
defer s.mu.Unlock()
if k.Path == "" || k.DestBase == "" {
return fmt.Errorf("kept copy: empty path or dest base")
}
k.ID = KeptCopyID(k.Path)
for i := range s.KeptCopies {
if s.KeptCopies[i].ID == k.ID {
since := s.KeptCopies[i].Since
k.Since = since
if s.KeptCopies[i] == k {
return nil // nothing changed — no write
}
s.KeptCopies[i] = k
return s.save()
}
}
if k.Since == "" {
k.Since = time.Now().UTC().Format(time.RFC3339)
}
s.KeptCopies = append(s.KeptCopies, k)
if s.log != nil {
s.log.Printf("[INFO] [settings] kept copy recorded: kind=%s source=%q reason=%s path=%s", k.Kind, k.Source, k.Reason, k.Path)
}
return s.save()
}
// DropKeptCopy forgets the record of a copy path (the source came back, or the household removed the copy). The
// copy's data is not touched here.
func (s *Settings) DropKeptCopy(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.KeptCopies {
if s.KeptCopies[i].ID == id {
s.KeptCopies = append(s.KeptCopies[:i], s.KeptCopies[i+1:]...)
return s.save()
}
}
return nil
}
+4
View File
@@ -270,6 +270,10 @@ type Settings struct {
// SMBShares is the ordered registry of exported folders. Each Path is an absolute host path under
// a registered storage root; the smb.conf + compose + backup classification all ride this list.
SMBShares []SMBShare `json:"smb_shares,omitempty"`
// KeptCopies (D25, operator ruling 2026-10-21; R-1175 + R-1181): second-drive copies whose SOURCE is gone (a drive
// away or decommissioned, a declared folder missing). They are kept until the household releases one with
// „Eltávolítás"; no night job removes them. See internal/settings/kept_copies.go.
KeptCopies []KeptCopy `json:"kept_copies,omitempty"`
}
// AppEmail holds the global app-email toggle and an optional household display name.
+3 -1
View File
@@ -38,4 +38,6 @@ var (
// memoryVerdict through DeployStack to the API, which renders it in the request's language (R-575 —
// it used to be rendered here in Hungarian, so an English page showed a Hungarian line). Pinned by
// TestR575_OvercommitWarningFollowsTheLanguage.
const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_warning"
// R-1192 (2026-10-21): the key changed with the text — its old second sentence („Normál használat mellett ez nem okoz
// problémát.") was false; the 8 GB box froze under its first uploads.
const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_slow"
@@ -0,0 +1,97 @@
package stacks
import (
"fmt"
"path/filepath"
"time"
)
// ── R-1191 (2026-10-21): a `.fab` import builds the app record the way the unit restore does ─────────────────────
//
// The import used to write a bare record (Deployed, DeployedAt, Env, DesiredState) and start the app; the family-gate
// reconcile then removed the gate file of an app with no FamilyGate record, so a family-only app (Glance, Grimmory,
// MeTube) came back open to anyone (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-…). The
// sign-up lock (R-773) and the locked fields were dropped the same way.
//
// Now, in two steps around the import's own order (the bundle's compose file is in place only after the config step):
// - SaveImportedAppConfig — the record: locked fields, desired state running, and the PRIOR record's life records
// (carryLifeRecords: an installed app keeps its gates, pins and update history; an import never un-gates it);
// - PrepareImportedApp — right before the start: the family gate (when the template is a family app and the record
// has none) and, for an app that had no record before, the sign-up lock — the same helpers the unit restore calls
// (prepareFamilyGate, restoreSignupLock), never a second copy of them.
// SaveImportedAppConfig writes an imported app's record. Returns whether the app had NO record before (a fresh import),
// which PrepareImportedApp needs for the sign-up lock rule (decision 49: a restore never adds a lock to an installed
// app the household has not closed).
func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string) (fresh bool, err error) {
name := filepath.Base(stackDir)
meta := LoadMetadata(stackDir)
priorRaw := LoadAppConfig(stackDir)
cfg := &AppConfig{
Deployed: true,
DeployedAt: time.Now().UTC().Format(time.RFC3339),
Env: env,
// R-166: importing a bundle is the household installing that app — it is meant to run.
DesiredState: DesiredStateRunning,
}
for _, f := range meta.DeployFields {
if f.LockedAfterDeploy {
cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
}
}
carryLifeRecords(m.logger, name, priorRaw, cfg)
if priorRaw != nil {
cfg.PinnedImages = priorRaw.PinnedImages
cfg.InstalledImages = priorRaw.InstalledImages
cfg.PreviousImages = priorRaw.PreviousImages
}
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return false, err
}
m.logger.Printf("[INFO] [stacks] %s: import record saved (fresh=%v, family gate carried=%v, sign-up lock carried=%v)",
name, priorRaw == nil, cfg.FamilyGate != nil, cfg.SetupGate != nil)
return priorRaw == nil, nil
}
// PrepareImportedApp gives an imported app its doors BEFORE it starts. Called by the import after the bundle's
// config files (the compose file among them) are in place and before StartStack. An error means „do not start".
func (m *Manager) PrepareImportedApp(stackDir string, env map[string]string, fresh bool) error {
name := filepath.Base(stackDir)
composePath := filepath.Join(stackDir, "docker-compose.yml")
meta := LoadMetadata(stackDir)
cfg := LoadAppConfigDecrypted(stackDir, m.encKey)
if cfg == nil {
return fmt.Errorf("the imported app's record could not be read")
}
changed := false
if cfg.FamilyGate == nil && meta.FamilyGate {
rec, err := m.prepareFamilyGate(name, composePath, env, &meta)
if err != nil {
return fmt.Errorf("the family gate could not be prepared (the app was not started): %w", err)
}
cfg.FamilyGate, changed = rec, true
}
if fresh && cfg.SetupGate == nil {
rec, err := m.restoreSignupLock(name, composePath, env, &meta)
if err != nil {
return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err)
}
if rec != nil {
cfg.SetupGate, changed = rec, true
}
}
if changed {
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
}
// The in-memory record is what the gate reconcile reads: without this the next tick would remove the file again.
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.Deployed = true
s.AppConfig = cfg
}
m.mu.Unlock()
m.logger.Printf("[INFO] [stacks] %s: import prepared before the start (family gate=%v, sign-up lock=%v)", name, cfg.FamilyGate != nil, cfg.SetupGate != nil)
return nil
}
@@ -0,0 +1,67 @@
package stacks
import (
"os"
"path/filepath"
"testing"
)
// R-1191 (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-fab-family-gate/): a `.fab` import of
// a family-only app must come back WITH its door — the gate file before the first start, the record carrying it, and
// the reconcile loop keeping it. The three catalog family apps' stanzas (Glance, MeTube: family_gate only; Grimmory:
// with exceptions) are the fixtures. COMPANION RED-PROOF: make PrepareImportedApp skip prepareFamilyGate → each case
// fails „no door after the import"; and the old bare record (SaveImportedAppConfig without carryLifeRecords) fails the
// installed-app case.
func TestR1191_AnImportedFamilyAppKeepsItsDoor(t *testing.T) {
base := "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
for name, yml := range map[string]string{
"glance": "display_name: Glance\nfamily_gate: true\n" + base,
"metube": "display_name: MeTube\nfamily_gate: true\n" + base,
"grimmory": "display_name: Grimmory\nfamily_gate: true\nfamily_gate_except: [\"/api/v1/opds\", \"/api/v2/opds\", \"/api/kobo\", \"/api/koreader\", \"/komga/api\"]\n" + base,
} {
t.Run(name, func(t *testing.T) {
m := gateManager(t, yml)
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
// a REMOVED app imported from its bundle (no record before)
fresh, err := m.SaveImportedAppConfig(dir, env)
must(t, err)
if !fresh {
t.Fatal("an app with no record must import as fresh")
}
must(t, m.PrepareImportedApp(dir, env, fresh))
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("no door after the import — the app would start open to anyone (R-1191)")
}
cfg := LoadAppConfig(dir)
if cfg == nil || cfg.FamilyGate == nil || cfg.DesiredState != DesiredStateRunning {
t.Fatalf("record after the import: %+v", cfg)
}
m.SetupGateTick() // the reconcile that removed the file of a gate-less record
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("the reconcile loop removed the imported app's door")
}
})
}
}
// An INSTALLED app re-imported over itself keeps the records it had (the gate's „since", an update's history) — the
// import never replaces them with a bare record.
func TestR1191_AnImportOverAnInstalledAppKeepsItsRecords(t *testing.T) {
m := gateManager(t, familyYml)
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
prior := &AppConfig{Deployed: true, Env: env, FamilyGate: &FamilyGateRecord{Since: "2026-10-01T00:00:00Z", Hosts: []string{"gapp.example.hu"}},
PinnedImages: map[string]string{"gapp": "example/gapp:1.2.3"}}
must(t, SaveAppConfig(dir, prior, m.encKey, nil))
fresh, err := m.SaveImportedAppConfig(dir, env)
must(t, err)
if fresh {
t.Fatal("an installed app must not import as fresh")
}
must(t, m.PrepareImportedApp(dir, env, fresh))
cfg := LoadAppConfig(dir)
if cfg == nil || cfg.FamilyGate == nil || cfg.FamilyGate.Since != "2026-10-01T00:00:00Z" || cfg.PinnedImages["gapp"] != "example/gapp:1.2.3" {
t.Fatalf("the installed app's records were not kept: %+v", cfg)
}
}
@@ -212,3 +212,5 @@ func hasSuffixIn(ss []string, suffix string) bool {
}
return false
}
func (p *fabWebProvider) PrepareImportedApp(string, map[string]string) error { return nil }
+2
View File
@@ -1328,6 +1328,8 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
// D25: second-drive copies whose source is gone — kept until the household removes one.
data["KeptCopies"] = s.keptCopyRows(s.langFor(r))
// R-487: removed apps whose unit was kept join the local restore picker.
data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits()
}
@@ -227,6 +227,16 @@ func i18nCasesB() []i18nCase {
d["OffboxApps"] = []m{{"Slug": "immich", "DisplayName": "Immich Photos", "Name": "immich"}}
return d
}},
// D25 (2026-10-21): the kept-copies list (a NEW case, so no existing fixture changes). Line is composed in Go in
// the request's language; the fixture carries only a drive label so it is the same in both renders.
{"backups_restore_kept", "backups_restore", func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["OffboxConfigured"] = true
d["OffsiteStoreState"] = "known"
d["KeptCopies"] = []keptCopyRow{{ID: "0123456789abcdef", Source: "csaladi_fotok", Line: "hdd_3", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}}
return d
}},
{"backups_restore_known_empty", "backups_restore", func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"errors"
"net/http"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is
// gone and lets the household release one with „Eltávolítás". Nothing else removes them.
// keptCopyRow is one line of the „Megőrzött másolatok" list.
type keptCopyRow struct {
ID string
Source string
Line string // „a(z) <drive> meghajtóról megőrzött másolat · <why>"
SizeHuman string
Since string
}
func (s *Server) keptCopyRows(lang string) []keptCopyRow {
if s.backupMgr == nil {
return nil
}
var out []keptCopyRow
for _, k := range s.backupMgr.KeptCopies() {
why := s.msgLang(lang, "kept.reason."+k.Reason)
out = append(out, keptCopyRow{
ID: k.ID,
Source: k.Source,
Line: s.msgLang(lang, "kept.from_drive", k.Drive) + " · " + why,
SizeHuman: backup.HumanizeBytes(k.SizeBytes),
Since: k.Since,
})
}
return out
}
// keptCopyRemoveHandler is „Eltávolítás": POST /backup/kept/remove (id, confirm=1). It removes ONE kept copy by its
// record (backup.RemoveKeptCopy: the recorded path, inside its copy tree, same device + inode) and nothing else.
func (s *Server) keptCopyRemoveHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true)
return
}
_ = r.ParseForm()
id := strings.TrimSpace(r.FormValue("id"))
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.needs_confirmation", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
s.logger.Printf("[WARN] [web] kept-copy removal refused (%s): a backup/restore op is running", id)
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
switch err := s.backupMgr.RemoveKeptCopy(id); {
case err == nil:
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.removed", false)
case errors.Is(err, backup.ErrKeptCopyNotFound):
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.not_found", true)
case errors.Is(err, backup.ErrKeptCopyChanged):
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.changed", true)
default:
s.logger.Printf("[WARN] [web] kept-copy removal %s failed: %v", id, err)
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.remove_failed", true, err.Error())
}
}
@@ -0,0 +1,97 @@
package web
import (
"io"
"log"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D25 (operator ruling 2026-10-21): the restore page lists kept second-drive copies with „Eltávolítás" — and shows no
// section at all when there are none. Render test per branch of the {{if .KeptCopies}} gate (the seam-wiring rule).
func TestD25_RestorePageListsKeptCopiesWithRemove(t *testing.T) {
base := func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["OffboxConfigured"] = true
d["OffsiteStoreState"] = "known"
return d
}
with := base()
with["KeptCopies"] = []keptCopyRow{{ID: "0123456789abcdef", Source: "csaladi_fotok", Line: "a(z) hdd_3 meghajtóról megőrzött másolat · a meghajtó nincs csatlakoztatva", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}}
html := renderBackupPage(t, "backups_restore", with)
for _, want := range []string{"Megőrzött másolatok", "csaladi_fotok", "a(z) hdd_3 meghajtóról megőrzött másolat", `action="/backup/kept/remove"`,
`name="id" value="0123456789abcdef"`, `name="confirm" value="1"`, "Eltávolítás", "confirmRemoveKeptCopy(this)"} {
if !strings.Contains(html, want) {
t.Errorf("restore page with a kept copy lacks %q", want)
}
}
if html := renderBackupPage(t, "backups_restore", base()); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "/backup/kept/remove") {
t.Error("restore page with NO kept copy shows the kept-copies section")
}
}
// „Eltávolítás" through the handler: no confirm → refused, nothing removed; an unknown id → its own refusal; the
// recorded copy → removed, and ONLY it. Real settings + manager + disk (the record is the only selector).
func TestD25_RemoveHandlerRemovesOnlyTheRecordedCopy(t *testing.T) {
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
destBase := filepath.Join(tmp, "hdd_1", "backups", "secondary", "_shares")
kept := filepath.Join(destBase, "hdd_3", "csaladi_fotok")
other := filepath.Join(destBase, "hdd_2", "b_share")
for _, d := range []string{kept, other} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
st, _ := os.Stat(kept)
sys := st.Sys().(*syscall.Stat_t)
if err := sett.UpsertKeptCopy(settings.KeptCopy{Kind: "share", Source: "csaladi_fotok", Drive: "hdd_3", Reason: "drive_away",
DestBase: destBase, Path: kept, Dev: uint64(sys.Dev), Ino: sys.Ino}); err != nil {
t.Fatal(err)
}
id := settings.KeptCopyID(kept)
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
s := &Server{cfg: cfg, backupMgr: backup.NewManager(cfg, sett, lg), logger: lg}
post := func(form string) string {
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/backup/kept/remove", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.keptCopyRemoveHandler(rr, req)
return rr.Header().Get("Location")
}
if loc := post("id=" + id); !strings.Contains(loc, "flash.kept.needs_confirmation") {
t.Fatalf("no confirm → %q", loc)
}
if _, err := os.Stat(kept); err != nil {
t.Fatal("removed without a confirm")
}
if loc := post("id=ffffffffffffffff&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") {
t.Fatalf("unknown id → %q", loc)
}
if loc := post("id=" + id + "&confirm=1"); !strings.Contains(loc, "flash.kept.removed") {
t.Fatalf("recorded copy → %q", loc)
}
if _, err := os.Stat(kept); !os.IsNotExist(err) {
t.Fatalf("the kept copy is still there: %v", err)
}
if _, err := os.Stat(filepath.Join(other, "f.txt")); err != nil {
t.Fatalf("another copy was touched: %v", err)
}
}
+3
View File
@@ -834,6 +834,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// DeleteOffsiteRestoreCopy for the prefix guard.
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
s.offboxVerifyCopyDeleteHandler(w, r)
// D25 (2026-10-21): „Eltávolítás" of ONE kept second-drive copy whose source is gone.
case path == "/backup/kept/remove" && r.Method == http.MethodPost:
s.keptCopyRemoveHandler(w, r)
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
s.sharesRestoreHandler(w, r)
@@ -167,7 +167,32 @@
<!-- v0.147.0 (4a): what the verification restores actually LEFT on disk. Until now the result of
an ellenőrző visszaállítás was invisible — the flash said "a verification folder on the
drive" without naming it, and nothing listed what had accumulated. Full path, size and date,
so the copy can be found, opened, and cleaned up. -->
so the copy can be found, opened, and cleaned up. -->{{/* D25 (operator ruling 2026-10-21; R-1175, R-1181): second-drive copies whose source is gone (a drive away or removed, a folder missing). No night job removes them; only „Eltávolítás" here, after a confirm. On one line with the comment above so a page without kept copies renders byte-identically. */}}{{if .KeptCopies}}
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.megorzott_masolatok"}}</h3>
<p class="form-hint" style="margin-bottom:.75rem">{{T "backups_restore.megorzott_masolatok_hint"}}</p>
<div class="app-row-list">
{{range .KeptCopies}}
{{template "app_list_row" dict "Slug" "" "Name" .Source "Secondary" (printf "%s · %s · %s" .Line (numText .SizeHuman) (fmtTimeStr .Since))}}
<form method="POST" action="/backup/kept/remove" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-danger-outline"
onclick="confirmRemoveKeptCopy(this)">{{T "backups_restore.eltavolitas"}}</button>
</form>
{{template "app_list_row_end"}}
{{end}}
</div>
<script>
function confirmRemoveKeptCopy(btn){
felhomConfirm(btn, '{{T "backups_restore.megorzott_eltavolitas_megerosites"}}', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
}
</script>
{{end}}
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.meglevo_ellenorzo_masolatok"}}</h3>
{{if .OffsiteRestoreCopies}}
@@ -55,7 +55,7 @@
</div>
{{if .OvercommitWarn}}
<div class="alert alert-warning" style="margin-top:0.5rem;margin-bottom:0">
{{T "deploy.az_alkalmazasok_csucsterhelese_meghaladh"}}
{{T "deploy.memory_overcommit_slow"}}
</div>
{{end}}
{{end}}
@@ -0,0 +1,908 @@
<!DOCTYPE html>
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
if(!btn)return;
var group=btn.closest('.nav-group');
if(!group)return;
var willOpen=!group.classList.contains('is-open');
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
g.classList.remove('is-open');
var t=g.querySelector('.nav-group-toggle');
if(t)t.setAttribute('aria-expanded','false');
});
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class=""><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/storage" class="">Meghajtók</a></li>
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
</ul>
</li>
<li class="nav-group is-open">
<button type="button" class="nav-group-toggle active" aria-expanded="true" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/backups" class="">Áttekintés</a></li>
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
<li><a href="/backups/restore" class="active">Visszaállítás</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
</ul>
<div class="sidebar-bottom">
<div class="nav-group-label">Beállítások</div>
<ul class="nav-links nav-links-sub">
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
<main class="content">
<div class="page-header">
<h2>Biztonsági mentés — Visszaállítás</h2>
<span class="domain-badge">example.hu</span>
</div>
<div id="restore-banner" class="flash" style="display:none"></div>
<div class="backup-section-card">
<h3>Visszaállítás</h3>
<div class="restore-section">
<div class="restore-form-row">
<label class="restore-label">Alkalmazás:</label>
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
<option value="">— Válassz —</option>
<option value="privatebin" data-has-hdd="" data-has-db="" data-has-volumes=""></option>
</select>
</div>
<div class="restore-form-row">
<label class="restore-label">Pillanatkép:</label>
<select id="restore-snapshot" class="restore-select" onchange="onRestoreConfirmChange()">
<option value="">— Válassz alkalmazást —</option>
</select>
</div>
<div id="restore-type-info" class="restore-info" style="display:none;margin-bottom:0.5rem">
</div>
<div id="restore-no-snapshots" class="restore-warning" style="display:none;">
Még nincs mentés felhasználói adattal.
</div>
<div class="restore-warning">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával.
Az alkalmazás a folyamat során automatikusan leáll és újraindul.
</div>
<div class="restore-confirm">
<label>
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
Megértettem, visszaállítás indítása.
</label>
</div>
<div class="restore-actions">
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">Visszaállítás indítása</button>
</div>
<div style="margin-top: 1rem; text-align: center; border-top: 1px solid var(--line); padding-top: 1rem;">
<a href="/import" class="btn btn-sm btn-outline">Importálás mentett csomagból (.fab)</a>
</div>
</div>
</div>
<div class="backup-section-card">
<h3>Visszaállítás a távoli tárolóból</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.</p>
<p class="form-hint">A távoli tároló üres — nincs mit visszaállítani.</p>
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Megőrzött másolatok</h3>
<p class="form-hint" style="margin-bottom:.75rem">Ezek a második meghajtón lévő másolatok olyan mappákhoz tartoznak, amelyek most nem érhetők el: a meghajtó nincs csatlakoztatva, már nincs a gépen, vagy a mappa hiányzik. Megőrizzük őket, amíg te el nem távolítod őket.</p>
<div class="app-row-list">
<div class="app-row">
<img class="app-row-icon" src="/static/assets/-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/-logo.png';}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';}">
<div class="app-row-text">
<span class="app-row-name">csaladi_fotok</span>
<span class="app-row-secondary">hdd_3 · 1,2 GB · 2026. 10. 21. 14:00</span>
</div>
<div class="app-row-actions">
<form method="POST" action="/backup/kept/remove" style="display:inline">
<input type="hidden" name="id" value="0123456789abcdef">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-danger-outline"
onclick="confirmRemoveKeptCopy(this)">Eltávolítás</button>
</form>
</div>
</div>
</div>
<script>
function confirmRemoveKeptCopy(btn){
felhomConfirm(btn, 'Biztosan eltávolítod ezt a megőrzött másolatot? A második meghajtóról véglegesen törlődik.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
}
</script>
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Meglévő ellenőrző másolatok</h3>
<p class="form-hint">Nincs ellenőrző másolat a meghajtón.</p>
</div>
<div class="backup-section-card">
<h3>Hordozható mentéscsomag (.fab)</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Hordozható pillanatfelvétel — bárhol tárolhatod, és bármikor visszatöltheted egy meghajtóról. A folyamatos védelmet az 1–3. szintű mentés adja.</p>
<p class="form-hint">Nincs telepített alkalmazás.</p>
</div>
<script>
(function(){
var banner = document.getElementById('restore-banner');
if (!banner) return;
var sawRunning = false;
function opLabel(op){ return op === 'tier2-restore' ? 'Fájl-visszaállítás'
: op === 'offbox-restore' ? 'Távoli visszaállítás' : 'Visszaállítás'; }
function render(st){
if (st.running) {
sawRunning = true;
banner.className = 'flash';
banner.style.display = 'block';
banner.textContent = opLabel(st.op) + ' folyamatban' + (st.stack ? ': ' + st.stack : '') + '…';
return;
}
if (st.last && (sawRunning || st.last_recent)) {
banner.style.display = 'block';
if (st.last.ok) {
banner.className = 'flash flash-success';
banner.textContent = st.last.message || (opLabel(st.last.op) + ' kész.');
} else {
banner.className = 'flash flash-error';
banner.textContent = st.last.message || (opLabel(st.last.op) + ' sikertelen.');
}
}
}
function poll(){
fetch('/api/backup/restore-status', {headers: {'Accept':'application/json'}})
.then(function(r){ return r.json(); })
.then(function(j){ if (j && j.data) render(j.data); })
.catch(function(){});
}
poll();
setInterval(poll, 3000);
})();
var fabQueue = [];
function fabSetStatus(msg){ document.getElementById('fab-dl-status').textContent = msg; }
function fabPassword(){ var el = document.getElementById('fab-dl-password'); return el ? el.value : ''; }
function fabDownload(btn){
var stack = btn.getAttribute('data-stack');
fetch('/api/export/download/estimate?stack=' + encodeURIComponent(stack))
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba: ' + (j.error || 'a becslés sikertelen')); return; }
var size = (j.data && j.data.total_size_human) || '?';
felhomConfirm(btn, 'A csomag becsült mérete: ' + size + '. Elindítod a letöltést? (Pillanatfelvétel a mostani állapotról.)', function(){
fabStart(stack, null);
});
})
.catch(function(){ fabSetStatus('Hiba: a becslés nem érhető el.'); });
}
function confirmDeleteVerifyCopy(btn){
var path = btn.getAttribute('data-copy-path') || '';
felhomConfirm(btn, 'Biztosan törlöd ezt az ellenőrző másolatot? ' + path + ' — a tényleges adataid változatlanok maradnak.', function(){
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba (' + stack + '): ' + (j.error || 'az export nem indult el')); if (next) next(); return; }
fabSetStatus('Csomag készítése: ' + stack + '…');
fabPoll(stack, next);
})
.catch(function(){ fabSetStatus('Hiba: az export nem indult el.'); if (next) next(); });
}
function fabPoll(stack, next){
var iv = setInterval(function(){
fetch('/api/export/status').then(function(r){ return r.json(); }).then(function(j){
if (!j || j.running || !j.done) return;
clearInterval(iv);
if (j.error) { fabSetStatus('Hiba (' + stack + '): ' + j.error); if (next) next(); return; }
var base = (j.output_path || '').split('/').pop();
if (!base) { fabSetStatus('Hiba: a csomag útvonala hiányzik.'); if (next) next(); return; }
fabSetStatus('Letöltés: ' + base + (j.output_size ? ' (' + j.output_size + ')' : ''));
window.location.href = '/api/export/download?file=' + encodeURIComponent(base);
if (next) setTimeout(next, 3000);
}).catch(function(){});
}, 2000);
}
function fabDownloadAll(){
var btns = document.querySelectorAll('.fab-dl-btn');
fabQueue = Array.prototype.map.call(btns, function(b){ return b.getAttribute('data-stack'); });
fabRunQueue();
}
function fabRunQueue(){
var stack = fabQueue.shift();
if (!stack) { fabSetStatus('Minden csomag elkészült.'); return; }
fabStart(stack, fabRunQueue);
}
var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat'];
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
if (s.drive_label) {
label += ' — ' + tierLabel + ', ' + s.drive_label;
} else {
label += ' — ' + tierLabel;
}
return label;
}
function onRestoreAppChange() {
var sel = document.getElementById('restore-app');
var appName = sel.value;
var snapSel = document.getElementById('restore-snapshot');
var noSnaps = document.getElementById('restore-no-snapshots');
var typeInfo = document.getElementById('restore-type-info');
document.getElementById('restore-confirm-cb').checked = false;
document.getElementById('restore-btn').disabled = true;
noSnaps.style.display = 'none';
typeInfo.style.display = 'none';
if (!appName) {
snapSel.innerHTML = '<option value="">— Válassz alkalmazást —</option>';
return;
}
var opt = sel.options[sel.selectedIndex];
var hasHDD = opt.getAttribute('data-has-hdd') === 'true';
var hasDB = opt.getAttribute('data-has-db') === 'true';
var hasVolumes = opt.getAttribute('data-has-volumes') === 'true';
if (hasHDD || hasVolumes) {
typeInfo.innerHTML = 'Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.';
typeInfo.className = 'restore-info';
} else if (hasDB) {
typeInfo.innerHTML = 'Adatbázis és konfiguráció visszaállítása — az alkalmazásnak nincs külön felhasználói adata.';
typeInfo.className = 'restore-info restore-info-partial';
} else {
typeInfo.innerHTML = 'Csak konfiguráció visszaállítása (compose fájlok, beállítások).';
typeInfo.className = 'restore-info restore-info-partial';
}
typeInfo.style.display = 'block';
snapSel.innerHTML = '<option value="">— Betöltés... —</option>';
fetch('/api/backup/snapshots?stack=' + encodeURIComponent(appName))
.then(function(r) { return r.json(); })
.then(function(data) {
snapSel.innerHTML = '<option value="">— Válassz —</option>';
if (data.ok && data.data && data.data.length > 0) {
var tier1 = data.data.filter(function(s) { return s.tier !== 2; });
var tier2 = data.data.filter(function(s) { return s.tier === 2; });
if (tier1.length > 0) {
var grp1 = document.createElement('optgroup');
grp1.label = '1. szint — Helyi mentés (ajánlott)';
tier1.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp1.appendChild(o);
});
snapSel.appendChild(grp1);
}
if (tier2.length > 0) {
var grp2 = document.createElement('optgroup');
grp2.label = '2. szint — Másodlagos másolat';
tier2.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp2.appendChild(o);
});
snapSel.appendChild(grp2);
}
} else {
snapSel.innerHTML = '<option value="">— Nincs elérhető mentés —</option>';
noSnaps.style.display = 'block';
}
});
}
function onRestoreConfirmChange() {
var cb = document.getElementById('restore-confirm-cb');
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
document.getElementById('restore-btn').disabled = !(cb.checked && app && snap);
}
function submitRestore() {
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
if (!app || !snap) return;
var btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = 'Visszaállítás folyamatban...';
var form = document.createElement('form');
form.method = 'POST';
form.action = '/backup/restore';
var fc = document.createElement('input');
fc.type = 'hidden'; fc.name = '_csrf';
fc.value = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
form.appendChild(fc);
var f1 = document.createElement('input');
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
form.appendChild(f1);
var f2 = document.createElement('input');
f2.type = 'hidden'; f2.name = 'snapshot_id'; f2.value = snap;
form.appendChild(f2);
document.body.appendChild(form);
form.submit();
}
</script>
</main>
<script>
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
async function checkBeforeDeploy(e, name) {
try {
var resp = await fetch('/api/stacks/' + name);
var data = await resp.json();
if (data.ok && data.data && data.data.deployed) {
e.preventDefault();
showAlert('Ez az alkalmazás már telepítve van.');
window.location.reload();
return false;
}
} catch(err) {}
return true;
}
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(event, name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
if (action === 'update') {
followUpdate(name, btn);
return;
}
window.location.reload();
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
function followUpdate(name, btn) {
var started = Date.now();
var timer = setInterval(async function() {
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
try {
var r = await fetch('/api/stacks/' + name);
var d = await r.json();
if (!d.ok || !d.data) return;
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
} catch (e) {}
}, 3000);
}
async function deleteOrphanStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'delete-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
document.body.appendChild(modal);
try {
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
var data = await resp.json();
var hddInfo = '';
var checkboxHTML = '';
if (data.ok && data.data && data.data.has_hdd_data) {
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
data.data.hdd_paths.forEach(function(p) {
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
hddInfo += '</div>';
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
}
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
var udPaths = '';
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
hddInfo + checkboxHTML +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
'</div>';
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
}
}
function closeDeleteModal() {
var modal = document.getElementById('delete-modal');
if (modal) modal.remove();
}
async function confirmDelete(name) {
var btn = document.getElementById('confirm-delete-btn');
var checkbox = document.getElementById('delete-hdd-check');
var removeHDD = checkbox ? checkbox.checked : false;
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('delete-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen törölve!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Törlés';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Törlés';
}
}
async function removeStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'remove-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
document.body.appendChild(modal);
try {
var [hddResp, backupResp] = await Promise.all([
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
]);
var sections = '';
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
if (keepOnly) {
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
}
sections += '<div class="modal-section">' +
'<strong>Mindig törlődik:</strong>' +
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
'<li>Telepítési konfiguráció (app.yaml)</li>' +
'<li>Másodlagos mentés ütemezése</li>' +
'</ul></div>';
var hddCheckbox = '';
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
var hddPaths = '';
hddResp.data.hdd_paths.forEach(function(p) {
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
sections += '<div class="modal-section">' +
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
'</div></div>';
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
}
var backupCheckbox = '';
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
var bkPaths = '';
backupResp.data.backup_paths.forEach(function(p) {
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
});
if (bkPaths) {
sections += '<div class="modal-section">' +
'<strong>Mentési adatok:</strong>' + bkPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
'</div></div>';
}
}
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
var udPaths = '';
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
sections +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
'</div>' + hddCheckbox;
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
}
}
function closeRemoveModal() {
var modal = document.getElementById('remove-modal');
if (modal) modal.remove();
}
async function confirmRemoveStack(name) {
var btn = document.getElementById('confirm-remove-btn');
var hddCheck = document.getElementById('remove-hdd-check');
var backupCheck = document.getElementById('remove-backup-check');
var removeHDD = hddCheck ? hddCheck.checked : false;
var removeBackups = backupCheck ? backupCheck.checked : false;
btn.disabled = true;
btn.textContent = 'Eltávolítás folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('remove-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen eltávolítva!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
}
</script>
</body>
</html>
@@ -214,8 +214,7 @@
</div>
<div class="alert alert-warning" style="margin-top:0.5rem;margin-bottom:0">
Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát.
Normál használat mellett ez nem okoz problémát.
Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást.
</div>