feat: files go in 4 MB pieces, several at once and each retried on its own, so they pass a proxy like Cloudflare and survive a bad link (0.0.8)

This commit is contained in:
redtea committed 2026-10-14 19:06:59 +03:00
1 parent 23e382bf7f
commit e9a845a6ea
8 files changed
+715 -252

No files matched your search

+63 -19
View File
@@ -398,6 +398,64 @@
// ---------- File viewer ----------
const fsURL = (what, path) => '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + what + '?path=' + encodeURIComponent(path);
// Sends a file to the client in pieces: each a request of its own (a proxy
// in front takes only so much at once), a few in flight together so a good
// link stays busy, and one the link drops is sent again alone. onFrac gets
// how much has gone; the answer is the client's, once it has written it.
const SEND_WORKERS = 4;
async function sendFile(path, blob, onFrac) {
let st;
try {
st = await fetch(fsURL('upload', path) + '&size=' + blob.size, {method: 'POST'}).then(r => r.json());
} catch (e) { return {ok: false, error: 'ошибка сети'}; }
if (!st.ok) return st;
const {id, chunk} = st.data;
const xurl = '/api/panel/fs/xfer/' + encodeURIComponent(id);
const n = Math.max(1, Math.ceil(blob.size / chunk));
const sent = new Array(n).fill(0);
const report = () => onFrac && onFrac(blob.size ? Math.min(1, sent.reduce((a, b) => a + b, 0) / blob.size) : 1);
let next = 0, failed = null;
const putPiece = (i, part) => new Promise(resolve => {
const x = new XMLHttpRequest();
x.open('PUT', xurl + '/' + i);
x.setRequestHeader('Content-Type', 'application/octet-stream');
x.upload.onprogress = ev => { sent[i] = ev.loaded; report(); };
x.onload = () => resolve({status: x.status, text: x.responseText});
x.onerror = x.ontimeout = () => resolve({status: 0});
x.timeout = 5 * 60 * 1000;
x.send(part);
});
async function worker() {
while (!failed && next < n) {
const i = next++, part = blob.slice(i * chunk, (i + 1) * chunk);
for (let tries = 0; !failed; ) {
const r = await putPiece(i, part);
if (r.status === 204) { sent[i] = part.size; report(); break; }
if (r.status === 410 || r.status === 400) { failed = (r.text || '').trim() || 'передача прервана'; break; }
sent[i] = 0; report();
if (r.status === 503) continue; // the client is busy: just again
if (++tries >= 30) { failed = 'сеть не держит передачу'; break; }
await new Promise(res => setTimeout(res, Math.min(tries, 10) * 1000));
}
}
}
await Promise.all(Array.from({length: Math.min(SEND_WORKERS, n)}, worker));
if (failed) {
fetch(xurl + '/cancel', {method: 'POST'}).catch(() => {});
return {ok: false, error: failed};
}
for (let tries = 0; ; ) {
try {
const r = await fetch(xurl + '/result', {cache: 'no-store'}).then(r => r.json());
if (r.done) return r.result;
tries = 0;
} catch (e) {
if (++tries >= 30) return {ok: false, error: 'нет ответа о записи файла'};
await new Promise(res => setTimeout(res, 2000));
}
}
}
// Writes a file as it is, with no base64: the request body is the file.
async function putFile(path, body) {
// The save button turns into a small spinner meanwhile, and takes no
@@ -408,10 +466,8 @@
btn.disabled = true;
setBusy(true);
try {
const r = await fetch(fsURL('upload', path), {method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body});
return (r.headers.get('Content-Type') || '').startsWith('application/json') ? await r.json() : {ok: false, error: 'HTTP ' + r.status};
} catch (e) { return {ok: false, error: 'ошибка сети'}; }
finally { setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
return await sendFile(path, new Blob([body]));
} finally { setBusy(false); btn.classList.remove('saving'); btn.disabled = false; }
}
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
@@ -960,22 +1016,10 @@
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); };
$('updlg-close').onclick = () => $('updlg').close();
// XHR rather than fetch: it reports how much of the request has gone out.
// The body is the file itself, which the panel passes on to the client as
// it comes, so the bar follows the whole way.
// The pieces go on to the client as they come, so the bar follows the
// whole way.
const UP_CAP = 512 * 1024 * 1024;
function writeWithProgress(path, file, onFrac) {
return new Promise(resolve => {
const x = new XMLHttpRequest();
x.open('POST', fsURL('upload', path));
x.setRequestHeader('Content-Type', 'application/octet-stream');
x.upload.onprogress = ev => { if (ev.lengthComputable) onFrac(ev.loaded / ev.total); };
x.upload.onload = () => onFrac(1);
x.onload = () => { try { resolve(JSON.parse(x.responseText)); } catch (e) { resolve({ok: false, error: 'HTTP ' + x.status}); } };
x.onerror = () => resolve({ok: false, error: 'ошибка сети'});
x.send(file);
});
}
const writeWithProgress = (path, file, onFrac) => sendFile(path, file, onFrac);
async function uploadFiles(files) {
clearTimeout(upDoneTimer);