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:
1 parent
23e382bf7f
commit
e9a845a6ea
8 files changed
+715
-252
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user