Files
felhom-controller/REPORT.md
T

93 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AF, 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).