753cd83456
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
190 lines
6.9 KiB
Go
190 lines
6.9 KiB
Go
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
|
|
}
|