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).
This commit is contained in:
@@ -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=<basename>`.
|
||||
**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=<basename>`; **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 = `<DataDir>/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 = `<DataDir>/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-<random>` 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-<random> 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 "<stem> (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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,10 +8,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser .fab upload (v0.128.0) — chunked, because the Cloudflare tunnel caps request bodies (~100 MB) -->
|
||||
<div class="card" style="max-width:900px;margin-bottom:1rem">
|
||||
<div id="fabUploadZone" style="border:1px dashed var(--line);border-radius:var(--radius);padding:1.25rem;text-align:center;cursor:pointer" onclick="fabPick()">
|
||||
<p style="color:var(--text-2);margin:0 0 .75rem">.fab csomag feltöltése — húzza ide, vagy válassza ki a fájlt</p>
|
||||
<button type="button" class="btn btn-sm btn-outline">Fájl kiválasztása</button>
|
||||
<input type="file" id="fabFileInput" accept=".fab" style="display:none">
|
||||
</div>
|
||||
<div id="fabUpProgress" style="display:none;margin-top:1rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem">
|
||||
<span id="fabUpText" style="color:var(--text-2)">Feltöltés: 0%</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="fabCancel()">Megszakítás</button>
|
||||
</div>
|
||||
<div style="background:var(--bg-2);border-radius:var(--radius);height:6px;margin-top:.5rem;overflow:hidden">
|
||||
<div id="fabUpBar" style="background:var(--blue);height:100%;width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fabUpError" style="display:none;color:var(--crit);margin-top:.75rem"></div>
|
||||
</div>
|
||||
|
||||
{{if not .Bundles}}
|
||||
<div class="card" style="max-width:700px">
|
||||
<p style="color:var(--text-3)">Nem található .fab csomag a regisztrált tárolókon.</p>
|
||||
<p style="color:var(--text-3);font-size:.85rem">Exportálj egy alkalmazást az alkalmazás oldaláról, vagy másolj egy .fab fájlt a <code>{tároló}/felhom-data/exports/</code> könyvtárba.</p>
|
||||
<p style="color:var(--text-3);font-size:.85rem">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 <code>{tároló}/felhom-data/exports/</code> könyvtárba.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card" style="max-width:900px">
|
||||
@@ -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]);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
|
||||
Reference in New Issue
Block a user