package web import ( "encoding/json" "io" "log" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" ) // .fab browser download (v0.124.0, decision 3): PORTABILITY, not a backup tier — no scheduling, // no status surface. The EXISTING async export pipeline produces the bundle (same producer as a // drive export → byte-identical format); the only new mechanism is the dest (a dedicated staging // dir under the controller data dir) and a guarded streaming exit. Bundles are removed after a // successful stream and swept by a TTL cleanup (fabDownloadTTL) on startup and on each start. const fabDownloadTTL = time.Hour // fabDownloadRoot is the staging root; the exporter writes bundles into ExportDir(root). func (s *Server) fabDownloadRoot() string { return filepath.Join(s.cfg.Paths.DataDir, "fab-downloads") } func (s *Server) fabDownloadDir() string { return appexport.ExportDir(s.fabDownloadRoot()) } // fabDownloadNamePattern matches the exporter's own bundle naming (_.fab); // a single safe segment, no separators, cannot start with a dot. var fabDownloadNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*\.fab$`) // validFabDownloadName is the containment guard's first half: only a bare, expected-shaped // bundle FILENAME is accepted — no separators, no traversal, no absolute paths. func validFabDownloadName(name string) bool { if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { return false } return fabDownloadNamePattern.MatchString(name) } // apiExportDownloadEstimate returns the size estimate for a download export (shown BEFORE // starting — the pre-download honesty mechanism). GET /api/export/download/estimate?stack=X func (s *Server) apiExportDownloadEstimate(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") if stackName == "" || !validStackName(stackName) { jsonError(w, "Missing or invalid stack parameter", http.StatusBadRequest) return } if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } est, err := s.appExporter.EstimateExport(stackName, s.fabDownloadRoot()) if err != nil { s.logger.Printf("[ERROR] [web] download-export estimate failed for %s: %v", stackName, err) jsonError(w, err.Error(), http.StatusInternalServerError) return } jsonResponse(w, map[string]interface{}{"ok": true, "data": est}) } // apiExportDownloadStart starts the EXISTING async export with the staging dir as dest. // POST /api/export/download/start {stack_name, password, stop_app} func (s *Server) apiExportDownloadStart(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"` Password string `json:"password"` StopApp bool `json:"stop_app"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Invalid request body", http.StatusBadRequest) return } if req.StackName == "" || !validStackName(req.StackName) { jsonError(w, "Missing or invalid stack_name", http.StatusBadRequest) return } // Opportunistic TTL sweep so an abandoned bundle never outlives fabDownloadTTL by much. sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, s.logger) if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil { jsonError(w, err.Error(), http.StatusInternalServerError) return } // A concurrent export/import gets the exporter's own busy answer (single-flight). if err := s.appExporter.StartExport(appexport.ExportRequest{ StackName: req.StackName, DestDrive: s.fabDownloadRoot(), Password: req.Password, StopApp: req.StopApp, }); err != nil { s.logger.Printf("[ERROR] [web] download-export start failed for %s: %v", req.StackName, err) jsonError(w, err.Error(), http.StatusConflict) return } s.logger.Printf("[INFO] [web] download-export started for %s (staging dir)", req.StackName) jsonResponse(w, map[string]interface{}{"ok": true}) } // apiExportDownloadFetch streams a produced bundle to the browser and removes it afterwards. // GET /api/export/download?file=. Containment is NON-NEGOTIABLE: only an // expected-shaped basename, resolved strictly inside the staging exports dir, is ever opened. func (s *Server) apiExportDownloadFetch(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("file") if !validFabDownloadName(name) { s.logger.Printf("[WARN] [web] download-export fetch refused: invalid bundle name") jsonError(w, "Invalid bundle name", http.StatusBadRequest) return } dir := filepath.Clean(s.fabDownloadDir()) path := filepath.Join(dir, name) // Second half of the guard: the resolved path's parent must BE the staging dir. if filepath.Dir(path) != dir { s.logger.Printf("[WARN] [web] download-export fetch refused: path escapes the staging dir") jsonError(w, "Invalid bundle name", http.StatusBadRequest) return } f, err := os.Open(path) if err != nil { jsonError(w, "Bundle not found", http.StatusNotFound) return } defer f.Close() fi, err := f.Stat() if err != nil || fi.IsDir() { jsonError(w, "Bundle not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) n, err := io.Copy(w, f) // streaming — never ReadAll if err != nil { // Mid-stream failure (client gone): keep the bundle for a retry; the TTL sweep owns it. s.logger.Printf("[WARN] [web] download-export stream aborted after %d bytes: %v", n, err) return } f.Close() if rmErr := os.Remove(path); rmErr != nil { s.logger.Printf("[WARN] [web] download-export cleanup failed (TTL sweep will retry): %v", rmErr) } s.logger.Printf("[INFO] [web] download-export streamed %d bytes and removed the staged bundle", n) } // sweepFabDownloads removes staged bundles (and exporter temp files) older than maxAge. // Returns how many entries were removed. Pure function of (dir, now) — tests inject both. func sweepFabDownloads(dir string, now time.Time, maxAge time.Duration, logger *log.Logger) int { entries, err := os.ReadDir(dir) if err != nil { return 0 } removed := 0 for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !strings.HasSuffix(name, ".fab") && !strings.HasSuffix(name, ".tmp") { continue } info, err := e.Info() if err != nil { continue } if now.Sub(info.ModTime()) > maxAge { if err := os.Remove(filepath.Join(dir, name)); err == nil { removed++ if logger != nil { logger.Printf("[INFO] [web] download-export TTL sweep removed a staged bundle (age > %s)", maxAge) } } } } return removed }