From db16371f47c30667371fa7ae54640f892ecb7165 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 13 Jul 2026 21:09:20 +0200 Subject: [PATCH] v0.128.0: chunked browser .fab upload on /import (tunnel-proof) Cloudflare edge caps request bodies (~100 MB, probed live: 120 MiB -> edge 413, 80 MiB -> origin), so the client slices the file into 64 MiB strictly-sequential chunks; the server streams each to a .part file in the default drive's exports dir and finalize renames atomically. Scan/validate/import pipeline untouched. upload/{init,chunk,finalize,abort} inside ServeExportAPI (inherits auth+CSRF); single-flight; offset==received or 409+echo; free-space gate; collision -> lowest-free "name (N).fab"; startup GC of *.part-*; 15-min idle abort. appexport.DiskFree exported (seam web.uploadDiskFree). Scenarios A-F tested, red-proofs run (traversal / out-of-order / overwrite). --- CHANGELOG.md | 35 ++ CONTEXT.md | 17 +- REUSE.md | 1 + controller/README.md | 8 +- controller/cmd/controller/main.go | 3 + controller/internal/appexport/estimate.go | 7 +- controller/internal/web/handler_export.go | 11 + .../internal/web/handler_export_upload.go | 420 ++++++++++++++++++ .../web/handler_export_upload_test.go | 407 +++++++++++++++++ controller/internal/web/server.go | 2 + .../internal/web/templates/app_import.html | 152 ++++++- 11 files changed, 1055 insertions(+), 8 deletions(-) create mode 100644 controller/internal/web/handler_export_upload.go create mode 100644 controller/internal/web/handler_export_upload_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b4bbd..932cf52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ ## Changelog +### v0.128.0 — browser .fab upload on the import page: chunked, tunnel-proof (2026-07-13) + +The Restore/import flow no longer requires copying `.fab` files to `{tároló}/exports/` by hand +(the FileBrowser step alpha testers stumble on): `/import` now has a drag-and-drop/file-picker +upload zone. The binding constraint is the Cloudflare tunnel's request-body cap — **step-0 probe +on the REAL tunnel (2026-07-13): 120 MiB POST → edge HTTP 413 from `Server: cloudflare` before +the origin saw it; 80 MiB → passed to the origin (302 /login)** — so the client slices the file +(`File.slice`, 64 MiB chunks, strictly sequential) and the server appends each chunk to a +`.part-` file in the DEFAULT drive's exports dir via `io.Copy` (no RAM proportional to +file size), then finalize fsyncs + atomically renames. The existing bundle scan + validation + +import pipeline take over untouched — the landing dir is exactly what `isValidExportPath` and +`ScanForBundles` already cover. + +- **Endpoints** (inside `ServeExportAPI` — inherits the main.go `RequireAuth(CsrfProtect(...))` + mount, nothing added at the mux): `POST /api/export/upload/{init,chunk,finalize,abort}`. + Single-flight (second init → 409). Init sanitizes the filename to a `[A-Za-z0-9._ -]` base + name with a mandatory `.fab` suffix and gates on free space (declared size + 1 GiB margin, + Hungarian error with both numbers). Chunk offset MUST equal bytes received (mismatch → 409 + + `received_bytes` so the client re-syncs one step); per-request body cap 96 MiB. Finalize + requires the exact declared size (mismatch → 422, `.part` deleted) and lands collisions on the + lowest-free `"name (N).fab"` (a re-run finds its own prior `(N)` — never `(1)(1)`). +- **No client-side sha256 — deliberate:** WebCrypto can't stream-hash multi-GB files; the `.fab` + format self-validates at import. Transport integrity = sequential offsets + exact final size + + the format's own validation. +- **Crash-safety:** upload state is in-memory (a restart loses the `.part`; the browser + re-uploads). Startup GC removes `*.part-*` in every registered drive's exports dir; an upload + idle ≥15 min is aborted server-side. +- **UI** (`app_import.html`): upload zone above the bundle list ("Fájl kiválasztása" / húzza ide), + progress "Feltöltés: {pct}% ({done} / {total} GB)" + "Megszakítás"; on success the page reloads + (the existing scan renders the new row). One retry per chunk on network error, re-synced from + the 409 echo. +- **Reuse:** `appexport.DiskFree` exported (was `diskFree`) for the space gate via the + `web.uploadDiskFree` test seam. Scenarios §7 A–F tested; red-proofs run for the traversal + sanitize, the out-of-order append and the collision overwrite (all FAILED pre-fix as required). + ### v0.127.3 — reveal copy states the shown code is ALREADY the live one (2026-07-13, Viktor) The supersede happens at upload, inside the ceremony job — BEFORE the code is ever displayed. diff --git a/CONTEXT.md b/CONTEXT.md index 3d0541b..10cb3d0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,22 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-13 eve (v0.127.0 — escrow ceremony wizard LIVE drill+demo) +Last updated: 2026-07-13 night (v0.128.0 — browser .fab upload on /import) + +> **2026-07-13 night — v0.128.0: CHUNKED BROWSER .FAB UPLOAD on /import (ruling F3: chunked).** +> Step-0 probe on the real tunnel PROVED the Cloudflare edge cap (120 MiB POST → edge 413 with +> `Server: cloudflare` on Content-Length alone; 80 MiB → origin 302 /login; local DNS overrides +> the hostname to the LAN guest, probe needed `--resolve` onto CF's public IP). Design: JS +> `File.slice` 64 MiB strictly-sequential chunks → `POST /api/export/upload/{init,chunk,finalize, +> abort}` inside `ServeExportAPI` (inherits RequireAuth+CsrfProtect; single-flight; offset must +> equal received else 409+echo; 96 MiB request cap; free-space gate size+1 GiB; finalize = +> exact-size + fsync + atomic rename, collision → lowest-free `"name (N).fab"`). Lands in the +> DEFAULT drive's exports dir — scan/validate/import pipeline untouched. No client hash +> (deliberate: .fab self-validates). In-memory state: startup GC of `*.part-*`, 15-min idle +> abort. §7 A–F tested + 3 red-proofs. `appexport.DiskFree` exported (REUSE.md row). +> **NOT live-validated: the end-to-end multi-GB browser upload through the real tunnel needs a +> dashboard login → Viktor's 5-minute leg (export an app to .fab, download, re-upload, import — +> full circle).** Possible follow-up if it itches: per-drive target picker (v1 = default drive only). > **2026-07-13 eve — v0.127.0: CUSTOMER-FACING ESCROW CEREMONY WIZARD (/backup/escrow) + > Scenario-F stale-blob re-check. MinAgent 0.88.0 (wizard only). LIVE on demo 9201 + drill guest diff --git a/REUSE.md b/REUSE.md index 8c8bd73..481ea67 100644 --- a/REUSE.md +++ b/REUSE.md @@ -97,6 +97,7 @@ | `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one | | `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) | | `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — | +| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var | | `planDriveGates` / `Server.ReconcileDriveGates` | controller/internal/web/intermediary.go | pure plan + executor | Drive appear/disappear reactions | `planDriveGates` is PURE (unit-testable); loop at `driveGateLoop` | | `Server.runStorageInit` / `runStorageAttach` | controller/internal/web/storage_handlers.go | wizard pipelines | New-drive enroll / re-attach | Format goes through the agent's two-step confirm (below) | diff --git a/controller/README.md b/controller/README.md index 43faa98..c79b49f 100644 --- a/controller/README.md +++ b/controller/README.md @@ -353,11 +353,13 @@ Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypte **Architecture:** `internal/appexport/` package with `ExportStackProvider` adapter interface (same pattern as `backup.StackDataProvider`). `exportAdapter` in `main.go` bridges `stacks.Manager` to the provider. -**API endpoints:** `/api/export/estimate`, `/api/export/start`, `/api/export/status`, `/api/export/bundles`, `/api/export/manifest`, `/api/export/import`, `/api/export/import/status`; **browser download (v0.124.0):** `/api/export/download/estimate`, `/api/export/download/start`, `GET /api/export/download?file=`. +**API endpoints:** `/api/export/estimate`, `/api/export/start`, `/api/export/status`, `/api/export/bundles`, `/api/export/manifest`, `/api/export/import`, `/api/export/import/status`; **browser download (v0.124.0):** `/api/export/download/estimate`, `/api/export/download/start`, `GET /api/export/download?file=`; **browser upload (v0.128.0):** `POST /api/export/upload/{init,chunk,finalize,abort}`. -**Browser download (v0.124.0 — portability, NOT a backup tier):** the same export pipeline runs with dest = `/fab-downloads/` (same producer → byte-identical bundle), then streams via a guarded endpoint (basename-shape + dir-containment guard; `Content-Disposition: attachment`; `io.Copy`; the staged bundle is removed after the stream and a 1h TTL sweep runs on startup + each start). Estimate is shown BEFORE starting; the batch UI downloads apps one at a time (no combined archive). Import remains drive-scan only — a downloaded bundle returns via a drive/share. `handler_export_download.go`. +**Browser download (v0.124.0 — portability, NOT a backup tier):** the same export pipeline runs with dest = `/fab-downloads/` (same producer → byte-identical bundle), then streams via a guarded endpoint (basename-shape + dir-containment guard; `Content-Disposition: attachment`; `io.Copy`; the staged bundle is removed after the stream and a 1h TTL sweep runs on startup + each start). Estimate is shown BEFORE starting; the batch UI downloads apps one at a time (no combined archive). `handler_export_download.go`. -**UI:** Export button on app info page, the "Hordozható mentéscsomag (.fab)" section on `/backups/restore` (per-app download + batch), standalone import page at `/import`. +**Browser upload (v0.128.0 — the download's return leg):** the `/import` page uploads a `.fab` straight from the browser. Chunked because the Cloudflare tunnel caps request bodies at ~100 MB (probed live 2026-07-13: 120 MiB → edge 413, 80 MiB → origin): JS slices the file into 64 MiB chunks (strictly sequential offsets; one retry per chunk re-synced from the 409 `received_bytes` echo), the server streams each to a `.part-` file in the DEFAULT drive's exports dir (`io.Copy`, 96 MiB per-request cap, free-space gate = size + 1 GiB), finalize checks the exact declared size, fsyncs and atomically renames (collision → lowest-free `"name (N).fab"`). Single-flight; no client-side hash (the .fab format self-validates at import); in-memory state — startup GC sweeps `*.part-*`, 15-min idle timeout aborts server-side. The scan + validation + import pipeline are untouched. `handler_export_upload.go`. + +**UI:** Export button on app info page, the "Hordozható mentéscsomag (.fab)" section on `/backups/restore` (per-app download + batch), standalone import page at `/import` (upload zone + drive-scanned bundle list). --- diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 43f8a9f..171ee9f 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -863,6 +863,9 @@ func main() { stackMgr.RecoverMigration(ctx) webServer.SetEncryptionKey(encKey) webServer.SetAppExporter(appExporter) + // Browser .fab upload (v0.128.0): upload state is in-memory, so a restart strands the .part — + // GC stray part files in every registered drive's exports dir at startup. + webServer.CleanupStaleUploadParts() // Escrow wizard (v0.127.0): the Scenario-F stale-blob flag feeds the Távoli mentés card. if escrowConfirmer != nil { webServer.SetEscrowStale(escrowConfirmer.StaleBlob) diff --git a/controller/internal/appexport/estimate.go b/controller/internal/appexport/estimate.go index c6522d5..70eea61 100644 --- a/controller/internal/appexport/estimate.go +++ b/controller/internal/appexport/estimate.go @@ -72,7 +72,7 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate, // Destination free space exportDir := ExportDir(destDrive) os.MkdirAll(exportDir, 0755) - est.DestFreeBytes = diskFree(exportDir) + est.DestFreeBytes = DiskFree(exportDir) est.DestFreeHuman = humanizeBytes(est.DestFreeBytes) // Need ~10% overhead for tar.gz metadata + compression margin @@ -135,8 +135,9 @@ func dockerVolumeSize(volumeName string) int64 { return duBytes(mountpoint) } -// diskFree returns available bytes on the filesystem containing path. -func diskFree(path string) int64 { +// DiskFree returns available bytes on the filesystem containing path (0 on any error). +// Exported since v0.128.0 — the browser-upload space gate reuses it via a web-package seam. +func DiskFree(path string) int64 { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output() diff --git a/controller/internal/web/handler_export.go b/controller/internal/web/handler_export.go index 09b4fc9..84ace83 100644 --- a/controller/internal/web/handler_export.go +++ b/controller/internal/web/handler_export.go @@ -52,6 +52,17 @@ func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) { 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) diff --git a/controller/internal/web/handler_export_upload.go b/controller/internal/web/handler_export_upload.go new file mode 100644 index 0000000..87687cd --- /dev/null +++ b/controller/internal/web/handler_export_upload.go @@ -0,0 +1,420 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" + "gitea.dooplex.hu/admin/felhom-controller/internal/logx" +) + +// Browser .fab upload (v0.128.0) — chunked, tunnel-proof. Cloudflare caps request bodies at +// ~100 MB on the free plan (step-0 probe 2026-07-13: a 120 MiB POST to the real tunnel got an +// edge HTTP 413 before the origin saw it; 80 MiB passed through), so the client slices the file +// and the server appends strictly sequential chunks to a .part file in the DEFAULT drive's +// exports dir; finalize renames atomically. The existing bundle scan + import pipeline take over +// untouched — the landing dir is exactly what isValidExportPath and ScanForBundles already cover. +// +// No client-side sha256 — deliberate: WebCrypto cannot stream-hash multi-GB files, and the .fab +// format self-validates at import (manifest + crypto checks in the import pipeline). Transport +// integrity = strict sequential offsets + exact declared final size + the format's own validation. +// +// Upload state is in-memory only: a controller restart loses the .part (the browser re-uploads — +// honest and simple; no resume machinery). Startup GC removes stray *.part-* files; an upload +// idle for uploadIdleTimeout is aborted server-side. + +const ( + // uploadChunkBytes is the chunk size handed to the client. 64 MiB clears the ~100 MB + // Cloudflare edge cap with headroom (step-0 probe above). + uploadChunkBytes = 64 << 20 + // uploadMaxChunkBody is the server-side per-request body cap — above the advertised chunk + // size, below the edge cap. + uploadMaxChunkBody = 96 << 20 + // uploadFreeSpaceMargin is the extra free space required beyond the declared file size. + uploadFreeSpaceMargin = 1 << 30 +) + +// uploadIdleTimeout aborts an upload with no chunk activity (package var: test seam). +var uploadIdleTimeout = 15 * time.Minute + +// uploadDiskFree is the free-space probe (package var: test seam — scenario C fakes statfs). +var uploadDiskFree = appexport.DiskFree + +// uploadJob is one in-flight browser upload. +type uploadJob struct { + id string + finalName string // sanitized target name ("app.fab") + partPath string // the .part- temp file in the exports dir + declared int64 // size_bytes from init + received int64 // bytes appended so far + file *os.File + writing bool // a chunk body is streaming to the file right now + timer *time.Timer +} + +// uploadState is the single-flight slot: ONE upload at a time (second init → 409). +type uploadState struct { + mu sync.Mutex + cur *uploadJob +} + +// uploadJSON writes the upload API envelope with an explicit status code. +func uploadJSON(w http.ResponseWriter, code int, v map[string]interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +// sanitizeUploadFilename reduces a client filename to a safe base name: charset +// [A-Za-z0-9._ -], mandatory .fab suffix, non-empty stem, no leading dot, no traversal. +func sanitizeUploadFilename(name string) (string, string) { + base := filepath.Base(strings.TrimSpace(name)) + if !strings.HasSuffix(base, ".fab") { + return "", "Csak .fab fájl tölthető fel." + } + stem := strings.TrimSuffix(base, ".fab") + if stem == "" || strings.HasPrefix(stem, ".") { + return "", "Érvénytelen fájlnév." + } + for _, r := range base { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '.' || r == '_' || r == ' ' || r == '-': + default: + return "", "Érvénytelen fájlnév." + } + } + return base, "" +} + +// uploadGB formats bytes as a one-decimal GB string for the Hungarian space error. +func uploadGB(b int64) string { + return fmt.Sprintf("%.1f", float64(b)/float64(1<<30)) +} + +// lowestFreeUploadName returns name if unused, else the first " (N).fab" (N≥1) that does +// not exist — computed from the ORIGINAL requested name every time, so a re-run finds its own +// prior "(N)" and never produces "(1)(1)". +func lowestFreeUploadName(dir, name string) string { + if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) { + return name + } + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + for n := 1; ; n++ { + cand := fmt.Sprintf("%s (%d)%s", stem, n, ext) + if _, err := os.Stat(filepath.Join(dir, cand)); os.IsNotExist(err) { + return cand + } + } +} + +// randomUploadToken returns a hex token from crypto/rand. +func randomUploadToken(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// apiUploadInit — POST /api/export/upload/init {filename, size_bytes}. Claims the single-flight +// slot, gates on free space, creates the .part file in the DEFAULT drive's exports dir. +func (s *Server) apiUploadInit(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var req struct { + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + name, humanErr := sanitizeUploadFilename(req.Filename) + if humanErr != "" { + jsonError(w, humanErr, http.StatusBadRequest) + return + } + if req.SizeBytes <= 0 { + jsonError(w, "Érvénytelen fájlméret.", http.StatusBadRequest) + return + } + + drive := s.settings.GetDefaultStoragePath() + if drive == "" { + jsonError(w, "Nincs alapértelmezett tároló beállítva.", http.StatusConflict) + return + } + exportDir := appexport.ExportDir(drive) + if err := os.MkdirAll(exportDir, 0755); err != nil { + logx.Warnf(s.logger, "[web] fab upload init: mkdir %s: %v", exportDir, err) + jsonError(w, "A tároló nem írható.", http.StatusInternalServerError) + return + } + free := uploadDiskFree(exportDir) + if free < req.SizeBytes+uploadFreeSpaceMargin { + jsonError(w, fmt.Sprintf( + "Nincs elég szabad hely a tárolón (szükséges: %s GB, elérhető: %s GB).", + uploadGB(req.SizeBytes), uploadGB(free)), http.StatusConflict) + return + } + + id, err := randomUploadToken(16) + if err != nil { + jsonError(w, "Belső hiba.", http.StatusInternalServerError) + return + } + partRand, err := randomUploadToken(8) + if err != nil { + jsonError(w, "Belső hiba.", http.StatusInternalServerError) + return + } + job := &uploadJob{ + id: id, + finalName: name, + partPath: filepath.Join(exportDir, name+".part-"+partRand), + declared: req.SizeBytes, + } + + s.fabUpload.mu.Lock() + if s.fabUpload.cur != nil { + s.fabUpload.mu.Unlock() + jsonError(w, "Már folyamatban van egy feltöltés.", http.StatusConflict) + return + } + f, err := os.OpenFile(job.partPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + s.fabUpload.mu.Unlock() + logx.Warnf(s.logger, "[web] fab upload init: create part: %v", err) + jsonError(w, "A tároló nem írható.", http.StatusInternalServerError) + return + } + job.file = f + job.timer = time.AfterFunc(uploadIdleTimeout, func() { s.expireIdleUpload(job) }) + s.fabUpload.cur = job + s.fabUpload.mu.Unlock() + + logx.Infof(s.logger, "[web] fab upload started: %s (%d bytes declared) → %s", + job.finalName, job.declared, exportDir) + uploadJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "upload_id": job.id, + "chunk_bytes": uploadChunkBytes, + "received_bytes": 0, + }) +} + +// apiUploadChunk — POST /api/export/upload/chunk?id=…&offset=N with a raw octet-stream body. +// offset MUST equal bytes received so far (strictly sequential); a mismatch answers 409 with the +// current received_bytes so the client re-syncs one step. Streams via io.Copy — no RAM +// proportional to the chunk. +func (s *Server) apiUploadChunk(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + offset, perr := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64) + + s.fabUpload.mu.Lock() + job := s.fabUpload.cur + if job == nil || job.id != id || id == "" { + s.fabUpload.mu.Unlock() + jsonError(w, "Ismeretlen feltöltés.", http.StatusNotFound) + return + } + if job.writing { + s.fabUpload.mu.Unlock() + uploadJSON(w, http.StatusConflict, map[string]interface{}{ + "ok": false, "error": "chunk already in flight", "received_bytes": job.received, + }) + return + } + if perr != nil || offset != job.received { + received := job.received + s.fabUpload.mu.Unlock() + uploadJSON(w, http.StatusConflict, map[string]interface{}{ + "ok": false, "error": "offset mismatch", "received_bytes": received, + }) + return + } + job.writing = true + job.timer.Stop() // no idle-expiry underneath an in-flight body + s.fabUpload.mu.Unlock() + + n, err := io.Copy(job.file, http.MaxBytesReader(w, r.Body, uploadMaxChunkBody)) + + s.fabUpload.mu.Lock() + job.writing = false + current := s.fabUpload.cur == job + if current { + job.received += n + job.timer.Reset(uploadIdleTimeout) + } + received := job.received + s.fabUpload.mu.Unlock() + + if !current { + // Aborted/expired while the body streamed — the .part is already gone. + jsonError(w, "A feltöltés megszakadt.", http.StatusGone) + return + } + if err != nil { + var mbe *http.MaxBytesError + code := http.StatusInternalServerError + if errors.As(err, &mbe) { + code = http.StatusRequestEntityTooLarge + } + logx.Debugf(s.logger, "[web] fab upload chunk failed at %d bytes: %v", received, err) + uploadJSON(w, code, map[string]interface{}{ + "ok": false, "error": "chunk write failed", "received_bytes": received, + }) + return + } + logx.Debugf(s.logger, "[web] fab upload chunk ok: +%d → %d/%d bytes", n, received, job.declared) + uploadJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, "received_bytes": received, + }) +} + +// apiUploadFinalize — POST /api/export/upload/finalize {upload_id}. Exact-size check, fsync, +// atomic rename to the final name (lowest-free " (N)" on collision). The page then re-runs the +// existing bundle scan — no second lister. +func (s *Server) apiUploadFinalize(w http.ResponseWriter, r *http.Request) { + var req struct { + UploadID string `json:"upload_id"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + s.fabUpload.mu.Lock() + job := s.fabUpload.cur + if job == nil || job.id != req.UploadID || req.UploadID == "" { + s.fabUpload.mu.Unlock() + jsonError(w, "Ismeretlen feltöltés.", http.StatusNotFound) + return + } + if job.writing { + s.fabUpload.mu.Unlock() + jsonError(w, "Egy adatcsomag még feltöltés alatt van.", http.StatusConflict) + return + } + job.timer.Stop() + s.fabUpload.cur = nil // the slot frees on every finalize outcome + s.fabUpload.mu.Unlock() + + if job.received != job.declared { + job.file.Close() + os.Remove(job.partPath) + logx.Warnf(s.logger, "[web] fab upload finalize size mismatch: got %d, declared %d — part deleted", + job.received, job.declared) + uploadJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{ + "ok": false, "error": "A feltöltött méret nem egyezik — próbálja újra.", + }) + return + } + if err := job.file.Sync(); err != nil { + job.file.Close() + os.Remove(job.partPath) + logx.Warnf(s.logger, "[web] fab upload finalize fsync: %v", err) + jsonError(w, "A fájl mentése sikertelen.", http.StatusInternalServerError) + return + } + if err := job.file.Close(); err != nil { + os.Remove(job.partPath) + jsonError(w, "A fájl mentése sikertelen.", http.StatusInternalServerError) + return + } + dir := filepath.Dir(job.partPath) + finalName := lowestFreeUploadName(dir, job.finalName) + if err := os.Rename(job.partPath, filepath.Join(dir, finalName)); err != nil { + os.Remove(job.partPath) + logx.Warnf(s.logger, "[web] fab upload finalize rename: %v", err) + jsonError(w, "A fájl mentése sikertelen.", http.StatusInternalServerError) + return + } + logx.Infof(s.logger, "[web] fab upload finished: %s (%d bytes)", finalName, job.declared) + uploadJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, "filename": finalName, + }) +} + +// apiUploadAbort — POST /api/export/upload/abort {upload_id} → delete the .part, free the slot. +func (s *Server) apiUploadAbort(w http.ResponseWriter, r *http.Request) { + var req struct { + UploadID string `json:"upload_id"` + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + s.fabUpload.mu.Lock() + job := s.fabUpload.cur + if job == nil || job.id != req.UploadID || req.UploadID == "" { + s.fabUpload.mu.Unlock() + jsonError(w, "Ismeretlen feltöltés.", http.StatusNotFound) + return + } + job.timer.Stop() + s.fabUpload.cur = nil + s.fabUpload.mu.Unlock() + + job.file.Close() + os.Remove(job.partPath) + logx.Infof(s.logger, "[web] fab upload aborted by client at %d/%d bytes", job.received, job.declared) + uploadJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// expireIdleUpload fires from the idle timer: an upload with no chunk for uploadIdleTimeout is +// aborted server-side and its .part deleted. A body streaming right now is never expired (the +// timer is stopped for the write's duration and re-armed after). +func (s *Server) expireIdleUpload(job *uploadJob) { + s.fabUpload.mu.Lock() + if s.fabUpload.cur != job || job.writing { + s.fabUpload.mu.Unlock() + return + } + s.fabUpload.cur = nil + s.fabUpload.mu.Unlock() + + job.file.Close() + os.Remove(job.partPath) + logx.Warnf(s.logger, "[web] fab upload idle-expired at %d/%d bytes — part deleted", + job.received, job.declared) +} + +// CleanupStaleUploadParts removes stray *.part-* files from every registered drive's exports dir +// (startup GC — upload state is in-memory, so a restart strands at most one .part per crash). +func (s *Server) CleanupStaleUploadParts() { + removed := 0 + for _, sp := range s.settings.GetStoragePaths() { + dir := appexport.ExportDir(sp.Path) + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !strings.Contains(e.Name(), ".part-") { + continue + } + if err := os.Remove(filepath.Join(dir, e.Name())); err == nil { + removed++ + } + } + } + if removed > 0 { + logx.Infof(s.logger, "[web] startup GC: removed %d stale upload part file(s)", removed) + } +} diff --git a/controller/internal/web/handler_export_upload_test.go b/controller/internal/web/handler_export_upload_test.go new file mode 100644 index 0000000..9b948af --- /dev/null +++ b/controller/internal/web/handler_export_upload_test.go @@ -0,0 +1,407 @@ +package web + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Chunked browser .fab upload (v0.128.0) — scenarios §7 A–F. Red-proofs recorded in REPORT.md: +// C: sanitize bypassed (raw join of the client filename) → the traversal test FAILS +// (the file lands outside the exports dir). +// B: offset check removed (append regardless) → the replay test FAILS (bytes doubled on disk). +// F: naive overwrite (rename straight to the requested name) → the collision test FAILS +// (the pre-existing bundle's bytes are clobbered). + +// uploadTestServer builds a Server with a DEFAULT registered drive on a TempDir and returns the +// drive's exports dir. The free-space seam reports "plenty" unless a test overrides it. +func uploadTestServer(t *testing.T) (*Server, string) { + t.Helper() + s := testServer(t) + drive := t.TempDir() + if err := s.settings.AddStoragePath(settings.StoragePath{ + Path: drive, Label: "Teszt HDD", IsDefault: true, Schedulable: true, + AddedAt: "2026-07-13T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(appexport.ExportDir(drive), 0755); err != nil { + t.Fatal(err) + } + prevFree := uploadDiskFree + uploadDiskFree = func(string) int64 { return 1 << 40 } // 1 TiB — never the constraint + t.Cleanup(func() { + uploadDiskFree = prevFree + s.dropUpload(t) + }) + return s, appexport.ExportDir(drive) +} + +// dropUpload force-releases a leftover slot so TempDir cleanup never races the idle timer. +func (s *Server) dropUpload(t *testing.T) { + t.Helper() + s.fabUpload.mu.Lock() + job := s.fabUpload.cur + s.fabUpload.cur = nil + s.fabUpload.mu.Unlock() + if job != nil { + job.timer.Stop() + job.file.Close() + os.Remove(job.partPath) + } +} + +func uploadInit(s *Server, filename string, size int64) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]interface{}{"filename": filename, "size_bytes": size}) + rr := httptest.NewRecorder() + s.apiUploadInit(rr, httptest.NewRequest(http.MethodPost, "/api/export/upload/init", bytes.NewReader(body))) + return rr +} + +func uploadChunk(s *Server, id string, offset int, data []byte) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + url := fmt.Sprintf("/api/export/upload/chunk?id=%s&offset=%d", id, offset) + s.apiUploadChunk(rr, httptest.NewRequest(http.MethodPost, url, bytes.NewReader(data))) + return rr +} + +func uploadFinalize(s *Server, id string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"upload_id": id}) + rr := httptest.NewRecorder() + s.apiUploadFinalize(rr, httptest.NewRequest(http.MethodPost, "/api/export/upload/finalize", bytes.NewReader(body))) + return rr +} + +func decodeUpload(t *testing.T, rr *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var m map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil { + t.Fatalf("bad JSON %q: %v", rr.Body.String(), err) + } + return m +} + +// mustInit runs a full init and returns the upload id. +func mustInit(t *testing.T, s *Server, filename string, size int64) string { + t.Helper() + rr := uploadInit(s, filename, size) + if rr.Code != http.StatusOK { + t.Fatalf("init: got %d (%s)", rr.Code, rr.Body.String()) + } + m := decodeUpload(t, rr) + id, _ := m["upload_id"].(string) + if id == "" { + t.Fatal("init returned no upload_id") + } + if got := int64(m["chunk_bytes"].(float64)); got != uploadChunkBytes { + t.Fatalf("chunk_bytes = %d, want %d", got, int64(uploadChunkBytes)) + } + return id +} + +// Scenario A — happy path: 3 sequential chunks land byte-exactly in the exports dir under the +// requested name, and the EXISTING bundle scan lists it (no second lister). The server streams +// via io.Copy — nothing here (or in the handler) buffers the file. +func TestFabUpload_HappyPath(t *testing.T) { + s, exportDir := uploadTestServer(t) + + chunks := [][]byte{ + bytes.Repeat([]byte{0xA1}, 700), + bytes.Repeat([]byte{0xB2}, 700), + bytes.Repeat([]byte{0xC3}, 300), + } + var want []byte + for _, c := range chunks { + want = append(want, c...) + } + id := mustInit(t, s, "sajatapp_20260713.fab", int64(len(want))) + + off := 0 + for i, c := range chunks { + rr := uploadChunk(s, id, off, c) + if rr.Code != http.StatusOK { + t.Fatalf("chunk %d: got %d (%s)", i, rr.Code, rr.Body.String()) + } + off += len(c) + if got := int64(decodeUpload(t, rr)["received_bytes"].(float64)); got != int64(off) { + t.Fatalf("chunk %d: received_bytes = %d, want %d", i, got, off) + } + } + + rr := uploadFinalize(s, id) + if rr.Code != http.StatusOK { + t.Fatalf("finalize: got %d (%s)", rr.Code, rr.Body.String()) + } + if got := decodeUpload(t, rr)["filename"]; got != "sajatapp_20260713.fab" { + t.Fatalf("finalize filename = %v", got) + } + + final := filepath.Join(exportDir, "sajatapp_20260713.fab") + got, err := os.ReadFile(final) + if err != nil { + t.Fatalf("final file: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("final bytes differ: got %d bytes, want %d", len(got), len(want)) + } + // No .part residue. + entries, _ := os.ReadDir(exportDir) + for _, e := range entries { + if strings.Contains(e.Name(), ".part-") { + t.Fatalf("leftover part file: %s", e.Name()) + } + } + // The EXISTING scan lists the upload (drive label included — the same row the page renders). + bundles := appexport.ScanForBundles([]appexport.DrivePathInfo{{Path: filepath.Dir(exportDir), Label: "Teszt HDD"}}) + found := false + for _, b := range bundles { + if b.FileName == "sajatapp_20260713.fab" && b.SizeBytes == int64(len(want)) { + found = true + } + } + if !found { + t.Fatalf("bundle scan does not list the upload: %+v", bundles) + } +} + +// Scenario B — offset mismatch: replaying an already-received chunk answers 409 with the current +// received_bytes (the client's re-sync datum) and appends NOTHING (asserted via on-disk size). +// Red-proof: drop the offset==received check → this FAILS (size doubles). +func TestFabUpload_OffsetMismatchRejected(t *testing.T) { + s, _ := uploadTestServer(t) + + chunk := bytes.Repeat([]byte{0x55}, 512) + id := mustInit(t, s, "b.fab", 1024) + if rr := uploadChunk(s, id, 0, chunk); rr.Code != http.StatusOK { + t.Fatalf("chunk 0: got %d", rr.Code) + } + + // Replay chunk at offset 0 (client lost the ack). + rr := uploadChunk(s, id, 0, chunk) + if rr.Code != http.StatusConflict { + t.Fatalf("replay: got %d, want 409", rr.Code) + } + if got := int64(decodeUpload(t, rr)["received_bytes"].(float64)); got != 512 { + t.Fatalf("replay received_bytes = %d, want 512 (the re-sync datum)", got) + } + s.fabUpload.mu.Lock() + part := s.fabUpload.cur.partPath + s.fabUpload.mu.Unlock() + if fi, err := os.Stat(part); err != nil || fi.Size() != 512 { + t.Fatalf("part size after replay = %v (err=%v), want 512 — replay must not append", fi, err) + } + + // Re-synced continuation at the echoed offset succeeds. + if rr := uploadChunk(s, id, 512, chunk); rr.Code != http.StatusOK { + t.Fatalf("re-synced chunk: got %d", rr.Code) + } +} + +// Scenario C — security gates on init: traversal filenames are neutered to a base name INSIDE the +// exports dir (never resolved outside), wrong extension refused, oversize vs the (faked) free +// space refused with the Hungarian message, and a second concurrent init answers 409. +// Red-proof: bypass sanitizeUploadFilename (raw join) → the traversal leg FAILS. +func TestFabUpload_SecurityGates(t *testing.T) { + s, exportDir := uploadTestServer(t) + + // Traversal name: sanitized to its base — the upload lands INSIDE exports, the target of the + // traversal stays untouched. + outside := filepath.Join(filepath.Dir(exportDir), "evil.fab") // {drive}/evil.fab — where ../evil.fab would land + id := mustInit(t, s, "../evil.fab", 4) + if rr := uploadChunk(s, id, 0, []byte("EVIL")); rr.Code != http.StatusOK { + t.Fatalf("chunk: got %d", rr.Code) + } + if rr := uploadFinalize(s, id); rr.Code != http.StatusOK { + t.Fatalf("finalize: got %d", rr.Code) + } + if _, err := os.Stat(filepath.Join(exportDir, "evil.fab")); err != nil { + t.Fatal("sanitized upload must land inside the exports dir") + } + if _, err := os.Stat(outside); !os.IsNotExist(err) { + t.Fatal("TRAVERSAL: a file appeared outside the exports dir") + } + + // Wrong extension / hidden / empty stem / bad charset → 400, no slot claimed. + for _, name := range []string{"x.txt", ".hidden.fab", ".fab", "árvíztűrő.fab", "a/b.txt"} { + if rr := uploadInit(s, name, 10); rr.Code != http.StatusBadRequest { + t.Errorf("init(%q): got %d, want 400", name, rr.Code) + } + } + + // Oversize vs free space (fake statfs seam): Hungarian message with both numbers. + prevFree := uploadDiskFree + uploadDiskFree = func(string) int64 { return 2 << 30 } // 2 GiB free + rr := uploadInit(s, "big.fab", 3<<30) // 3 GiB declared + uploadDiskFree = prevFree + if rr.Code != http.StatusConflict { + t.Fatalf("oversize init: got %d, want 409", rr.Code) + } + if msg, _ := decodeUpload(t, rr)["error"].(string); !strings.Contains(msg, "Nincs elég szabad hely") || + !strings.Contains(msg, "3.0 GB") || !strings.Contains(msg, "2.0 GB") { + t.Fatalf("oversize error message = %q", msg) + } + + // Second concurrent init → 409 (single-flight). + first := mustInit(t, s, "one.fab", 10) + if rr := uploadInit(s, "two.fab", 10); rr.Code != http.StatusConflict { + t.Fatalf("second init: got %d, want 409", rr.Code) + } + _ = first +} + +// Scenario C (route wrap) — the new upload paths live under the SAME main.go mount +// (RequireAuth(CsrfProtect(ServeExportAPI))): an unauthenticated POST answers 401 before any +// upload handler runs, proving the wrap applies to the new routes with nothing added at the mux. +func TestFabUpload_MountInheritsAuth(t *testing.T) { + s, _, _ := claimTestServer(t) + mux := http.NewServeMux() + mux.Handle("/api/export/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeExportAPI)))) + + for _, p := range []string{"/api/export/upload/init", "/api/export/upload/chunk", "/api/export/upload/finalize", "/api/export/upload/abort"} { + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}"))) + if rr.Code != http.StatusUnauthorized { + t.Errorf("%s unauthenticated: got %d, want 401", p, rr.Code) + } + } +} + +// Scenario D — finalize size mismatch: 422, the .part deleted, nothing renamed, slot freed. +func TestFabUpload_FinalizeSizeMismatch(t *testing.T) { + s, exportDir := uploadTestServer(t) + + id := mustInit(t, s, "short.fab", 1000) + if rr := uploadChunk(s, id, 0, bytes.Repeat([]byte{1}, 400)); rr.Code != http.StatusOK { + t.Fatalf("chunk: got %d", rr.Code) + } + rr := uploadFinalize(s, id) + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("finalize: got %d, want 422", rr.Code) + } + entries, _ := os.ReadDir(exportDir) + if len(entries) != 0 { + t.Fatalf("exports dir must be empty after a size-mismatch finalize, got %v", entries) + } + // Slot freed: a fresh init succeeds. + mustInit(t, s, "again.fab", 10) +} + +// Scenario E — GC: startup sweep removes stray .part files; an idle upload is aborted +// server-side (timer seam shortened) and its .part deleted, freeing the slot. +func TestFabUpload_GCAndIdleTimeout(t *testing.T) { + s, exportDir := uploadTestServer(t) + + // Startup GC. + if err := os.WriteFile(filepath.Join(exportDir, "stray.fab.part-deadbeef"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + keep := filepath.Join(exportDir, "keep.fab") + if err := os.WriteFile(keep, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + s.CleanupStaleUploadParts() + if _, err := os.Stat(filepath.Join(exportDir, "stray.fab.part-deadbeef")); !os.IsNotExist(err) { + t.Fatal("startup GC must remove stray .part files") + } + if _, err := os.Stat(keep); err != nil { + t.Fatal("startup GC must not touch real bundles") + } + + // Idle timeout. + prev := uploadIdleTimeout + uploadIdleTimeout = 30 * time.Millisecond + defer func() { uploadIdleTimeout = prev }() + + id := mustInit(t, s, "idle.fab", 100) + s.fabUpload.mu.Lock() + part := s.fabUpload.cur.partPath + s.fabUpload.mu.Unlock() + + deadline := time.Now().Add(3 * time.Second) + for { + s.fabUpload.mu.Lock() + gone := s.fabUpload.cur == nil + s.fabUpload.mu.Unlock() + if gone { + break + } + if time.Now().After(deadline) { + t.Fatal("idle upload was never expired") + } + time.Sleep(10 * time.Millisecond) + } + if _, err := os.Stat(part); !os.IsNotExist(err) { + t.Fatal("idle-expired .part must be deleted") + } + if rr := uploadChunk(s, id, 0, []byte("late")); rr.Code != http.StatusNotFound { + t.Fatalf("chunk after expiry: got %d, want 404", rr.Code) + } +} + +// Scenario F — collision: an existing "app.fab" is never overwritten; the upload lands as +// "app (1).fab", and a re-run finds its own prior (1) and lands as "app (2).fab" — never +// "(1)(1)". Red-proof: rename straight to the requested name → this FAILS (bytes clobbered). +func TestFabUpload_CollisionLowestFreeSuffix(t *testing.T) { + s, exportDir := uploadTestServer(t) + + pre := []byte("PRE-EXISTING") + if err := os.WriteFile(filepath.Join(exportDir, "app.fab"), pre, 0644); err != nil { + t.Fatal(err) + } + + run := func(payload string) string { + id := mustInit(t, s, "app.fab", int64(len(payload))) + if rr := uploadChunk(s, id, 0, []byte(payload)); rr.Code != http.StatusOK { + t.Fatalf("chunk: got %d", rr.Code) + } + rr := uploadFinalize(s, id) + if rr.Code != http.StatusOK { + t.Fatalf("finalize: got %d (%s)", rr.Code, rr.Body.String()) + } + return decodeUpload(t, rr)["filename"].(string) + } + + if got := run("FIRST-RERUN"); got != "app (1).fab" { + t.Fatalf("first collision landed as %q, want \"app (1).fab\"", got) + } + if got := run("SECOND-RERUN"); got != "app (2).fab" { + t.Fatalf("second collision landed as %q, want \"app (2).fab\" (never (1)(1))", got) + } + if body, err := os.ReadFile(filepath.Join(exportDir, "app.fab")); err != nil || !bytes.Equal(body, pre) { + t.Fatal("the pre-existing bundle was overwritten") + } + if body, _ := os.ReadFile(filepath.Join(exportDir, "app (1).fab")); string(body) != "FIRST-RERUN" { + t.Fatalf("app (1).fab content = %q", body) + } +} + +// Abort deletes the .part and frees the slot (the Megszakítás button's route). +func TestFabUpload_Abort(t *testing.T) { + s, exportDir := uploadTestServer(t) + + id := mustInit(t, s, "cancelme.fab", 100) + if rr := uploadChunk(s, id, 0, bytes.Repeat([]byte{7}, 50)); rr.Code != http.StatusOK { + t.Fatalf("chunk: got %d", rr.Code) + } + body, _ := json.Marshal(map[string]string{"upload_id": id}) + rr := httptest.NewRecorder() + s.apiUploadAbort(rr, httptest.NewRequest(http.MethodPost, "/api/export/upload/abort", bytes.NewReader(body))) + if rr.Code != http.StatusOK { + t.Fatalf("abort: got %d", rr.Code) + } + entries, _ := os.ReadDir(exportDir) + if len(entries) != 0 { + t.Fatalf("exports dir must be empty after abort, got %v", entries) + } + mustInit(t, s, "next.fab", 10) // slot freed +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index b80273d..ffccc02 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -87,6 +87,8 @@ type Server struct { // netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec). netAdd netAddState netAgentFn func() (netAgent, error) + // fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0). + fabUpload uploadState netProbeFn func(ctx context.Context, dir string) probeOutcome netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error) // agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared diff --git a/controller/internal/web/templates/app_import.html b/controller/internal/web/templates/app_import.html index d57996a..2a0b66c 100644 --- a/controller/internal/web/templates/app_import.html +++ b/controller/internal/web/templates/app_import.html @@ -8,10 +8,29 @@ + +
+
+

