aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 13:22:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 13:22:17 +0200
commitf211a13dc2e5dd82eaba49222171d6f29d858eb5 (patch)
tree222b9d55116179e35c3013239b7f8e1120907e91 /packages
parent87725dc7a2da27c2ca3b9e58af751f0e6f8c9de7 (diff)
downloadmeshbay-f211a13dc2e5dd82eaba49222171d6f29d858eb5.tar.gz
fix(hub): a downloaded file opens in a tab only under a type that runs nothing
Open is offered for PDFs, raster images, audio, video and plain text, typed from the name; HTML, SVG and the rest are not opened in the hub's origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js8
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py39
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py20
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)])