diff options
Diffstat (limited to 'packages')
5 files changed, 111 insertions, 24 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 50bca46..0d2b25e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -132,6 +132,42 @@ export async function freeName(name, exists) { return `${stem} (${Date.now()})${ext}`; } +// ── Opening a finished download in a tab ──────────────────────────────────── +// +// A tab opened on a `blob:` URL is a document of **this origin**: it can read +// the session in localStorage and the keys in IndexedDB. So only what a browser +// renders without running anything is opened there, under a type chosen here +// from the name — never the type the browser would guess from the bytes, which +// is how an `.html` or `.svg` a member put on a node would have run as the hub. +// Everything else is already on disk and is opened from there, not from here. +const OPENABLE = { + pdf: 'application/pdf', + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', + mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', + oga: 'audio/ogg', opus: 'audio/ogg', flac: 'audio/flac', wav: 'audio/wav', + mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', + mov: 'video/quicktime', + txt: 'text/plain;charset=utf-8', log: 'text/plain;charset=utf-8', + md: 'text/plain;charset=utf-8', csv: 'text/plain;charset=utf-8', +}; + +/** The type a file of this name is opened under, or null: not opened in a tab. */ +export function openableType(name) { + const m = /\.([A-Za-z0-9]+)$/.exec(String(name || '')); + return (m && OPENABLE[m[1].toLowerCase()]) || null; +} + +/** Open `blob` in a tab if its name allows it; false if it does not. */ +export function openInTab(blob, name) { + const type = openableType(name); + if (!type) return false; + const url = URL.createObjectURL(new Blob([blob], { type })); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + return true; +} + /** * Where this download should be written, if a folder has been granted. * @@ -182,14 +218,9 @@ export async function openTarget(filename) { // writing to it, and nothing is lost while nothing is written. pausable: true, // Reading it back is the only way a page can "open" a file it wrote: hand - // the bytes to a tab and let the browser decide what to do with them. No - // web page can start a desktop application, or show a file manager. - open: async () => { - const file = await handle.getFile(); - const url = URL.createObjectURL(file); - window.open(url, '_blank', 'noopener'); - setTimeout(() => URL.revokeObjectURL(url), 60000); - }, + // the bytes to a tab. No web page can start a desktop application, or show + // a file manager — and only a type that runs nothing is offered (above). + open: openableType(name) ? async () => openInTab(await handle.getFile(), name) : null, }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index 255c162..aa45a25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -432,7 +432,7 @@ async function downloadEntry(transfers, transport, gek, entry) { // Saved under a name every platform can write, and the row says so when that // is not the node's (portable-name.js, docs/MESHBAY_DESIGN.md §10). const saveName = portableName(entry.name); - const openRef = { url: null }; + const openRef = { blob: null }; let target = null; // The in-memory fallback's accumulator, held out here so a pause does not // discard what has already been decrypted. @@ -452,8 +452,10 @@ async function downloadEntry(transfers, transport, gek, entry) { if (target === false) return false; // `pausable` travels with the target, because only the target knows. The // in-memory fallback (a null target) is just an array and pauses fine. - return target ? { name: target.name, pausable: !!target.pausable } - : { pausable: true }; + // So does whether it can be opened: the app's own save opens through the + // OS, a page opens only a type that runs nothing (downloads.openInTab). + return target ? { name: target.name, pausable: !!target.pausable, openable: !!target.open } + : { pausable: true, openable: !!downloads.openableType(saveName) }; }, // After the target, never before: a granted slot has to be taken up within @@ -464,7 +466,7 @@ async function downloadEntry(transfers, transport, gek, entry) { kind: 'download', bytes: entry.size, chunks: totalChunks }), open: () => (target && target.open) ? target.open() - : (openRef.url ? window.open(openRef.url, '_blank') : undefined), + : (openRef.blob ? downloads.openInTab(openRef.blob, saveName) : undefined), // Kept across a pause: the chunks collected so far on the in-memory path. // A resumed run fills in from where it stopped rather than starting a @@ -495,7 +497,7 @@ async function downloadEntry(transfers, transport, gek, entry) { lease && lease.tr, from, memoryChunks); const blob = new Blob(chunks); _saveBlob(blob, saveName); - openRef.url = URL.createObjectURL(blob); + openRef.blob = blob; } }, }); @@ -551,7 +553,6 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE // totalBytes decides how this is delivered, but it is not the archive's // size — headers and the central directory come on top — so it is not // announced as a Content-Length that the download would then miss. - const zipOpenRef = { url: null }; let target = null; transfers.start({ @@ -571,7 +572,9 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE }))) { return false; } - return target ? { name: target.name } : true; + // An archive is never opened in a tab (downloads.openInTab); the app's + // own save opens it through the OS. + return target ? { name: target.name, openable: !!target.open } : { openable: false }; }, // **One** lease for the archive, not one per file. Dozens of leases for a @@ -580,8 +583,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE makeLease: () => transport.openTransfer({ kind: 'download', bytes: totalBytes, chunks: files.length }), - open: () => (target && target.open) ? target.open() - : (zipOpenRef.url ? window.open(zipOpenRef.url, '_blank') : undefined), + open: () => ((target && target.open) ? target.open() : undefined), run: async ({ signal, onProgress, lease }) => { const writable = target ? target.writable : null; const parts = writable ? null : []; @@ -611,7 +613,6 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE else { const blob = new Blob(parts, { type: 'application/zip' }); _saveBlob(blob, suggested); - zipOpenRef.url = URL.createObjectURL(blob); } } catch (err) { if (writable) await writable.abort().catch(() => {}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 0d012ed..7036c4f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -80,8 +80,9 @@ export class TransferStore { settled: it.samples.length > 2 && (it.samples[it.samples.length - 1].t - it.samples[0].t) >= 3000, percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0, - // Only for a file written into a folder the browser granted us: that is - // the one case where the page can read its own download back. + // Where the finished file can be read back — a granted folder, the + // in-memory floor, the app's own save — and only for a type that is + // safe to open (downloads.openInTab). canOpen: it.status === 'done' && typeof it.open === 'function', // Whether the target can be stopped and continued. False is the honest // answer for a service-worker stream, and the button is not drawn. @@ -225,6 +226,9 @@ export class TransferStore { // the button is offered where it works and nowhere else — a pause // that silently restarts from zero is worse than no pause. if (ready && ready.pausable) item.pausable = true; + // Likewise whether "Open" can do anything: a page opens in a tab + // only a type that runs nothing (downloads.openInTab). + if (ready && ready.openable === false) item.open = null; item.status = 'running'; this._emit(); } diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index c8c68a9..3716f4c 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -101,13 +101,44 @@ def test_the_open_action_reads_the_file_back(tmp_path): """ "Open" is the browser being handed the bytes, not a desktop application being started — no web page can do the second, and none can show a file - manager either. It is only offered for a file written into a granted folder, - since that is the one a page can read back. + manager either. A file written into a granted folder is read back from it. """ src = DOWNLOADS.read_text(encoding="utf-8") target = src[src.index("export async function openTarget"):] - assert "getFile()" in target and "window.open(" in target - assert "revokeObjectURL" in target, "the blob URL must not be leaked" + assert "openInTab(await handle.getFile(), name)" in target + opener = src[src.index("export function openInTab"):] + opener = opener[:opener.index("\n}\n")] + assert "window.open(" in opener + assert "revokeObjectURL" in opener, "the blob URL must not be leaked" + + +def test_a_tab_is_opened_only_for_a_type_that_runs_nothing(tmp_path): + """ + A tab on a `blob:` URL is a document of the hub's origin, with its session + and its keys. Measured in Chrome and Firefox: HTML typed `text/html` runs + there, the same bytes typed `text/plain` do not. So the type is chosen from + the name, never guessed from the bytes, and what could run is not opened. + """ + result = _run(""" +const opened = []; +globalThis.window = { open: (url) => opened.push(url) }; +globalThis.URL.createObjectURL = (blob) => `blob:${blob.type}`; +globalThis.URL.revokeObjectURL = () => {}; +globalThis.setTimeout = () => 0; // the revoke a minute later; node would wait for it +const bytes = new Blob(['<script>alert(1)</script>'], { type: 'text/html' }); +for (const name of ['page.html', 'page.HTM', 'logo.svg', 'feed.xml', 'x.xhtml', + 'tool.js', 'noext', 'notes.txt', 'film.MP4', 'scan.pdf', 'pic.jpeg']) { + say([name, M.openInTab(bytes, name)]); +} +say(opened); +""", tmp_path) + *verdicts, opened = result + refused = {name for name, ok in verdicts if not ok} + assert refused == {"page.html", "page.HTM", "logo.svg", "feed.xml", "x.xhtml", + "tool.js", "noext"} + assert opened == ["blob:text/plain;charset=utf-8", "blob:video/mp4", + "blob:application/pdf", "blob:image/jpeg"], \ + "a tab must get the type chosen from the name, not the blob's own" # ── Streaming to disk without the File System Access API ──────────────────── diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 1d0c0f0..c7a9dc5 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -918,3 +918,23 @@ def test_the_reason_is_translated_everywhere(): `transfers.not_pausable` in a tooltip rather than a sentence.""" for path in sorted((STATIC / "locales").glob("*.js")): assert "'transfers.not_pausable'" in path.read_text(encoding="utf-8"), path.name + + +def test_open_is_offered_only_when_the_target_says_it_can_be(tmp_path): + """A row whose file cannot be opened (a type that could run in the hub's + origin, an archive, a stream the page cannot read back) has no Open button, + rather than one that does nothing.""" + result = _run(""" +const store = new TransferStore(); +const done = (openable) => store.start({ + kind: 'download', name: 'f', total: 1, + prepare: async () => (openable === undefined ? {} : { openable }), + open: () => {}, + run: async ({ onProgress }) => { onProgress(1, 1); }, +}); +done(false); done(true); done(undefined); +await new Promise(r => setTimeout(r, 30)); +say(...store.list().map(it => [it.status, it.canOpen])); +""", tmp_path) + assert sorted(map(tuple, result)) == sorted([ + ("done", False), ("done", True), ("done", True)]) |