Files
felhom-controller/controller/internal/web/handler_export.go
T
admin 9f436c8a3b v0.150.0 — green gate restored + the export link stops leaking the CSRF token
F7/R-53: app_export.html built the app's public URL as '<sub>.{{$.CSRFToken}}',
so the "Megnyitás" link was wrong for every app with a subdomain and a session
CSRF token was written into a URL. Template now uses {{$.Domain}}, and
exportPageHandler supplies the key — it builds its own data map instead of
going through baseData, which is where every other page gets it. The page's
real CSRF path (csrfH() reading the meta tag) is correct and untouched.

The 7 red internal/backup tests are green again, with no behaviour change.
TestTier2V2_* / TestSharesTier2* all failed for one environmental reason:
Tier-2's off-drive guard asks system.SamePhysicalDevice (st_dev equality)
whether a target is really a second disk, and every t.TempDir() here shares one
filesystem — so the guard correctly refused the fixture's "two drives" and the
tests never reached their subject ("nincs másik fizikai meghajtó").

Seam in the package's existing style: a nil-defaulted Manager.samePhysicalDevice
field + sameDevice wrapper, seven call sites routed through it. Nil resolves to
system.SamePhysicalDevice, so production is byte-for-byte unchanged; only the two
fixtures inject a fake modelling one drive per directory subtree. No assertion
weakened, nothing skipped/renamed/deleted; all 7 mutation-proved.

Also: the ssh->pct-exec ASCII-grep and heredoc-credential traps are now in
CLAUDE.md's live-validation section.
2026-07-20 09:42:24 +02:00

410 lines
13 KiB
Go

package web
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
)
// jsonResponse writes a JSON body with a 200 status.
// (Shared JSON helper previously defined in the now-removed storage_handlers.go.)
func jsonResponse(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(v)
}
// jsonError writes a JSON error response with the given status code.
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"error": msg,
})
}
// 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)
// .fab browser download (v0.124.0): estimate → start (existing pipeline, staging dest) →
// guarded stream. Portability exit only — no scheduling, no status surface.
case path == "/api/export/download/estimate" && r.Method == http.MethodGet:
s.apiExportDownloadEstimate(w, r)
case path == "/api/export/download/start" && r.Method == http.MethodPost:
s.apiExportDownloadStart(w, r)
case path == "/api/export/download" && r.Method == http.MethodGet:
s.apiExportDownloadFetch(w, r)
// Chunked browser .fab upload (v0.128.0) — lands in the default drive's exports dir, then
// the bundle scan + import pipeline take over untouched. See handler_export_upload.go.
case path == "/api/export/upload/init" && r.Method == http.MethodPost:
s.apiUploadInit(w, r)
case path == "/api/export/upload/chunk" && r.Method == http.MethodPost:
s.apiUploadChunk(w, r)
case path == "/api/export/upload/finalize" && r.Method == http.MethodPost:
s.apiUploadFinalize(w, r)
case path == "/api/export/upload/abort" && r.Method == http.MethodPost:
s.apiUploadAbort(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,
// R-53: the page builds the app's public URL from the customer domain. This handler does not
// go through baseData (which is where every other page gets "Domain"), so it must supply the
// key itself — the template used to substitute the CSRF token here instead.
"Domain": s.cfg.Customer.Domain,
}
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"`
DeselectOptional []string `json:"deselect_optional"` // Task 4: `.fab` class selection
OptInExcluded []string `json:"opt_in_excluded"`
}
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
}
// F2 (defense-in-depth): reject a traversal/escape stack_name before any export work.
if !validStackName(req.StackName) {
jsonError(w, "Invalid stack_name", 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,
DeselectOptional: req.DeselectOptional,
OptInExcluded: req.OptInExcluded,
})
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
}