package web import ( "encoding/json" "net/http" "path/filepath" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" ) // ServeExportAPI dispatches /api/export/* endpoints. func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { // GET /api/export/estimate?stack=X&drive=Y case path == "/api/export/estimate" && r.Method == http.MethodGet: s.apiExportEstimate(w, r) // POST /api/export/start case path == "/api/export/start" && r.Method == http.MethodPost: s.apiExportStart(w, r) // GET /api/export/status case path == "/api/export/status" && r.Method == http.MethodGet: s.apiExportStatus(w, r) // GET /api/export/bundles — scan for .fab files on all drives case path == "/api/export/bundles" && r.Method == http.MethodGet: s.apiExportBundles(w, r) // POST /api/export/manifest — read manifest from a .fab file case path == "/api/export/manifest" && r.Method == http.MethodPost: s.apiExportManifest(w, r) // POST /api/export/import — start async import case path == "/api/export/import" && r.Method == http.MethodPost: s.apiImportStart(w, r) // GET /api/export/import/status — poll import progress case path == "/api/export/import/status" && r.Method == http.MethodGet: s.apiImportStatus(w, r) default: http.NotFound(w, r) } } // exportPageHandler renders the export form for a specific app. func (s *Server) exportPageHandler(w http.ResponseWriter, r *http.Request, name string) { if s.appExporter == nil { http.Error(w, "App export not available", http.StatusServiceUnavailable) return } stack, ok := s.stackMgr.GetStack(name) if !ok || !stack.Deployed { http.NotFound(w, r) return } // Build drive list for the dropdown drives := s.storageDriveList() data := map[string]interface{}{ "Stack": stack, "Drives": drives, } s.executeTemplate(w, r, "app_export", data) } // importPageHandler renders the import page (standalone, not tied to a stack). func (s *Server) importPageHandler(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { http.Error(w, "App import not available", http.StatusServiceUnavailable) return } drives := s.storageDriveList() bundles := appexport.ScanForBundles(drives) data := map[string]interface{}{ "Bundles": bundles, } s.executeTemplate(w, r, "app_import", data) } // apiExportEstimate returns size estimation for an export. func (s *Server) apiExportEstimate(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { jsonError(w, "App export not available", http.StatusServiceUnavailable) return } stackName := r.URL.Query().Get("stack") drive := r.URL.Query().Get("drive") s.logger.Printf("[DEBUG] [web] apiExportEstimate: stack=%q drive=%q", stackName, drive) if stackName == "" || drive == "" { jsonError(w, "Missing stack or drive parameter", http.StatusBadRequest) return } if !s.isValidDrivePath(drive) { s.logger.Printf("[DEBUG] [web] apiExportEstimate: invalid drive path %q", drive) jsonError(w, "Invalid drive path", http.StatusBadRequest) return } est, err := s.appExporter.EstimateExport(stackName, drive) if err != nil { s.logger.Printf("[ERROR] [web] Export estimate failed for %s: %v", stackName, err) s.logger.Printf("[DEBUG] [web] apiExportEstimate error: %v", err) jsonError(w, err.Error(), http.StatusInternalServerError) return } s.logger.Printf("[DEBUG] [web] apiExportEstimate: total=%s free=%s fits=%v", est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest) jsonResponse(w, map[string]interface{}{ "ok": true, "data": est, }) } // apiExportStart starts an async export. func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { jsonError(w, "App export not available", http.StatusServiceUnavailable) return } var req struct { StackName string `json:"stack_name"` DestDrive string `json:"dest_drive"` Password string `json:"password"` StopApp bool `json:"stop_app"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.logger.Printf("[DEBUG] [web] apiExportStart: invalid body: %v", err) jsonError(w, "Invalid request body", http.StatusBadRequest) return } s.logger.Printf("[DEBUG] [web] apiExportStart: stack=%q drive=%q encrypted=%v stopApp=%v", req.StackName, req.DestDrive, req.Password != "", req.StopApp) if req.StackName == "" || req.DestDrive == "" { jsonError(w, "Missing stack_name or dest_drive", http.StatusBadRequest) return } if !s.isValidDrivePath(req.DestDrive) { s.logger.Printf("[DEBUG] [web] apiExportStart: invalid drive path %q", req.DestDrive) jsonError(w, "Invalid drive path", http.StatusBadRequest) return } err := s.appExporter.StartExport(appexport.ExportRequest{ StackName: req.StackName, DestDrive: req.DestDrive, Password: req.Password, StopApp: req.StopApp, }) if err != nil { s.logger.Printf("[ERROR] [web] Export start failed for %s: %v", req.StackName, err) s.logger.Printf("[DEBUG] [web] apiExportStart error: %v", err) jsonError(w, err.Error(), http.StatusConflict) return } s.logger.Printf("[INFO] [web] Export started for %s to %s", req.StackName, req.DestDrive) jsonResponse(w, map[string]interface{}{"ok": true}) } // apiExportStatus returns current export/import job status. func (s *Server) apiExportStatus(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { jsonError(w, "App export not available", http.StatusServiceUnavailable) return } job := s.appExporter.GetActiveJob() if job == nil { jsonResponse(w, map[string]interface{}{ "ok": true, "running": false, "done": false, }) return } jsonResponse(w, job.Snapshot()) } // apiExportBundles scans all drives for .fab bundles. func (s *Server) apiExportBundles(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { jsonError(w, "App export not available", http.StatusServiceUnavailable) return } drives := s.storageDriveList() bundles := appexport.ScanForBundles(drives) jsonResponse(w, map[string]interface{}{ "ok": true, "bundles": bundles, }) } // apiExportManifest reads and returns the manifest from a .fab file. func (s *Server) apiExportManifest(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { jsonError(w, "App export not available", http.StatusServiceUnavailable) return } var req struct { Path string `json:"path"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Invalid request body", http.StatusBadRequest) return } if req.Path == "" { jsonError(w, "Missing path", http.StatusBadRequest) return } s.logger.Printf("[DEBUG] [web] apiExportManifest: path=%q hasPassword=%v", req.Path, req.Password != "") // Security: validate path is within a registered exports directory if !s.isValidExportPath(req.Path) { s.logger.Printf("[DEBUG] [web] apiExportManifest: invalid path %q", req.Path) jsonError(w, "Invalid bundle path", http.StatusBadRequest) return } encrypted, _ := appexport.IsEncryptedFAB(req.Path) s.logger.Printf("[DEBUG] [web] apiExportManifest: encrypted=%v", encrypted) var manifest *appexport.Manifest var err error if encrypted { if req.Password == "" { s.logger.Printf("[DEBUG] [web] apiExportManifest: encrypted, needs password") jsonResponse(w, map[string]interface{}{ "ok": true, "encrypted": true, "needs_password": true, }) return } manifest, err = appexport.ReadManifestFromEncryptedFAB(req.Path, req.Password) } else { manifest, err = appexport.ReadManifestFromFAB(req.Path) } if err != nil { s.logger.Printf("[DEBUG] [web] apiExportManifest: error: %v", err) jsonError(w, err.Error(), http.StatusBadRequest) return } s.logger.Printf("[DEBUG] [web] apiExportManifest: app=%s display=%s size=%d", manifest.AppName, manifest.DisplayName, manifest.TotalSizeBytes) jsonResponse(w, map[string]interface{}{ "ok": true, "manifest": manifest, }) } // apiImportStart starts an async import. func (s *Server) apiImportStart(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { jsonError(w, "App import not available", http.StatusServiceUnavailable) return } var req struct { Path string `json:"path"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.logger.Printf("[DEBUG] [web] apiImportStart: invalid body: %v", err) jsonError(w, "Invalid request body", http.StatusBadRequest) return } s.logger.Printf("[DEBUG] [web] apiImportStart: path=%q hasPassword=%v", req.Path, req.Password != "") if req.Path == "" { jsonError(w, "Missing path", http.StatusBadRequest) return } if !s.isValidExportPath(req.Path) { s.logger.Printf("[DEBUG] [web] apiImportStart: invalid path %q", req.Path) jsonError(w, "Invalid bundle path", http.StatusBadRequest) return } err := s.appExporter.StartImport(appexport.ImportRequest{ FABPath: req.Path, Password: req.Password, }) if err != nil { s.logger.Printf("[ERROR] [web] Import start failed for %s: %v", req.Path, err) s.logger.Printf("[DEBUG] [web] apiImportStart error: %v", err) jsonError(w, err.Error(), http.StatusConflict) return } s.logger.Printf("[INFO] [web] Import started from %s", req.Path) jsonResponse(w, map[string]interface{}{"ok": true}) } // apiImportStatus returns current import job status (same as export status). func (s *Server) apiImportStatus(w http.ResponseWriter, r *http.Request) { s.apiExportStatus(w, r) } // storageDriveList converts settings StoragePaths to appexport DrivePathInfo. func (s *Server) storageDriveList() []appexport.DrivePathInfo { paths := s.settings.GetStoragePaths() drives := make([]appexport.DrivePathInfo, 0, len(paths)) for _, sp := range paths { drives = append(drives, appexport.DrivePathInfo{ Path: sp.Path, Label: sp.Label, }) } return drives } // isValidDrivePath checks if a path is a registered storage path. func (s *Server) isValidDrivePath(path string) bool { for _, sp := range s.settings.GetStoragePaths() { if sp.Path == path { return true } } return false } // isValidExportPath checks if a file path is within a registered exports directory. func (s *Server) isValidExportPath(filePath string) bool { cleanPath := filepath.Clean(filePath) for _, sp := range s.settings.GetStoragePaths() { exportDir := appexport.ExportDir(sp.Path) if strings.HasPrefix(cleanPath, filepath.Clean(exportDir)+string(filepath.Separator)) { return true } } return false }