v0.8.0: dep prune, memtest introspection fix, concurrent uploads, x-api-key
Dependency cleanup (ponytail audit): - Drop 14 unused dependency declarations across 7 crates; quick-xml and x509-parser leave the tree entirely (SAML cert/XML work is handled by bergshamra + roxmltree). Fixes: - introspect: drop the over-broad "microsoft" UTF-16 bulk-scan marker that mislabeled Secure-Boot-signed non-Windows bootables (memtest86, signed BSDs, firmware tools) as Windows — the string lives in their MS-signed EFI loader's FAT long-filename entries. INTROSPECT_REV 3 -> 4 re-probes existing local ISOs on startup so the bogus label clears on upgrade. - upload: begin_upload now reclaims an abandoned <id>.partial instead of rejecting the re-upload with "already uploading". Robust against browser refresh, tab close, and dropped connections (the chunked protocol can't resume a dead session anyway). Features: - Storage upload: multi-file + concurrent. Each dropped/selected .iso gets its own progress row and uploads independently; a single page-leave guard plus a pagehide keepalive-abort replace the old shared singletons. - Operator API key (x-api-key): a persisted key authenticates /api/* exactly like an operator session, for Postman/scripts. New core ApiKeyStore (generated on first run, regenerable), accepted in require_auth alongside the session cookie, surfaced in Settings -> Advanced with copy + regenerate and a usage reference. GET /api/api-key + POST /api/api-key/regenerate. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
27703c437a
commit
1c262a6d61
+131
-51
@@ -603,36 +603,85 @@
|
||||
|
||||
// ── Upload card ──
|
||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||
el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
|
||||
el('div', {}, ['Drop one or more ', el('strong', {}, '.iso'), ' files here, or click to choose.']),
|
||||
el('div', {style:'font-size:12px;margin-top:6px'},
|
||||
'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
|
||||
'Linux + Windows installers auto-detected on upload. Multiple files upload at once. Streaming, no 502s on big files.'),
|
||||
]);
|
||||
const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
|
||||
style:'display:none', id:'file'});
|
||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
||||
// v0.5.8: cancel button — shown only while an upload is in flight.
|
||||
const cancelUpload = el('button', {class:'danger', type:'button',
|
||||
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
|
||||
multiple:true, style:'display:none', id:'file'});
|
||||
// v0.8.0: one progress row per file, appended here. Replaces the
|
||||
// single shared bar/msg/cancel that a second concurrent upload used
|
||||
// to clobber.
|
||||
const uploadsList = el('div', {id:'uploads', style:'display:grid;gap:12px'});
|
||||
|
||||
// One page-leave guard + one tab-hide cleanup for the whole card,
|
||||
// registered only while ≥1 upload is in flight (added on 0→1, removed
|
||||
// on 1→0) so listeners never pile up across re-renders.
|
||||
let activeUploads = 0;
|
||||
const activeIds = new Set();
|
||||
const warnLeave = (e) => { if (activeUploads > 0) { e.preventDefault(); e.returnValue = ''; return ''; } };
|
||||
const abortOnHide = () => {
|
||||
// keepalive lets these DELETEs outlive the unload; the server also
|
||||
// reclaims an orphaned .partial on the next upload, so best-effort
|
||||
// is fine here.
|
||||
for (const id of activeIds) {
|
||||
try { fetch('/api/uploads/' + encodeURIComponent(id), {method:'DELETE', keepalive:true}); } catch (_) {}
|
||||
}
|
||||
};
|
||||
const addGuards = () => {
|
||||
window.addEventListener('beforeunload', warnLeave);
|
||||
window.addEventListener('pagehide', abortOnHide);
|
||||
};
|
||||
const removeGuards = () => {
|
||||
window.removeEventListener('beforeunload', warnLeave);
|
||||
window.removeEventListener('pagehide', abortOnHide);
|
||||
};
|
||||
|
||||
const failText = async (r) => {
|
||||
const text = (await r.text()).slice(0, 240);
|
||||
let hint = '';
|
||||
if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.';
|
||||
else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.';
|
||||
else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.';
|
||||
else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.';
|
||||
return 'HTTP ' + r.status + ' ' + text + hint;
|
||||
};
|
||||
|
||||
// Launch an upload per dropped/selected .iso. The browser's ~6
|
||||
// connections-per-origin cap naturally bounds how many stream at
|
||||
// once, so there's no hand-rolled queue. Non-.iso files are ignored.
|
||||
const startMany = (fileList) => {
|
||||
[...fileList].filter(f => /\.iso$/i.test(f.name)).forEach(uploadOne);
|
||||
};
|
||||
|
||||
drop.onclick = () => file.click();
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
|
||||
drop.addEventListener('drop', e => {
|
||||
e.preventDefault(); drop.classList.remove('hover');
|
||||
if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
|
||||
startMany(e.dataTransfer.files);
|
||||
});
|
||||
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
|
||||
// Reset value so re-selecting the same filename still fires onchange.
|
||||
file.onchange = () => { startMany(file.files); file.value = ''; };
|
||||
|
||||
// Chunked upload telemetry. The old browser path posted one huge
|
||||
// multipart body, which left operators staring at 0% when a reverse
|
||||
// proxy buffered or rejected the request before OpenPXE saw it. This
|
||||
// path writes small raw chunks; each acknowledged chunk advances the
|
||||
// bar and leaves a visible .partial file in the ISO directory.
|
||||
async function upload(f) {
|
||||
// One independent chunked upload with its own progress row. The old
|
||||
// browser path posted one huge multipart body, which left operators
|
||||
// staring at 0% when a reverse proxy buffered or rejected the request
|
||||
// before OpenPXE saw it. This path writes small raw chunks; each
|
||||
// acknowledged chunk advances the bar and leaves a visible .partial.
|
||||
async function uploadOne(f) {
|
||||
const started = Date.now();
|
||||
const bar = $('#bar');
|
||||
const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); };
|
||||
const bar = el('div', {class:'bar'});
|
||||
const prog = el('div', {class:'progress active'}, bar);
|
||||
const rowMsg = el('div', {class:'msg'});
|
||||
const cancelBtn = el('button', {class:'danger', type:'button', style:'margin-top:8px'}, 'Cancel');
|
||||
const row = el('div', {}, [
|
||||
el('div', {style:'font-weight:600;font-size:13px;margin-bottom:6px;word-break:break-all'}, f.name),
|
||||
prog, rowMsg, cancelBtn,
|
||||
]);
|
||||
uploadsList.appendChild(row);
|
||||
|
||||
const setStatus = (text, cls) => { rowMsg.textContent = text; rowMsg.className = 'msg ' + (cls || ''); };
|
||||
const update = (loaded, total, phase) => {
|
||||
const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100;
|
||||
bar.style.width = pct.toFixed(1) + '%';
|
||||
@@ -640,34 +689,24 @@
|
||||
const rate = loaded > 0 ? loaded / elapsed : 0;
|
||||
const remain = rate > 0 ? (total - loaded) / rate : 0;
|
||||
setStatus(
|
||||
phase + ' ' + f.name + ' - ' +
|
||||
phase + ' - ' +
|
||||
fmtBytes(loaded) + ' of ' + fmtBytes(total) +
|
||||
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
|
||||
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
|
||||
};
|
||||
const failText = async (r) => {
|
||||
const text = (await r.text()).slice(0, 240);
|
||||
let hint = '';
|
||||
if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.';
|
||||
else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.';
|
||||
else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.';
|
||||
else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.';
|
||||
return 'HTTP ' + r.status + ' ' + text + hint;
|
||||
};
|
||||
|
||||
let uploadId = null;
|
||||
// v0.5.8: cancel + leave-page guard. The AbortController stops the
|
||||
// in-flight chunk; the beforeunload listener warns the operator
|
||||
// that navigating away aborts the upload (the server-side partial
|
||||
// is then cleaned up by the DELETE in the catch below).
|
||||
// The AbortController stops this upload's in-flight chunk on Cancel.
|
||||
// The card-level beforeunload guard (added while activeUploads > 0)
|
||||
// warns on navigation; the server reclaims an abandoned .partial on
|
||||
// the next upload either way.
|
||||
const ac = new AbortController();
|
||||
let canceled = false;
|
||||
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
|
||||
window.addEventListener('beforeunload', warnLeave);
|
||||
cancelUpload.style.display = '';
|
||||
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
|
||||
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||
prog.classList.add('active');
|
||||
cancelBtn.onclick = () => { canceled = true; ac.abort(); };
|
||||
|
||||
activeUploads += 1;
|
||||
if (activeUploads === 1) addGuards();
|
||||
setStatus('Preparing ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||
bar.style.width = '1%';
|
||||
|
||||
try {
|
||||
@@ -678,6 +717,7 @@
|
||||
if (!begin.ok) throw new Error(await failText(begin));
|
||||
const session = await begin.json();
|
||||
uploadId = session.upload_id;
|
||||
activeIds.add(uploadId);
|
||||
const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));
|
||||
|
||||
let offset = Number(session.offset || 0);
|
||||
@@ -702,23 +742,29 @@
|
||||
} while (!finished);
|
||||
|
||||
setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
|
||||
render('storage');
|
||||
} catch (err) {
|
||||
if (uploadId) {
|
||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||
catch {}
|
||||
catch (_) {}
|
||||
}
|
||||
if (canceled || (err && err.name === 'AbortError')) {
|
||||
setStatus('Upload canceled — partial file discarded.', '');
|
||||
setStatus('Canceled — partial file discarded.', '');
|
||||
} else {
|
||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||
}
|
||||
} finally {
|
||||
window.removeEventListener('beforeunload', warnLeave);
|
||||
cancelUpload.style.display = 'none';
|
||||
cancelUpload.onclick = null;
|
||||
if (uploadId) activeIds.delete(uploadId);
|
||||
cancelBtn.style.display = 'none';
|
||||
prog.classList.remove('active');
|
||||
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
||||
activeUploads -= 1;
|
||||
if (activeUploads === 0) {
|
||||
removeGuards();
|
||||
// Refresh the table to show the new image(s) — but only if the
|
||||
// operator is still on Storage. isConnected goes false once
|
||||
// render() swapped the view, so a mid-upload tab change won't
|
||||
// yank them back here.
|
||||
if (uploadsList.isConnected) render('storage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1355,7 +1401,7 @@
|
||||
diskCard,
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
|
||||
el('div', {class:'body'}, [drop, file, uploadsList]),
|
||||
]),
|
||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||
@@ -1732,7 +1778,7 @@
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, me, sso, notify, docs] = await Promise.all([
|
||||
const [status, me, sso, notify, docs, apiKey] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/me').catch(() => ({})),
|
||||
getJSON('/api/sso').catch(() => ({
|
||||
@@ -1742,6 +1788,7 @@
|
||||
// fetches the notify config + API docs it needs too.
|
||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
getJSON('/api/api-key').catch(() => ({ key:'', header:'x-api-key' })),
|
||||
]);
|
||||
|
||||
// ── Account card (Forms admin credentials, v0.4.5).
|
||||
@@ -2055,7 +2102,7 @@
|
||||
// into a collapsible disclosure beneath the core settings cards —
|
||||
// webhook/email notifications + the API reference. Keeps Settings
|
||||
// clean by default while leaving the knobs one click away.
|
||||
const [notifyCard, apiCard] = views._advancedCards(notify, docs);
|
||||
const [notifyCard, apiCard] = views._advancedCards(notify, docs, apiKey);
|
||||
const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
|
||||
el('summary', {class:'advanced-summary'}, 'Advanced'),
|
||||
el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
|
||||
@@ -2070,7 +2117,7 @@
|
||||
// and the API reference. There is no longer an Advanced sidebar tab;
|
||||
// the Settings view folds these into a collapsible disclosure and
|
||||
// passes in the pre-fetched `notify` + `docs` payloads.
|
||||
_advancedCards: (notify, docs) => {
|
||||
_advancedCards: (notify, docs, apiKey) => {
|
||||
|
||||
// ── Notification config ──
|
||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||
@@ -2185,14 +2232,47 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (relocated from Settings) ──
|
||||
// ── API key + reference (relocated from Settings) ──
|
||||
const groups = docs.groups || [];
|
||||
|
||||
// v0.8.0: operator API key. Paste into the `x-api-key` request
|
||||
// header to drive /api/* from Postman / scripts without a browser
|
||||
// session (full operator access). Read + rotate via /api/api-key.
|
||||
const keyHeader = (apiKey && apiKey.header) || 'x-api-key';
|
||||
const keyField = el('input', {type:'text', readonly:true,
|
||||
value: (apiKey && apiKey.key) || '(unavailable)',
|
||||
style:'width:100%;font-family:var(--mono)'});
|
||||
const keyMsg = el('span', {class:'hint', style:'margin-left:10px'});
|
||||
const copyKey = el('button', {class:'ghost', type:'button', onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(keyField.value); keyMsg.textContent = 'Copied to clipboard.'; }
|
||||
catch { keyField.select(); keyMsg.textContent = 'Select the field and copy.'; }
|
||||
}}, 'Copy');
|
||||
const regenKey = el('button', {class:'danger', type:'button', style:'margin-left:8px',
|
||||
onclick: async () => {
|
||||
if (!confirm('Regenerate the API key? The current key stops working immediately and any client using it must be updated.')) return;
|
||||
const r = await postJSON('/api/api-key/regenerate', {});
|
||||
if (r.ok) { const j = await r.json(); keyField.value = j.key || ''; keyMsg.textContent = 'New key generated.'; }
|
||||
else { keyMsg.textContent = 'Regenerate failed: ' + (await r.text()).slice(0, 120); }
|
||||
}}, 'Regenerate');
|
||||
const apiKeyBlock = el('div', {style:'padding:16px;border-bottom:1px solid var(--border)'}, [
|
||||
el('label', {class:'field', style:'margin-bottom:10px'}, [
|
||||
el('span', {class:'name'}, 'API key'),
|
||||
keyField,
|
||||
el('span', {class:'hint'}, [
|
||||
'Send as the ', el('code', {}, keyHeader),
|
||||
' request header to call the API from Postman or scripts — full operator access, so keep it secret.',
|
||||
]),
|
||||
]),
|
||||
el('div', {}, [copyKey, regenKey, keyMsg]),
|
||||
]);
|
||||
|
||||
const apiCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'API reference'),
|
||||
el('h2', {}, 'API'),
|
||||
el('span', {class:'sub'},
|
||||
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
||||
]),
|
||||
apiKeyBlock,
|
||||
el('div', {class:'api-ref'},
|
||||
groups.length
|
||||
? groups.map(g => el('div', {class:'group'}, [
|
||||
|
||||
Reference in New Issue
Block a user