Files
deploy-felhom-compose/controller/internal/web/handler_export.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:14:43 +01:00

357 lines
10 KiB
Go

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] [handler_export] 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] [handler_export] 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("[DEBUG] [handler_export] apiExportEstimate error: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.logger.Printf("[DEBUG] [handler_export] 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] [handler_export] apiExportStart: invalid body: %v", err)
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] 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] [handler_export] 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("[DEBUG] [handler_export] apiExportStart error: %v", err)
jsonError(w, err.Error(), http.StatusConflict)
return
}
s.logger.Printf("[INFO] 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] [handler_export] 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] [handler_export] apiExportManifest: invalid path %q", req.Path)
jsonError(w, "Invalid bundle path", http.StatusBadRequest)
return
}
encrypted, _ := appexport.IsEncryptedFAB(req.Path)
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: encrypted=%v", encrypted)
var manifest *appexport.Manifest
var err error
if encrypted {
if req.Password == "" {
s.logger.Printf("[DEBUG] [handler_export] 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] [handler_export] apiExportManifest: error: %v", err)
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] 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] [handler_export] apiImportStart: invalid body: %v", err)
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] 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] [handler_export] 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("[DEBUG] [handler_export] apiImportStart error: %v", err)
jsonError(w, err.Error(), http.StatusConflict)
return
}
s.logger.Printf("[INFO] 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
}