controller: .fab browser download — the existing export pipeline staged under the data dir + a guarded streaming exit (estimate-first, io.Copy, post-stream cleanup, 1h TTL sweep); portability framing (decision 3); traversal guard red-proven, export→import round-trip + corrupt-bundle refusal unit-proven
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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 (<stack>_<timestamp>.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=<basename>. 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
|
||||
}
|
||||
Reference in New Issue
Block a user