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:
2026-07-13 21:09:20 +02:00
parent 59e4ca70de
commit db16371f47
11 changed files with 1055 additions and 8 deletions
@@ -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 &mdash; 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" .}}