Files
deploy-felhom-compose/controller/internal/web/storage_handlers.go
T
admin 6b7ca566df fix: clean up stale raw mounts before scanning in attach wizard
After an interrupted attach wizard, the raw mount stays behind,
causing the device to appear as "mounted" in scan results. Now the
scan button calls cancel first, which unmounts any stale raw mounts
that have no bind mount in fstab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:30:32 +01:00

1094 lines
30 KiB
Go

package web
import (
"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" or "migrate"
done bool
fmtProg []storage.FormatProgress
migProg []storage.MigrateProgress
}
// 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
}
}
// 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.render(w, "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
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)
default:
http.NotFound(w, r)
}
}
// storageScanAPIHandler handles POST /api/storage/scan.
func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) {
result, err := storage.ScanDisks()
if err != nil {
s.logger.Printf("[ERROR] storageScan: %v", err)
jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError)
return
}
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 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] 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,
}
// 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(); scanErr == nil {
for _, disk := range scanResult.AvailableDisks {
if disk.Path == req.DevicePath && len(disk.Partitions) == 1 && disk.Partitions[0].FSType == "" {
s.logger.Printf("[INFO] 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] 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] Failed to register storage path after init: %v", err)
} else {
s.logger.Printf("[INFO] 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.render(w, "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"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
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] 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,
}
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)
}
go func() {
progressCh := make(chan storage.MigrateProgress, 64)
go func() {
for p := range progressCh {
job.appendMigProg(p)
}
}()
if err := storage.MigrateAppData(migrReq, stopFn, startFn, updateFn, progressCh); err != nil {
s.logger.Printf("[ERROR] Migration failed: stack=%s: %v", req.StackName, err)
} else {
s.logger.Printf("[INFO] 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
return stacks.SaveAppConfig(stackDir, appCfg)
}
// 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 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] 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] 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] 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.render(w, "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
}
// Clean up any previous raw mount first
s.diskJobMu.Lock()
if s.activeRawMount != "" {
_ = storage.CleanupRawMount(s.activeRawMount)
s.activeRawMount = ""
}
s.diskJobMu.Unlock()
rawPath, err := storage.MountRaw(req.DevicePath)
if err != nil {
s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.diskJobMu.Lock()
s.activeRawMount = rawPath
s.diskJobMu.Unlock()
s.logger.Printf("[INFO] 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 !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] 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 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] 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,
}
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] 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] Failed to register storage path after attach: %v", err)
} else {
s.logger.Printf("[INFO] 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] Failed to cleanup raw mount %s: %v", rawMount, err)
} else {
s.logger.Printf("[INFO] 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})
}