.fab csomag feltöltése — húzza ide, vagy válassza ki a fájlt

+ + +
+ + +
+ {{if not .Bundles}}

Nem található .fab csomag a regisztrált tárolókon.

-

Exportálj egy alkalmazást az alkalmazás oldaláról, vagy másolj egy .fab fájlt a {tároló}/felhom-data/exports/ könyvtárba.

+

Exportálj egy alkalmazást az alkalmazás oldaláról, tölts fel egy .fab fájlt itt fent, vagy másolj egyet a {tároló}/felhom-data/exports/ könyvtárba.

{{else}}
@@ -281,6 +300,137 @@ function showError(msg) { el.style.display = 'block'; document.getElementById('importBtn').disabled = false; } + +// --- Browser .fab upload (v0.128.0): File.slice sequential chunk loop. One retry per chunk on a +// network error, re-syncing from the server's 409 received_bytes echo; progress from bytes acked. +var fabUploadId = null; +var fabCancelled = false; +var fabNetErrText = 'A feltöltés megszakadt — ellenőrizze a kapcsolatot, majd próbálja újra.'; + +function csrfRawH() { + var el = document.querySelector('meta[name="csrf-token"]'); + return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/octet-stream'} : {'Content-Type': 'application/octet-stream'}; +} + +function fabPick() { + document.getElementById('fabFileInput').click(); +} + +function fabGB(b) { + return (b / 1073741824).toFixed(2); +} + +function fabProgress(done, total) { + var pct = total > 0 ? Math.floor(done * 100 / total) : 0; + document.getElementById('fabUpText').textContent = + 'Feltöltés: ' + pct + '% (' + fabGB(done) + ' / ' + fabGB(total) + ' GB)'; + document.getElementById('fabUpBar').style.width = pct + '%'; +} + +function fabFail(msg) { + document.getElementById('fabUpProgress').style.display = 'none'; + document.getElementById('fabUploadZone').style.display = 'block'; + var el = document.getElementById('fabUpError'); + el.textContent = msg; + el.style.display = 'block'; + fabUploadId = null; +} + +async function fabCancel() { + fabCancelled = true; + if (fabUploadId) { + try { + await fetch('/api/export/upload/abort', { + method: 'POST', headers: csrfH(), + body: JSON.stringify({upload_id: fabUploadId}) + }); + } catch(e) { /* best-effort — the server's idle timeout collects the rest */ } + } + fabUploadId = null; + document.getElementById('fabUpProgress').style.display = 'none'; + document.getElementById('fabUploadZone').style.display = 'block'; +} + +async function fabStart(file) { + if (!file) return; + document.getElementById('fabUpError').style.display = 'none'; + if (!file.name.toLowerCase().endsWith('.fab')) { + fabFail('Csak .fab fájl tölthető fel.'); + return; + } + fabCancelled = false; + document.getElementById('fabUploadZone').style.display = 'none'; + document.getElementById('fabUpProgress').style.display = 'block'; + fabProgress(0, file.size); + + try { + var resp = await fetch('/api/export/upload/init', { + method: 'POST', headers: csrfH(), + body: JSON.stringify({filename: file.name, size_bytes: file.size}) + }); + var data = await resp.json(); + if (!data.ok) { fabFail(data.error || fabNetErrText); return; } + fabUploadId = data.upload_id; + var chunkBytes = data.chunk_bytes; + + var offset = 0; + var retriedThisChunk = false; + while (offset < file.size) { + if (fabCancelled) return; + var r = null; + try { + r = await fetch('/api/export/upload/chunk?id=' + fabUploadId + '&offset=' + offset, { + method: 'POST', headers: csrfRawH(), + body: file.slice(offset, Math.min(offset + chunkBytes, file.size)) + }); + } catch(e) { + if (retriedThisChunk) { fabFail(fabNetErrText); return; } + retriedThisChunk = true; + continue; // same offset — the server only counts appended bytes + } + var d = await r.json().catch(function(){ return {}; }); + if (r.status === 409 && typeof d.received_bytes === 'number') { + if (retriedThisChunk) { fabFail(d.error || fabNetErrText); return; } + retriedThisChunk = true; + offset = d.received_bytes; // re-sync one step from the echo + fabProgress(offset, file.size); + continue; + } + if (!r.ok || !d.ok) { fabFail(d.error || fabNetErrText); return; } + offset = d.received_bytes; + retriedThisChunk = false; + fabProgress(offset, file.size); + } + if (fabCancelled) return; + + var fin = await fetch('/api/export/upload/finalize', { + method: 'POST', headers: csrfH(), + body: JSON.stringify({upload_id: fabUploadId}) + }); + var fd = await fin.json(); + if (!fd.ok) { fabFail(fd.error || fabNetErrText); return; } + fabUploadId = null; + document.getElementById('fabUpText').textContent = 'Feltöltés kész: ' + fd.filename; + document.getElementById('fabUpBar').style.width = '100%'; + // Refresh the bundle list through the EXISTING scan (the page render runs it). + setTimeout(function(){ window.location.reload(); }, 800); + } catch(e) { + fabFail(fabNetErrText); + } +} + +(function(){ + var input = document.getElementById('fabFileInput'); + input.addEventListener('change', function(){ fabStart(input.files[0]); input.value = ''; }); + var zone = document.getElementById('fabUploadZone'); + zone.addEventListener('dragover', function(e){ e.preventDefault(); zone.style.borderColor = 'var(--blue)'; }); + zone.addEventListener('dragleave', function(){ zone.style.borderColor = 'var(--line)'; }); + zone.addEventListener('drop', function(e){ + e.preventDefault(); + zone.style.borderColor = 'var(--line)'; + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) fabStart(e.dataTransfer.files[0]); + }); +})(); {{template "layout_end" .}}