6.5 KiB
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}insideServeExportAPI(inherits the main.goRequireAuth(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 requiresoffset == received(else 409 +received_bytesecho) and streams viaio.Copy; finalize = exact declared size (else 422 +.partdeleted) → 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 inmain.goafterSetAppExporter); 15-min idle timer aborts server-side. No client-side sha256 — deliberate, rationale in the file header (WebCrypto can't stream-hash multi-GB;.fabself-validates at import).controller/internal/appexport/estimate.go—diskFreeexported asDiskFree(REUSE.md row added); web seamuploadDiskFreepackage 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.slicesequential 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_SecurityGatesFAILED 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_OffsetMismatchRejectedFAILED with "replay: got 200, want 409". - F — naive overwrite (rename straight to the requested name):
TestFabUpload_CollisionLowestFreeSuffixFAILED withfirst 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 (--resolveonto 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/controlleris matched by a gitignore rule —main.gochanges needgit 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).