# REPORT — v0.128.0: chunked browser .fab upload on /import (tunnel-proof) **Date:** 2026-07-13 · **Version:** controller v0.128.0 (baseline v0.127.3 `59e4ca7` reconfirmed against live Gitea; local == origin/main) · Commit `db16371` on `main` · MinAgent unchanged (no agent coupling — the feature is controller-local fs + HTTP). Implements TASK-fab-browser-upload (ruling F3 2026-07-13: chunked). ## Step-0 probe — the Cloudflare edge cap, measured on the REAL tunnel (before any code) | Probe | Result | |---|---| | POST 120 MiB (zeros) → `https://felhom.demo-felhom.eu/step0-cap-probe` | **HTTP 413 "Payload Too Large" from the edge** (`Server: cloudflare`, `CF-RAY a1aa78cd1f04324d-VIE`, cloudflare-branded HTML body). Rejected on the declared Content-Length: only ~1.1 MB left the client, 0.41 s. | | POST 80 MiB, same target | **HTTP 302 → `/login?next=…`** — the controller's auth redirect, i.e. the origin was reached (edge passage PROVEN). 0.48 s; the origin answered before consuming the body (unauth redirect), so no full-upload throughput datum — the edge decision is header-based, which is the datum that matters. | Gotcha recorded: local DNS (192.168.0.250) overrides `felhom.demo-felhom.eu` to the LAN guest (192.168.0.139), and curl's default `Expect: 100-continue` lets the server answer before the body is sent — the honest probe needed `--resolve` onto Cloudflare's public IP (172.67.149.59) plus `-H "Expect:"`. **Outcome: cap confirmed at ~100 MB → `uploadChunkBytes` = 64 MiB (the spec's default), server-side per-request cap 96 MiB.** ## What shipped - `controller/internal/web/handler_export_upload.go` (new) — `POST /api/export/upload/{init,chunk,finalize,abort}` inside `ServeExportAPI` (inherits the main.go `RequireAuth(CsrfProtect(...))` mount — nothing added at the mux). Single-flight slot (netstorage-job shape); init sanitizes to a `[A-Za-z0-9._ -]` base name + mandatory `.fab`, free-space gate (declared + 1 GiB margin, Hungarian error with both numbers); chunk requires `offset == received` (else 409 + `received_bytes` echo) and streams via `io.Copy`; finalize = exact declared size (else 422 + `.part` deleted) → fsync → atomic rename, collision → lowest-free `"name (N).fab"` computed from the ORIGINAL name (never `(1)(1)`); abort deletes the `.part`. Startup GC sweeps `*.part-*` on every registered drive (wired in `main.go` after `SetAppExporter`); 15-min idle timer aborts server-side. No client-side sha256 — deliberate, rationale in the file header (WebCrypto can't stream-hash multi-GB; `.fab` self-validates at import). - `controller/internal/appexport/estimate.go` — `diskFree` exported as `DiskFree` (REUSE.md row added); web seam `uploadDiskFree` package var. - `controller/internal/web/templates/app_import.html` — upload zone above the bundle list (drag-and-drop + "Fájl kiválasztása", accept=".fab"), progress "Feltöltés: {pct}% ({done} / {total} GB)" + "Megszakítás", spec'd Hungarian error strings; `File.slice` sequential loop with one retry per chunk re-synced from the 409 echo; on success the page reloads (the EXISTING scan renders the row — no second lister). Copy-by-hand hint now names BOTH paths. Gates green: template_id, emoji, native_confirm, mojibake. ## Tests (all green; full module `go build && go vet && go test ./...` = 23 packages ok) | Scenario | Test | Result | |---|---|---| | A happy path (3 chunks byte-exact, existing scan lists it, no `.part` residue) | `TestFabUpload_HappyPath` | PASS | | B offset replay → 409 + echo, disk unchanged, re-sync continues | `TestFabUpload_OffsetMismatchRejected` | PASS | | C traversal neutered inside exports; ext/charset/hidden refused; oversize (faked statfs) → Hungarian msg with 3.0/2.0 GB; 2nd init → 409 | `TestFabUpload_SecurityGates` | PASS | | C route wrap (unauth POST → 401 on all four paths via the main.go-shaped mount) | `TestFabUpload_MountInheritsAuth` | PASS | | D finalize size mismatch → 422, `.part` deleted, slot freed | `TestFabUpload_FinalizeSizeMismatch` | PASS | | E startup GC removes stray `.part`, keeps real bundles; idle-expiry deletes + frees slot | `TestFabUpload_GCAndIdleTimeout` | PASS | | F collision → `app (1).fab`, re-run → `app (2).fab`, original bytes untouched | `TestFabUpload_CollisionLowestFreeSuffix` | PASS | | abort deletes `.part` + frees slot | `TestFabUpload_Abort` | PASS | **Red-proofs (§10, all run + reverted, `git diff` clean):** - **C** — sanitize bypassed (raw client filename used): `TestFabUpload_SecurityGates` FAILED with "sanitized upload must land inside the exports dir" (the file materialized at the traversal target `{drive}/evil.fab`). - **B** — offset check removed (append regardless): `TestFabUpload_OffsetMismatchRejected` FAILED with "replay: got 200, want 409". - **F** — naive overwrite (rename straight to the requested name): `TestFabUpload_CollisionLowestFreeSuffix` FAILED with `first collision landed as "app.fab", want "app (1).fab"`. ## Deploy + live verification - Image `gitea.dooplex.hu/admin/felhom-controller:0.128.0` (`sha256:4c943ea1…`) built on 180. - **Demo guest 9201**: `Up … (healthy)` on 0.128.0; selfupdate log confirms "Current version 0.128.0 is up to date"; no ERROR/FATAL. **Drill guest** (nested PVE 192.168.0.152, guest 9201): `Up … (healthy)` on 0.128.0. - Route live-checked on the deployed box: unauthenticated `POST /api/export/upload/init` → 401 JSON both via LAN and **through the real Cloudflare edge** (`--resolve` onto 172.67.149.59) — the new path is deployed and auth+CSRF-wrapped on the customer path. ## NOT live-validated (Viktor's ~5-minute leg) The end-to-end multi-GB browser upload through the real tunnel needs a dashboard login (CC has no customer session). Suggested test: export a real app to `.fab`, download it via the browser, re-upload it on `/import`, import it — the full circle. Everything beneath the login was exercised: edge cap probed, endpoints tested A–F, deployed route answers through the tunnel. ## Observations - The per-drive target picker was deliberately left out (v1 = default drive only, per §12); if a customer keeps exports on a non-default drive, the upload still lands on the default one and the scan shows both — acceptable, but a picker is the natural follow-up. - `controller/cmd/controller` is matched by a gitignore rule — `main.go` changes need `git add -f` (it IS tracked; add refuses the path without `-f`). - Startup GC logs only when it removes something; the clean deploy logged nothing (expected).