v0.15.0: Attach existing drive wizard (bind mount, no format)

New Settings wizard to attach drives with existing filesystems without
formatting. Mounts partition at staging path, lets user browse and pick
a subfolder, then bind-mounts it at /mnt/<name> with fstab entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 21:12:02 +01:00
parent e54e097d02
commit 98834dd7e8
8 changed files with 1311 additions and 0 deletions
+267
View File
@@ -142,6 +142,18 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -823,3 +835,258 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
"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.
func (s *Server) storageAttachCancelHandler(w http.ResponseWriter, r *http.Request) {
s.diskJobMu.Lock()
rawMount := s.activeRawMount
s.activeRawMount = ""
s.diskJobMu.Unlock()
if rawMount == "" {
jsonResponse(w, map[string]interface{}{"ok": true, "msg": "Nincs aktív raw mount"})
return
}
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)
}
jsonResponse(w, map[string]interface{}{"ok": true, "msg": "Raw mount eltávolítva"})
}