Files
felhom-controller/controller/internal/web/handler_export.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +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, s.errText(r, err), 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, s.errText(r, err), 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, s.errText(r, err), 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, s.errText(r, err), 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
}