package web import ( "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/storage" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // activeDiskJob tracks an in-progress disk operation (format or migrate). type activeDiskJob struct { mu sync.RWMutex jobType string // "format", "migrate", or "migrate-drive" done bool fmtProg []storage.FormatProgress migProg []storage.MigrateProgress driveMigProg []storage.DriveMigrateProgress } // DeployStorageInfo holds storage info for the deploy page (already-deployed apps). type DeployStorageInfo struct { Path string Label string DataSizeHuman string FreeHuman string FreePercent float64 } // appendFmtProg adds a format progress update to the job. func (j *activeDiskJob) appendFmtProg(p storage.FormatProgress) { j.mu.Lock() defer j.mu.Unlock() j.fmtProg = append(j.fmtProg, p) if p.Step == "done" || p.Step == "error" { j.done = true } } // appendMigProg adds a migration progress update to the job. func (j *activeDiskJob) appendMigProg(p storage.MigrateProgress) { j.mu.Lock() defer j.mu.Unlock() j.migProg = append(j.migProg, p) if p.Step == "done" || p.Step == "error" { j.done = true } } // appendDriveMigProg adds a drive migration progress update to the job. func (j *activeDiskJob) appendDriveMigProg(p storage.DriveMigrateProgress) { j.mu.Lock() defer j.mu.Unlock() j.driveMigProg = append(j.driveMigProg, p) if p.Step == "done" || p.Step == "error" { j.done = true } } // lastDriveMigProg returns the most recent drive migration progress. func (j *activeDiskJob) lastDriveMigProg() (storage.DriveMigrateProgress, bool) { j.mu.RLock() defer j.mu.RUnlock() if len(j.driveMigProg) == 0 { return storage.DriveMigrateProgress{}, false } return j.driveMigProg[len(j.driveMigProg)-1], true } // lastFmtProg returns the most recent format progress snapshot. func (j *activeDiskJob) lastFmtProg() (storage.FormatProgress, bool) { j.mu.RLock() defer j.mu.RUnlock() if len(j.fmtProg) == 0 { return storage.FormatProgress{}, false } return j.fmtProg[len(j.fmtProg)-1], true } // lastMigProg returns the most recent migration progress snapshot. func (j *activeDiskJob) lastMigProg() (storage.MigrateProgress, bool) { j.mu.RLock() defer j.mu.RUnlock() if len(j.migProg) == 0 { return storage.MigrateProgress{}, false } return j.migProg[len(j.migProg)-1], true } // isDone returns true if the job has finished. func (j *activeDiskJob) isDone() bool { j.mu.RLock() defer j.mu.RUnlock() return j.done } // jsonResponse writes a JSON response. func jsonResponse(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(v) } // jsonError writes a JSON error response. func jsonError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": msg, }) } // tryStartDiskJob attempts to start a new disk operation job. // Returns false if another job is already active. func (s *Server) tryStartDiskJob(jobType string) (*activeDiskJob, bool) { s.diskJobMu.Lock() defer s.diskJobMu.Unlock() if s.diskJob != nil && !s.diskJob.isDone() { return nil, false } job := &activeDiskJob{jobType: jobType} s.diskJob = job return job, true } // currentDiskJob returns the current disk job (may be nil or done). func (s *Server) currentDiskJob() *activeDiskJob { s.diskJobMu.Lock() defer s.diskJobMu.Unlock() return s.diskJob } // --- Storage Init Wizard --- // storageInitHandler serves the storage init wizard page. func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("settings", "Meghajtó inicializálása") s.executeTemplate(w, r, "storage_init", data) } // storageAPIHandler is the main handler for /api/storage/* routes. func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageAPI: %s %s from %s", r.Method, path, r.RemoteAddr) } switch { case path == "/api/storage/scan" && r.Method == http.MethodPost: s.storageScanAPIHandler(w, r) case path == "/api/storage/init" && r.Method == http.MethodPost: s.storageInitAPIHandler(w, r) case path == "/api/storage/init/status" && r.Method == http.MethodGet: s.storageInitStatusAPIHandler(w, r) case path == "/api/storage/migrate" && r.Method == http.MethodPost: s.storageMigrateAPIHandler(w, r) case path == "/api/storage/migrate/status" && r.Method == http.MethodGet: s.storageMigrateStatusAPIHandler(w, r) case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost: s.staleDataCleanupHandler(w, r) case path == "/api/storage/attach/mount-raw" && r.Method == http.MethodPost: s.storageAttachMountRawHandler(w, r) case path == "/api/storage/attach/browse" && r.Method == http.MethodGet: s.storageAttachBrowseHandler(w, r) case path == "/api/storage/attach/mkdir" && r.Method == http.MethodPost: s.storageAttachMkdirHandler(w, r) case path == "/api/storage/attach" && r.Method == http.MethodPost: s.storageAttachAPIHandler(w, r) case path == "/api/storage/attach/status" && r.Method == http.MethodGet: s.storageAttachStatusAPIHandler(w, r) case path == "/api/storage/attach/cancel" && r.Method == http.MethodPost: s.storageAttachCancelHandler(w, r) case path == "/api/storage/disconnect" && r.Method == http.MethodPost: s.storageDisconnectHandler(w, r) case path == "/api/storage/reconnect" && r.Method == http.MethodPost: s.storageReconnectHandler(w, r) case path == "/api/storage/restart-apps" && r.Method == http.MethodPost: s.storageRestartAppsHandler(w, r) case path == "/api/storage/status" && r.Method == http.MethodGet: s.storageStatusHandler(w, r) case path == "/api/storage/migrate-drive" && r.Method == http.MethodPost: s.driveMigrateAPIHandler(w, r) case path == "/api/storage/migrate-drive/status" && r.Method == http.MethodGet: s.driveMigrateStatusHandler(w, r) case path == "/api/storage/decommission/remove" && r.Method == http.MethodPost: s.decommissionRemoveHandler(w, r) default: http.NotFound(w, r) } } // storageScanAPIHandler handles POST /api/storage/scan. func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) { if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageScan: scanning disks") } result, err := storage.ScanDisks(s.logger, s.cfg.Logging.Level == "debug") if err != nil { s.logger.Printf("[ERROR] [web] storageScan: %v", err) jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks)) } jsonResponse(w, map[string]interface{}{ "ok": true, "available": result.AvailableDisks, "system": result.SystemDisks, "available_count": len(result.AvailableDisks), }) } // storageInitAPIHandler handles POST /api/storage/init — starts format+mount job. func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) { var req struct { DevicePath string `json:"device_path"` MountName string `json:"mount_name"` Label string `json:"label"` CreatePartition bool `json:"create_partition"` SetDefault bool `json:"set_default"` Confirm string `json:"confirm"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageInit: device=%s mountName=%s label=%q partition=%v default=%v from %s", req.DevicePath, req.MountName, req.Label, req.CreatePartition, req.SetDefault, r.RemoteAddr) } if req.Confirm != "FORMÁZÁS" { jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest) return } if req.DevicePath == "" || req.MountName == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } job, ok := s.tryStartDiskJob("format") if !ok { jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict) return } s.logger.Printf("[INFO] [web] Storage init started: device=%s mountName=%s by %s", req.DevicePath, req.MountName, r.RemoteAddr) fmtReq := storage.FormatRequest{ DevicePath: req.DevicePath, MountName: req.MountName, Label: req.Label, CreatePartition: req.CreatePartition, SetDefault: req.SetDefault, Logger: s.logger, Debug: s.cfg.Logging.Level == "debug", } // Smart partition: if disk has exactly 1 partition with no filesystem, // skip destructive repartitioning and format the existing partition directly. if fmtReq.CreatePartition { if scanResult, scanErr := storage.ScanDisks(s.logger, s.cfg.Logging.Level == "debug"); scanErr == nil { for _, disk := range scanResult.AvailableDisks { if disk.Path == req.DevicePath && len(disk.Partitions) == 1 && disk.Partitions[0].FSType == "" { s.logger.Printf("[INFO] [web] Disk %s has 1 empty partition (%s) — skipping repartition", req.DevicePath, disk.Partitions[0].Path) fmtReq.DevicePath = disk.Partitions[0].Path fmtReq.CreatePartition = false break } } } } go func() { progressCh := make(chan storage.FormatProgress, 32) // Collect progress go func() { for p := range progressCh { job.appendFmtProg(p) } }() mountPath, err := storage.FormatAndMount(fmtReq, progressCh) close(progressCh) if err != nil { s.logger.Printf("[ERROR] [web] Storage init failed: %v", err) return } // Auto-register the new storage path label := req.Label if label == "" { label = settings.InferStorageLabel(mountPath) } sp := settings.StoragePath{ Path: mountPath, Label: label, IsDefault: req.SetDefault, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } if err := s.settings.AddStoragePath(sp); err != nil { s.logger.Printf("[WARN] [web] Failed to register storage path after init: %v", err) } else { s.logger.Printf("[INFO] [web] Storage path registered: %s (%s)", mountPath, label) // Sync FileBrowser mounts with new storage path s.SyncFileBrowserMounts() } }() jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Inicializálás elindítva", }) } // storageInitStatusAPIHandler handles GET /api/storage/init/status. func (s *Server) storageInitStatusAPIHandler(w http.ResponseWriter, r *http.Request) { job := s.currentDiskJob() if job == nil || job.jobType != "format" { jsonResponse(w, map[string]interface{}{ "ok": true, "active": false, }) return } p, ok := job.lastFmtProg() if !ok { jsonResponse(w, map[string]interface{}{ "ok": true, "active": true, "step": "starting", "msg": "Inicializálás elindult...", "pct": 0, }) return } jsonResponse(w, map[string]interface{}{ "ok": true, "active": !job.isDone(), "step": p.Step, "msg": p.Message, "pct": p.Percent, "error": p.Error, "done": job.isDone(), }) } // --- Migration --- // migratePageHandler serves the migration page for an app. func (s *Server) migratePageHandler(w http.ResponseWriter, r *http.Request, stackName string) { stack, ok := s.stackMgr.GetStack(stackName) if !ok { http.NotFound(w, r) return } appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil || !appCfg.Deployed { http.NotFound(w, r) return } currentHDDPath := appCfg.Env["HDD_PATH"] if currentHDDPath == "" { http.Error(w, "Ez az alkalmazás nem tárol adatot külső meghajtón.", http.StatusBadRequest) return } // Other storage paths (exclude current) var otherPaths []DeployStoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentHDDPath { continue } dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } otherPaths = append(otherPaths, dp) } if len(otherPaths) == 0 { http.Error(w, "Nincs más elérhető tárhely az áthelyezéshez.", http.StatusBadRequest) return } // Current path label currentLabel := settings.InferStorageLabel(currentHDDPath) for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentHDDPath { currentLabel = sp.Label break } } // Estimate current data size mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, currentHDDPath) var totalSizeHuman string if len(mounts) > 0 { var total int64 for _, m := range mounts { total += dirSizeInt64(m) } totalSizeHuman = dirSizeBytesHuman(total) } data := s.baseData("stacks", stack.Meta.DisplayName+" — Adatáthelyezés") data["Stack"] = stack data["Meta"] = stack.Meta data["CurrentHDDPath"] = currentHDDPath data["CurrentLabel"] = currentLabel data["OtherPaths"] = otherPaths data["DataSizeHuman"] = totalSizeHuman s.executeTemplate(w, r, "migrate", data) } // storageMigrateAPIHandler handles POST /api/storage/migrate — starts migration job. func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request) { var req struct { StackName string `json:"stack_name"` TargetPath string `json:"target_path"` AutoDeleteStale *bool `json:"auto_delete_stale"` // default true } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageMigrate: stack=%s target=%s from %s", req.StackName, req.TargetPath, r.RemoteAddr) } if req.StackName == "" || req.TargetPath == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } stack, ok := s.stackMgr.GetStack(req.StackName) if !ok { jsonError(w, "Alkalmazás nem található: "+req.StackName, http.StatusNotFound) return } appCfg := s.stackMgr.LoadAppConfigByName(req.StackName) if appCfg == nil || !appCfg.Deployed { jsonError(w, "Az alkalmazás nincs telepítve", http.StatusBadRequest) return } currentHDDPath := appCfg.Env["HDD_PATH"] if currentHDDPath == "" { jsonError(w, "Az alkalmazásnak nincs HDD_PATH beállítva", http.StatusBadRequest) return } if currentHDDPath == req.TargetPath { jsonError(w, "A forrás és a cél tárhely azonos", http.StatusBadRequest) return } // C8: Validate TargetPath against registered storage paths to prevent path traversal. registeredPaths := s.settings.GetStoragePaths() validTarget := false for _, sp := range registeredPaths { if req.TargetPath == sp.Path { validTarget = true break } } if !validTarget { jsonError(w, "Érvénytelen célútvonal: nem regisztrált adattároló", http.StatusBadRequest) return } mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, currentHDDPath) if len(mounts) == 0 { jsonError(w, "Az alkalmazáshoz nem találhatók HDD csatlakozások", http.StatusBadRequest) return } job, ok := s.tryStartDiskJob("migrate") if !ok { jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict) return } s.logger.Printf("[INFO] [web] Migration started: stack=%s from=%s to=%s by %s", req.StackName, currentHDDPath, req.TargetPath, r.RemoteAddr) migrReq := storage.MigrateRequest{ StackName: req.StackName, DisplayName: stack.Meta.DisplayName, CurrentHDDPath: currentHDDPath, TargetPath: req.TargetPath, HDDMounts: mounts, Logger: s.logger, Debug: s.cfg.Logging.Level == "debug", } stopFn := func(name string) error { return s.stackMgr.StopStack(name) } startFn := func(name string) error { return s.stackMgr.StartStack(name) } updateFn := func(name, newPath string) error { return s.updateStackHDDPath(name, newPath) } autoDelete := true if req.AutoDeleteStale != nil { autoDelete = *req.AutoDeleteStale } orch := &storage.MigrateOrchestrator{ Sett: s.settings, BackupTrigger: s.backupMgr, Logger: s.logger, } opts := storage.MigrateOptions{ AutoDeleteStale: autoDelete, } go func() { progressCh := make(chan storage.MigrateProgress, 64) go func() { for p := range progressCh { job.appendMigProg(p) } }() if err := orch.RunEnhancedMigration(migrReq, stopFn, startFn, updateFn, opts, progressCh); err != nil { s.logger.Printf("[ERROR] [web] Migration failed: stack=%s: %v", req.StackName, err) } else { s.logger.Printf("[INFO] [web] Migration complete: stack=%s → %s", req.StackName, req.TargetPath) // Sync FileBrowser mounts (storage paths may now have new app data) go s.SyncFileBrowserMounts() } close(progressCh) }() jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Áthelyezés elindítva", }) } // storageMigrateStatusAPIHandler handles GET /api/storage/migrate/status. func (s *Server) storageMigrateStatusAPIHandler(w http.ResponseWriter, r *http.Request) { job := s.currentDiskJob() if job == nil || job.jobType != "migrate" { jsonResponse(w, map[string]interface{}{ "ok": true, "active": false, }) return } p, ok := job.lastMigProg() if !ok { jsonResponse(w, map[string]interface{}{ "ok": true, "active": true, "step": "starting", "msg": "Áthelyezés elindult...", "pct": 0, }) return } jsonResponse(w, map[string]interface{}{ "ok": true, "active": !job.isDone(), "step": p.Step, "msg": p.Message, "pct": p.Percent, "error": p.Error, "done": job.isDone(), "bytes_copied": p.BytesCopied, "bytes_total": p.BytesTotal, "elapsed_sec": p.ElapsedSeconds, }) } // updateStackHDDPath updates the HDD_PATH in a stack's app.yaml. func (s *Server) updateStackHDDPath(stackName, newPath string) error { stack, ok := s.stackMgr.GetStack(stackName) if !ok { return fmt.Errorf("stack not found: %s", stackName) } stackDir := filepath.Dir(stack.ComposePath) appCfg := stacks.LoadAppConfig(stackDir) if appCfg == nil { return fmt.Errorf("app.yaml not found for stack: %s", stackName) } appCfg.Env["HDD_PATH"] = newPath meta := stacks.LoadMetadata(stackDir) return stacks.SaveAppConfig(stackDir, appCfg, s.encKey, stacks.SensitiveEnvVars(&meta)) } // storageInfoForStack returns deploy storage info for a deployed stack. func (s *Server) storageInfoForStack(stackName string) *DeployStorageInfo { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return nil } hddPath := appCfg.Env["HDD_PATH"] if hddPath == "" { return nil } info := &DeployStorageInfo{Path: hddPath} // Find label for _, sp := range s.settings.GetStoragePaths() { if sp.Path == hddPath { info.Label = sp.Label break } } if info.Label == "" { info.Label = settings.InferStorageLabel(hddPath) } // Data size stack, ok := s.stackMgr.GetStack(stackName) if ok { mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, hddPath) var total int64 for _, m := range mounts { total += dirSizeInt64(m) } if total > 0 { info.DataSizeHuman = dirSizeBytesHuman(total) } } // Free space if di := system.GetDiskUsage(hddPath); di != nil { info.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { info.FreePercent = di.AvailGB / di.TotalGB * 100 } } return info } // dirSizeInt64 returns total bytes in a directory. func dirSizeInt64(path string) int64 { var total int64 filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } total += info.Size() return nil }) return total } // dirSizeBytesHuman formats bytes as human-readable. func dirSizeBytesHuman(b int64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case b >= GB: return fmt.Sprintf("%.1f GB", float64(b)/float64(GB)) case b >= MB: return fmt.Sprintf("%.0f MB", float64(b)/float64(MB)) case b >= KB: return fmt.Sprintf("%.0f KB", float64(b)/float64(KB)) default: return fmt.Sprintf("%d B", b) } } // otherStoragePathsForStack returns storage paths excluding the one the app is on. func (s *Server) otherStoragePathsForStack(stackName string) []settings.StoragePath { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return nil } currentHDDPath := appCfg.Env["HDD_PATH"] var others []settings.StoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path != currentHDDPath { others = append(others, sp) } } return others } // storageSectionLabel returns the label for a given path. func (s *Server) storageLabelForPath(path string) string { for _, sp := range s.settings.GetStoragePaths() { if sp.Path == path { return sp.Label } } return strings.TrimPrefix(path, "/mnt/") } // StaleStorageData describes leftover data on a non-active storage path. type StaleStorageData struct { Path string // e.g., "/mnt/hdd_placeholder" Label string // e.g., "Külső tárhely (hdd_placeholder)" Mounts []string // host-side paths with data SizeHuman string // e.g., "48 MB" SizeBytes int64 } // findStaleStorageData detects leftover app data on non-active storage paths. // This happens after migration: the old data stays on the previous storage path. func (s *Server) findStaleStorageData(stackName string) []StaleStorageData { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return nil } currentHDDPath := appCfg.Env["HDD_PATH"] if currentHDDPath == "" { return nil } stack, ok := s.stackMgr.GetStack(stackName) if !ok { return nil } var result []StaleStorageData // Check all registered storage paths except the current one for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentHDDPath { continue } // Use ParseComposeHDDMounts to find what dirs WOULD exist on this path mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, sp.Path) if len(mounts) == 0 { continue } // Check which mounts actually have data var existingMounts []string var totalSize int64 for _, m := range mounts { info, err := os.Stat(m) if err != nil || !info.IsDir() { continue } size := dirSizeInt64(m) if size > 0 { existingMounts = append(existingMounts, m) totalSize += size } } if len(existingMounts) == 0 { continue } label := sp.Label if label == "" { label = settings.InferStorageLabel(sp.Path) } result = append(result, StaleStorageData{ Path: sp.Path, Label: label, Mounts: existingMounts, SizeHuman: dirSizeBytesHuman(totalSize), SizeBytes: totalSize, }) } return result } // staleDataCleanupHandler handles POST /api/storage/stale-cleanup. // Deletes leftover app data from a previous storage path after migration. func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request) { var req struct { StackName string `json:"stack_name"` StalePath string `json:"stale_path"` // the old storage root, e.g., "/mnt/hdd_placeholder" } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] staleDataCleanup: stack=%s stalePath=%s from %s", req.StackName, req.StalePath, r.RemoteAddr) } if req.StackName == "" || req.StalePath == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } // Verify the app exists and is deployed stack, ok := s.stackMgr.GetStack(req.StackName) if !ok { jsonError(w, "Alkalmazás nem található: "+req.StackName, http.StatusNotFound) return } appCfg := s.stackMgr.LoadAppConfigByName(req.StackName) if appCfg == nil || !appCfg.Deployed { jsonError(w, "Az alkalmazás nincs telepítve", http.StatusBadRequest) return } currentHDDPath := appCfg.Env["HDD_PATH"] if currentHDDPath == "" { jsonError(w, "Az alkalmazásnak nincs HDD_PATH beállítva", http.StatusBadRequest) return } // SAFETY: StalePath must NOT be the current HDD_PATH if req.StalePath == currentHDDPath { jsonError(w, "Az aktív tárhely adatai nem törölhetők! Ez az alkalmazás aktuális adattárolója.", http.StatusForbidden) return } // SAFETY: StalePath must be a registered storage path found := false for _, sp := range s.settings.GetStoragePaths() { if sp.Path == req.StalePath { found = true break } } if !found { jsonError(w, "A megadott útvonal nem regisztrált adattároló", http.StatusBadRequest) return } // Find mounts to delete mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, req.StalePath) if len(mounts) == 0 { jsonError(w, "Nem találhatók törlendő adatok", http.StatusNotFound) return } // Protected paths check protected := stacks.ProtectedHDDPaths(req.StalePath) var deleted []string var errors []string var totalFreed int64 for _, mountPath := range mounts { cleanPath := filepath.Clean(mountPath) // Safety: never delete protected top-level dirs if protected != nil && protected[cleanPath] { s.logger.Printf("[WARN] [web] Refusing to delete protected HDD path: %s", cleanPath) errors = append(errors, fmt.Sprintf("Védett útvonal, nem törölhető: %s", cleanPath)) continue } // Verify it actually exists and has data info, err := os.Stat(cleanPath) if err != nil || !info.IsDir() { continue } size := dirSizeInt64(cleanPath) if err := os.RemoveAll(cleanPath); err != nil { s.logger.Printf("[ERROR] [web] Failed to remove stale data %s: %v", cleanPath, err) errors = append(errors, fmt.Sprintf("Törlés sikertelen: %s — %v", cleanPath, err)) } else { s.logger.Printf("[INFO] [web] Removed stale data: %s (%s) for stack %s", cleanPath, dirSizeBytesHuman(size), req.StackName) deleted = append(deleted, cleanPath) totalFreed += size } } if len(deleted) == 0 && len(errors) > 0 { jsonError(w, "Törlés sikertelen: "+strings.Join(errors, "; "), http.StatusInternalServerError) return } jsonResponse(w, map[string]interface{}{ "ok": true, "deleted": deleted, "freed_human": dirSizeBytesHuman(totalFreed), "errors": errors, }) } // --- Attach Existing Drive Wizard --- // storageAttachHandler serves the attach wizard page. func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("settings", "Meglévő meghajtó csatolása") s.executeTemplate(w, r, "storage_attach", data) } // storageAttachMountRawHandler handles POST /api/storage/attach/mount-raw. // Temporarily mounts a partition at a staging path for browsing. func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Request) { var req struct { DevicePath string `json:"device_path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.DevicePath == "" { jsonError(w, "Hiányzó eszközútvonal", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageAttachMountRaw: device=%s from %s", req.DevicePath, r.RemoteAddr) } // Hold lock across entire cleanup+mount+set to prevent races s.diskJobMu.Lock() if s.activeRawMount != "" { _ = storage.CleanupRawMount(s.activeRawMount) s.activeRawMount = "" } rawPath, err := storage.MountRaw(req.DevicePath) if err != nil { s.diskJobMu.Unlock() s.logger.Printf("[ERROR] [web] storageAttachMountRaw: %v", err) jsonError(w, err.Error(), http.StatusInternalServerError) return } s.activeRawMount = rawPath s.diskJobMu.Unlock() s.logger.Printf("[INFO] [web] Raw mount for attach: %s → %s", req.DevicePath, rawPath) jsonResponse(w, map[string]interface{}{ "ok": true, "raw_path": rawPath, }) } // storageAttachBrowseHandler handles GET /api/storage/attach/browse?path=... // Lists directories at the given path within the raw mount staging area. func (s *Server) storageAttachBrowseHandler(w http.ResponseWriter, r *http.Request) { browsePath := r.URL.Query().Get("path") if browsePath == "" { jsonError(w, "Hiányzó útvonal paraméter", http.StatusBadRequest) return } // Security: validate path is under the raw mount staging area cleanPath := filepath.Clean(browsePath) if cleanPath != storage.RawMountBase && !strings.HasPrefix(cleanPath, storage.RawMountBase+"/") { jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest) return } dirs, err := storage.ListDirectories(cleanPath) if err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } jsonResponse(w, map[string]interface{}{ "ok": true, "path": cleanPath, "dirs": dirs, }) } // storageAttachMkdirHandler handles POST /api/storage/attach/mkdir. // Creates a new directory in the raw mount staging area. func (s *Server) storageAttachMkdirHandler(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.Path == "" || req.Name == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } // Security: validate path is under the raw mount staging area cleanPath := filepath.Clean(req.Path) if !strings.HasPrefix(cleanPath, storage.RawMountBase) { jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest) return } createdPath, err := storage.CreateDirectory(cleanPath, req.Name) if err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } s.logger.Printf("[INFO] [web] Created directory for attach: %s", createdPath) jsonResponse(w, map[string]interface{}{ "ok": true, "created_path": createdPath, }) } // storageAttachAPIHandler handles POST /api/storage/attach — starts the final attach job. func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request) { var req struct { DevicePath string `json:"device_path"` MountName string `json:"mount_name"` SubPath string `json:"sub_path"` Label string `json:"label"` SetDefault bool `json:"set_default"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageAttach: device=%s mountName=%s subPath=%s label=%q default=%v from %s", req.DevicePath, req.MountName, req.SubPath, req.Label, req.SetDefault, r.RemoteAddr) } if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } job, ok := s.tryStartDiskJob("attach") if !ok { jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict) return } s.logger.Printf("[INFO] [web] Storage attach started: device=%s mountName=%s subPath=%s by %s", req.DevicePath, req.MountName, req.SubPath, r.RemoteAddr) attachReq := storage.AttachRequest{ DevicePath: req.DevicePath, MountName: req.MountName, SubPath: req.SubPath, Label: req.Label, SetDefault: req.SetDefault, Logger: s.logger, Debug: s.cfg.Logging.Level == "debug", } go func() { progressCh := make(chan storage.FormatProgress, 32) go func() { for p := range progressCh { job.appendFmtProg(p) } }() mountPath, err := storage.FinalizeAttach(attachReq, progressCh) close(progressCh) if err != nil { s.logger.Printf("[ERROR] [web] Storage attach failed: %v", err) return } // Clear raw mount tracking (it's now permanent via fstab) s.diskJobMu.Lock() s.activeRawMount = "" s.diskJobMu.Unlock() // Auto-register the new storage path label := req.Label if label == "" { label = settings.InferStorageLabel(mountPath) } sp := settings.StoragePath{ Path: mountPath, Label: label, IsDefault: req.SetDefault, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } if err := s.settings.AddStoragePath(sp); err != nil { s.logger.Printf("[WARN] [web] Failed to register storage path after attach: %v", err) } else { s.logger.Printf("[INFO] [web] Storage path registered: %s (%s)", mountPath, label) s.SyncFileBrowserMounts() } }() jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Csatolás elindítva", }) } // storageAttachStatusAPIHandler handles GET /api/storage/attach/status. func (s *Server) storageAttachStatusAPIHandler(w http.ResponseWriter, r *http.Request) { job := s.currentDiskJob() if job == nil || job.jobType != "attach" { jsonResponse(w, map[string]interface{}{ "ok": true, "active": false, }) return } p, ok := job.lastFmtProg() if !ok { jsonResponse(w, map[string]interface{}{ "ok": true, "active": true, "step": "starting", "msg": "Csatolás elindult...", "pct": 0, }) return } jsonResponse(w, map[string]interface{}{ "ok": true, "active": !job.isDone(), "step": p.Step, "msg": p.Message, "pct": p.Percent, "error": p.Error, "done": job.isDone(), }) } // storageAttachCancelHandler handles POST /api/storage/attach/cancel. // Cleans up the temporary raw mount when the user cancels the wizard. // Also cleans up any stale raw mounts from interrupted previous sessions. func (s *Server) storageAttachCancelHandler(w http.ResponseWriter, r *http.Request) { s.diskJobMu.Lock() rawMount := s.activeRawMount s.activeRawMount = "" s.diskJobMu.Unlock() if rawMount != "" { if err := storage.CleanupRawMount(rawMount); err != nil { s.logger.Printf("[WARN] [web] Failed to cleanup raw mount %s: %v", rawMount, err) } else { s.logger.Printf("[INFO] [web] Cleaned up raw mount: %s", rawMount) } } // Also clean up any stale raw mounts from previous interrupted sessions storage.CleanupStaleRawMounts() jsonResponse(w, map[string]interface{}{"ok": true}) } // storageDisconnectHandler handles POST /api/storage/disconnect. // Performs a safe disconnect: stops affected apps, syncs, unmounts. func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.Path == "" { jsonError(w, "Hiányzó útvonal", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s from %s", req.Path, r.RemoteAddr) } if s.storageWatchdog == nil { jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable) return } // Check if USB device (only USB drives can be safely disconnected) fsInfo := system.GetFSInfo(req.Path) if fsInfo != nil && fsInfo.Device != "" && !system.IsUSBDevice(fsInfo.Device) { if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s device=%s is not USB, rejecting", req.Path, fsInfo.Device) } jsonError(w, "Csak USB meghajtó választható le biztonságosan", http.StatusBadRequest) return } stoppedStacks, err := s.storageWatchdog.SafeDisconnect(r.Context(), req.Path) if err != nil { s.logger.Printf("[ERROR] [web] Safe disconnect %s: %v", req.Path, err) jsonError(w, fmt.Sprintf("Leválasztás sikertelen: %v", err), http.StatusInternalServerError) return } s.logger.Printf("[INFO] [web] Disk disconnect completed: %s (stopped %d stacks)", req.Path, len(stoppedStacks)) if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s success, stopped %d stacks", req.Path, len(stoppedStacks)) } jsonResponse(w, map[string]interface{}{ "ok": true, "message": "A meghajtó biztonságosan eltávolítható.", "stopped_stacks": stoppedStacks, }) } // storageReconnectHandler handles POST /api/storage/reconnect. // Attempts to remount a disconnected drive. func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.Path == "" { jsonError(w, "Hiányzó útvonal", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s from %s", req.Path, r.RemoteAddr) } if s.storageWatchdog == nil { jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable) return } stoppedStacks, err := s.storageWatchdog.Reconnect(r.Context(), req.Path) if err != nil { s.logger.Printf("[ERROR] [web] Reconnect %s: %v", req.Path, err) jsonError(w, fmt.Sprintf("Csatlakoztatás sikertelen: %v", err), http.StatusInternalServerError) return } s.logger.Printf("[INFO] [web] Disk reconnect completed: %s", req.Path) if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s success, previously stopped stacks=%v", req.Path, stoppedStacks) } jsonResponse(w, map[string]interface{}{ "ok": true, "message": "Meghajtó sikeresen csatlakoztatva.", "stopped_stacks": stoppedStacks, }) } // storageRestartAppsHandler handles POST /api/storage/restart-apps. // Restarts apps that were auto-stopped due to a drive disconnect. func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if req.Path == "" { jsonError(w, "Hiányzó útvonal", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s from %s", req.Path, r.RemoteAddr) } if s.storageWatchdog == nil { jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable) return } // Validate drive is connected if s.settings.IsDisconnected(req.Path) { if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s is disconnected, rejecting", req.Path) } jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest) return } started, failed := s.storageWatchdog.RestartStoppedApps(req.Path) s.logger.Printf("[INFO] [web] Restart apps for %s: started=%d failed=%d", req.Path, len(started), len(failed)) if s.isDebug() { s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s started=%v failed=%v", req.Path, started, failed) } jsonResponse(w, map[string]interface{}{ "ok": true, "started": started, "failed": failed, }) } // storageStatusHandler handles GET /api/storage/status. // Returns status of all storage paths including connection state and USB detection. func (s *Server) storageStatusHandler(w http.ResponseWriter, r *http.Request) { paths := s.settings.GetStoragePaths() type pathStatus struct { Path string `json:"path"` Label string `json:"label"` Connected bool `json:"connected"` IsUSB bool `json:"is_usb"` DisconnectedAt string `json:"disconnected_at"` StoppedStacks []string `json:"stopped_stacks"` } result := make([]pathStatus, 0, len(paths)) for _, sp := range paths { ps := pathStatus{ Path: sp.Path, Label: sp.Label, Connected: !sp.Disconnected, DisconnectedAt: sp.DisconnectedAt, StoppedStacks: sp.StoppedStacks, } if ps.StoppedStacks == nil { ps.StoppedStacks = []string{} } // Detect USB for connected drives if !sp.Disconnected { if fsInfo := system.GetFSInfo(sp.Path); fsInfo != nil && fsInfo.Device != "" { ps.IsUSB = system.IsUSBDevice(fsInfo.Device) } } result = append(result, ps) } jsonResponse(w, map[string]interface{}{ "ok": true, "data": result, }) } // migrateDrivePageHandler handles GET /settings/storage/migrate-drive. func (s *Server) migrateDrivePageHandler(w http.ResponseWriter, r *http.Request) { sourcePath := r.URL.Query().Get("source") if sourcePath == "" { http.Redirect(w, r, "/settings", http.StatusFound) return } data := s.baseData("settings", "Meghajtó kiváltása") data["SourcePath"] = sourcePath data["SourceLabel"] = s.settings.GetStorageLabel(sourcePath) data["SourceDiskInfo"] = system.GetDiskUsage(sourcePath) // Find apps on source drive type appInfo struct { Name string DisplayName string } var appsOnSource []appInfo for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } cfg := s.stackMgr.LoadAppConfigByName(stack.Name) if cfg != nil && cfg.Env["HDD_PATH"] == sourcePath { appsOnSource = append(appsOnSource, appInfo{ Name: stack.Name, DisplayName: stack.Meta.DisplayName, }) } } data["AppsOnSource"] = appsOnSource // Available destination paths type destPathInfo struct { Path string Label string FreeHuman string } var destPaths []destPathInfo for _, sp := range s.settings.GetConnectedPaths() { if sp.Path == sourcePath { continue } free := "" if di := system.GetDiskUsage(sp.Path); di != nil { free = fmt.Sprintf("%.1f GB", di.AvailGB) } destPaths = append(destPaths, destPathInfo{ Path: sp.Path, Label: sp.Label, FreeHuman: free, }) } data["DestPaths"] = destPaths // Tier 2 impact analysis var tier2Impact []string allCrossConfigs := s.settings.GetAllCrossDriveConfigs() for _, app := range appsOnSource { if cfg := allCrossConfigs[app.Name]; cfg != nil && cfg.Enabled { tier2Impact = append(tier2Impact, fmt.Sprintf("%s: 2. szintű mentés → %s (automatikusan átirányításra kerül)", app.DisplayName, s.settings.GetStorageLabel(cfg.DestinationPath))) } } data["Tier2Impact"] = tier2Impact s.executeTemplate(w, r, "migrate_drive", data) } // driveMigrateAPIHandler handles POST /api/storage/migrate-drive — starts drive migration. func (s *Server) driveMigrateAPIHandler(w http.ResponseWriter, r *http.Request) { if s.driveMigrator == nil { jsonError(w, "Meghajtó-migráció nem elérhető", http.StatusServiceUnavailable) return } var req struct { SourcePath string `json:"source_path"` DestPath string `json:"dest_path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } if s.isDebug() { s.logger.Printf("[DEBUG] [web] driveMigrate: source=%s dest=%s from %s", req.SourcePath, req.DestPath, r.RemoteAddr) } if req.SourcePath == "" || req.DestPath == "" { jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) return } // Validate against registered paths registeredPaths := s.settings.GetStoragePaths() validSrc, validDst := false, false for _, sp := range registeredPaths { if sp.Path == req.SourcePath { validSrc = true } if sp.Path == req.DestPath { validDst = true } } if !validSrc || !validDst { jsonError(w, "Érvénytelen útvonal: nem regisztrált adattároló", http.StatusBadRequest) return } job, ok := s.tryStartDiskJob("migrate-drive") if !ok { jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict) return } s.logger.Printf("[INFO] [web] Drive migration started: %s → %s by %s", req.SourcePath, req.DestPath, r.RemoteAddr) go func() { ctx := context.Background() progressCh := make(chan storage.DriveMigrateProgress, 64) go func() { for p := range progressCh { job.appendDriveMigProg(p) } }() migrReq := storage.DriveMigrateRequest{ SourcePath: req.SourcePath, DestPath: req.DestPath, } if err := s.driveMigrator.MigrateDrive(ctx, migrReq, progressCh); err != nil { s.logger.Printf("[ERROR] [web] Drive migration failed: %v", err) } else { s.logger.Printf("[INFO] [web] Drive migration complete: %s → %s", req.SourcePath, req.DestPath) go s.SyncFileBrowserMounts() } close(progressCh) }() jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Meghajtó kiváltás elindítva", }) } // driveMigrateStatusHandler handles GET /api/storage/migrate-drive/status. func (s *Server) driveMigrateStatusHandler(w http.ResponseWriter, r *http.Request) { job := s.currentDiskJob() if job == nil || job.jobType != "migrate-drive" { jsonResponse(w, map[string]interface{}{ "ok": true, "active": false, }) return } p, ok := job.lastDriveMigProg() if !ok { jsonResponse(w, map[string]interface{}{ "ok": true, "active": true, "step": "validating", "msg": "Meghajtó kiváltás elindult...", "pct": 0, }) return } jsonResponse(w, map[string]interface{}{ "ok": true, "active": !job.isDone(), "step": p.Step, "msg": p.Message, "detail": p.Detail, "pct": p.Percent, "error": p.Error, "done": job.isDone(), "elapsed_sec": p.ElapsedSeconds, }) } // decommissionRemoveHandler handles POST /api/storage/decommission/remove. func (s *Server) decommissionRemoveHandler(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"storage_path"` } // Support both form and JSON if r.Header.Get("Content-Type") == "application/json" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Érvénytelen kérés", http.StatusBadRequest) return } } else { _ = r.ParseForm() req.Path = r.FormValue("storage_path") } if s.isDebug() { s.logger.Printf("[DEBUG] [web] decommissionRemove: path=%s from %s", req.Path, r.RemoteAddr) } if req.Path == "" { jsonError(w, "Hiányzó útvonal", http.StatusBadRequest) return } if !s.settings.IsDecommissioned(req.Path) { jsonError(w, "A meghajtó nincs kiváltva állapotban", http.StatusBadRequest) return } if err := s.settings.RemoveStoragePath(req.Path); err != nil { s.logger.Printf("[ERROR] [web] Failed to remove decommissioned path %s: %v", req.Path, err) jsonError(w, "Eltávolítás sikertelen: "+err.Error(), http.StatusInternalServerError) return } s.logger.Printf("[INFO] [web] Decommissioned storage path removed: %s", req.Path) // For form submissions, redirect back to settings if r.Header.Get("Content-Type") != "application/json" { http.Redirect(w, r, "/settings?storage_msg=success&storage_detail=Meghajtó+eltávolítva+a+rendszerből.", http.StatusFound) return } jsonResponse(w, map[string]interface{}{ "ok": true, "msg": "Meghajtó eltávolítva a rendszerből", }) }