Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
09f051c
Test service worker
guerler Aug 1, 2025
914268c
Add prettier
guerler Aug 1, 2025
87ff8a1
Allow base path
guerler Aug 1, 2025
fd35775
Reject external requests
guerler Aug 1, 2025
5a168a5
Run prettier
guerler Aug 1, 2025
5fb5a78
Adjust logs
guerler Aug 2, 2025
60d1c10
Update
guerler Aug 2, 2025
e377f7d
Use insert
guerler Aug 2, 2025
3a3c31b
Add scope
guerler Aug 4, 2025
b66adb8
Fix scope
guerler Aug 4, 2025
e5fced4
Add examples
guerler Aug 5, 2025
7150ac4
Use stored index.html
guerler Aug 5, 2025
acc666e
Script path separation
guerler Aug 5, 2025
153b473
Add wait routine
guerler Aug 5, 2025
24de154
With injection test
guerler Aug 6, 2025
8b0babb
Attempt to autodetect base path
guerler Aug 9, 2025
76f3062
Pick parent level index.htm if multiple entries are available
guerler Aug 9, 2025
a73e4ff
Use base path locally
guerler Aug 9, 2025
5748c85
Add incoming dictionary handling
guerler Aug 9, 2025
815fede
Handle pathname
guerler Aug 9, 2025
90819df
Fallback for local run
guerler Aug 9, 2025
d9c5f15
Fix scope handling
guerler Aug 9, 2025
d7120b1
Simplify debug scope
guerler Aug 9, 2025
39523c5
Add xml
guerler Aug 9, 2025
178d844
Generalize sw configuration options
guerler Aug 9, 2025
eaa1216
Block all request to Galaxy service except GET requests within scope
guerler Aug 9, 2025
2ca083f
Add help text, test data and comments
guerler Aug 9, 2025
26353e4
Simplify error message
guerler Aug 9, 2025
6b704f5
Add main css styles
guerler Aug 9, 2025
5b143e5
Properly clear file system
guerler Aug 9, 2025
2e1fb56
Fix comment for consistency
guerler Aug 9, 2025
b809a5b
Clear cache when loading new static page
guerler Aug 9, 2025
a41d8c5
Update xml, scope content
guerler Aug 9, 2025
2a8862d
Update test labels
guerler Aug 9, 2025
3a84068
Fix description
guerler Aug 9, 2025
3669963
Update description, and logo
guerler Aug 9, 2025
9402ffd
Rename service script for clarity
guerler Aug 9, 2025
4e9bf18
Replace logo
guerler Aug 9, 2025
5c2ab03
Adjustments
guerler Aug 9, 2025
76b7634
Avoid race condition
guerler Aug 9, 2025
5f59ce7
Remove empty line
guerler Aug 9, 2025
dbe3b31
Remove empty line
guerler Aug 9, 2025
b9aedd4
Add error log, properly indent conditions
guerler Aug 9, 2025
6d2a0c8
Add cleanup handler
guerler Aug 10, 2025
4186dd3
Add cleanup handler
guerler Aug 10, 2025
48b6a66
Load data just once
guerler Aug 10, 2025
3f897a6
Use consistent naming
guerler Aug 10, 2025
4fcdfc2
Add project
guerler Aug 10, 2025
1f42bb8
Make package public
guerler Aug 10, 2025
f69c315
Update package version
guerler Aug 10, 2025
51bc833
Clear filesystem after unregistration
guerler Aug 10, 2025
38c82aa
Reload data upon page reload
guerler Aug 10, 2025
82373c4
Simplify logo
guerler Aug 10, 2025
7b0f74c
Fix qza only
guerler Aug 10, 2025
71b48b8
Revise log
guerler Aug 11, 2025
966e52c
Update package version
guerler Aug 11, 2025
5cac88c
Fix margin
guerler Sep 8, 2025
fe63ae5
Make visualization embeddable
guerler Oct 23, 2025
cdc01f0
Store test dataset as constant
guerler Oct 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/ghost/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
13 changes: 13 additions & 0 deletions packages/ghost/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
20 changes: 20 additions & 0 deletions packages/ghost/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@galaxyproject/ghost",
"private": false,
"version": "0.0.4",
"type": "module",
"files": [
"static"
],
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"prettier": "prettier --write 'package.json' '*.js' 'src/*.js' 'public/*.js'"
},
"devDependencies": {
"jszip": "^3.10.1",
"prettier": "^3.6.2",
"vite": "^7.0.4"
}
}
5 changes: 5 additions & 0 deletions packages/ghost/prettier.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
tabWidth: 4,
printWidth: 120,
bracketSameLine: true,
};
127 changes: 127 additions & 0 deletions packages/ghost/public/ghost-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const DESTROY = 5000;
const TIMEOUT = 100;

const virtualFS = new Map();

let ready = false;
let scope = "";

const getMimeType = (path) => {
const mimeMap = {
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".jsonp": "application/javascript",
".png": "image/png",
".jpg": "image/jpeg",
".html": "text/html",
".svg": "image/svg+xml",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
};
const ext = path.slice(path.lastIndexOf(".")).split("?")[0];
return mimeMap[ext] || "text/plain";
};

self.addEventListener("install", (event) => {
console.log("[GHOST] Installing...");
event.waitUntil(self.skipWaiting());
});

self.addEventListener("activate", (event) => {
console.log("[GHOST] Activating...");
event.waitUntil(clients.claim());
});

self.addEventListener("message", (event) => {
if (event.data.type === "CREATE") {
scope = event.data.scope;
const files = Object.entries(event.data.files);
for (const [path, content] of files) {
virtualFS.set(path, content);
}
console.log(`[GHOST] Serving ${files.length} files from ${scope}`);
ready = true;
}
if (event.data.type === "DESTROY") {
virtualFS.clear();
console.log("[GHOST] Destroyed filesystem");
ready = false;
}
});

self.addEventListener("fetch", (event) => {
console.log("[GHOST] Intercepting...");
const url = new URL(event.request.url);
const isSameOrigin = url.origin === self.location.origin;
const scoped = scope.endsWith("/") ? scope : scope + "/";

// Only handle same-origin requests
if (isSameOrigin) {
if (event.request.method === "GET" && url.pathname.startsWith(scoped)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get requests include the session key, this might be a concern. How does the interception work, can a script that's loaded through the worker makes requests using their own shipped networking library ? Have you tried exploiting this with malicious archives ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am experimenting with an option that injects the actual dataset and sandboxes the entire visualization. This would limit the file size, which is a drawback, but likely make it safe fyi.

event.respondWith(
(async () => {
const start = Date.now();
while (!ready) {
if (Date.now() - start > TIMEOUT * TIMEOUT) {
return new Response("Filesystem not ready", {
status: 503,
headers: { "Cache-Control": "no-store" },
});
}
await new Promise((r) => setTimeout(r, TIMEOUT));
}
const path = decodeURIComponent(url.pathname).split("?")[0];
if (virtualFS.has(path)) {
const mime = getMimeType(path);
const content = virtualFS.get(path);
return new Response(content, { headers: { "Content-Type": mime } });
}
return new Response("Not Found", { status: 404, statusText: "Not Found" });
})(),
);
} else {
// Block other same-origin requests (outside our scope)
console.error("[GHOST] Blocking same-origin request:", url.href);
event.respondWith(
new Response("Blocked by Service Worker", {
status: 403,
statusText: "Forbidden",
headers: { "Content-Type": "text/plain" },
}),
);
}
}
});

// Function to check if there are any active clients within the same scope
const checkClientsAndCleanup = async () => {
// Get all clients that are within the same scope
const clientsList = await clients.matchAll({
type: "window",
includeUncontrolled: true,
});

// Filter clients by scope
const scopedClients = clientsList.filter((client) => {
const clientUrl = new URL(client.url);
return clientUrl.pathname.startsWith(scope);
});

// If no scoped clients are present, start cleanup
if (scopedClients.length === 0) {
// Clear interval
clearInterval(clientCheckInterval);

// Cleanup logic: unregister service worker or any other resource cleanup
await self.registration.unregister();

// Clear filesystem
virtualFS.clear();
console.log("[GHOST] Service worker unregistered");
}
};

// Start checking for active clients
const clientCheckInterval = setInterval(checkClientsAndCleanup, DESTROY);
56 changes: 56 additions & 0 deletions packages/ghost/public/ghost.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE visualization SYSTEM "../../visualization.dtd">
<visualization name="GHost" embeddable="true">
<description>Virtual Webserver for HTML archives</description>
<data_sources>
<data_source>
<model_class>HistoryDatasetAssociation</model_class>
<test test_attr="ext">qzv</test>
</data_source>
</data_sources>
<params>
<param required="true">dataset_id</param>
</params>
<entry_point entry_point_type="script" src="index.js" css="index.css" />
<tests>
<test>
<param name="dataset_id" label="Raincloud Baseline" ftype="qzv" value="https://raw.githubusercontent.com/qiime2/q2-fmt/master/demo/raincloud-baseline0.qzv" />
</test>
<test>
<param name="dataset_id" label="FastTree Empire" ftype="qzv" value="https://raw.githubusercontent.com/caporaso-lab/q2view-visualizations/main/uu-fasttree-empire.qzv" />
</test>
</tests>
<help format="markdown"><![CDATA[
# What is GHost?

GHost is a Galaxy visualization for securely displaying interactive websites that are packaged
as QIIME 2 visualization files (`.qzv`) or similar ZIP archives containing an `index.html` file.
These archives often contain JavaScript, and data assets for rich, client-side exploration of analysis results.

When you open a `.qzv` dataset with GHost:

1. The file is fetched and unzipped in your browser.
2. The `index.html` is located and assets load through a **service worker** at a dedicated scope.
3. The service worker serves only the files from the archive, blocking all other same-origin requests
(for example, API calls) to prevent malicious content from interacting with Galaxy.

## Key Features

- **Secure Sandbox**: Blocks access to Galaxy APIs from within the visualization.
- **Dynamic Asset Loading**: No need to pre-extract archives on the server.
- **Format Agnostic**: Works with any ZIP that contains a self-contained HTML visualization.

## Security Notes

- Only GET requests within the visualization's virtual scope are served.
- All other same-origin requests are blocked with a `403 Forbidden` response.
- Cross-origin requests are unaffected but subject to normal browser CORS rules.

## When to Use GHost

GHost is ideal for:
- Viewing `.qzv` files generated by QIIME 2.
- Displaying any self-contained HTML/JS/CSS visualization from a ZIP.
- Running third-party visualizations in a safe, sandboxed environment inside Galaxy.
]]></help>
</visualization>
6 changes: 6 additions & 0 deletions packages/ghost/public/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions packages/ghost/src/main.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
:root {
--message-background: rgba(255, 255, 255, 0.9);
--message-color: rgba(0, 0, 0, 0.9);
}

#message {
background: var(--message-background);
border-radius: 4px;
color: var(--message-color);
display: none;
font-family: sans-serif;
font-weight: 100;
font-size: 0.85rem;
left: 1rem;
padding: 0.7rem;
position: absolute;
right: 1rem;
top: 1rem;
word-break: break-all;
z-index: 9999;
}

body {
margin: 0;
}
Loading