Skip to content

Commit e09f8d2

Browse files
committed
Download as zip
1 parent 2ef3827 commit e09f8d2

12 files changed

Lines changed: 818 additions & 38 deletions

File tree

web/nginx.conf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ server {
5656
add_header Cache-Control "public" always;
5757
}
5858

59+
# -------------------------
60+
# StreamSaver service worker
61+
# -------------------------
62+
location = /streamsaver/sw.js {
63+
try_files $uri =404;
64+
expires -1;
65+
access_log off;
66+
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
67+
add_header Service-Worker-Allowed "/streamsaver/" always;
68+
}
69+
5970
# -------------------------
6071
# Custom resources
6172
# -------------------------

web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@uiw/codemirror-themes": "4.23.6",
5151
"@uiw/react-codemirror": "^4.23.13",
5252
"@ungap/structured-clone": "^1.3.0",
53+
"@zip.js/zip.js": "^2.8.26",
5354
"ajv": "^8.17.1",
5455
"async-mutex": "^0.5.0",
5556
"axios": "^1.9.0",
@@ -78,6 +79,7 @@
7879
"react-dom": "^18.3.1",
7980
"run-exclusive": "^2.2.19",
8081
"screen-scaler": "^2.0.0",
82+
"streamsaver": "^2.0.6",
8183
"tsafe": "^1.8.12",
8284
"tss-react": "^4.9.18",
8385
"type-route": "1.1.0",

web/public/streamsaver/mitm.html

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<!--
2+
StreamSaver.js MITM page.
3+
This page registers sw.js and forwards each download stream MessageChannel to it.
4+
-->
5+
<script>
6+
let keepAlive = () => {
7+
keepAlive = () => {}
8+
const ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping'
9+
const interval = setInterval(() => {
10+
if (sw) {
11+
sw.postMessage('ping')
12+
} else {
13+
fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)))
14+
}
15+
}, 10000)
16+
}
17+
18+
let messages = []
19+
window.onmessage = event => messages.push(event)
20+
21+
let sw = null
22+
let scope = ''
23+
24+
function registerWorker () {
25+
return navigator.serviceWorker.getRegistration('./').then(swReg => {
26+
return swReg || navigator.serviceWorker.register('sw.js', { scope: './' })
27+
}).then(swReg => {
28+
const swRegTmp = swReg.installing || swReg.waiting
29+
30+
scope = swReg.scope
31+
32+
return (sw = swReg.active) || new Promise(resolve => {
33+
const onStateChange = () => {
34+
if (swRegTmp.state === 'activated') {
35+
swRegTmp.removeEventListener('statechange', onStateChange)
36+
sw = swReg.active
37+
resolve()
38+
}
39+
}
40+
41+
swRegTmp.addEventListener('statechange', onStateChange)
42+
})
43+
})
44+
}
45+
46+
function onMessage (event) {
47+
const { data, ports, origin } = event
48+
49+
if (!ports || !ports.length) {
50+
throw new TypeError("[StreamSaver] You didn't send a messageChannel")
51+
}
52+
53+
if (typeof data !== 'object') {
54+
throw new TypeError("[StreamSaver] You didn't send an object")
55+
}
56+
57+
data.origin = origin
58+
data.referrer = data.referrer || document.referrer || origin
59+
data.streamSaverVersion = new URLSearchParams(location.search).get('version')
60+
61+
if (!data.headers) {
62+
console.warn("[StreamSaver] pass `data.headers` to the service worker")
63+
} else {
64+
new Headers(data.headers)
65+
}
66+
67+
if (!data.pathname) {
68+
data.pathname = Math.random().toString().slice(-6) + '/' + data.filename
69+
}
70+
71+
data.pathname = data.pathname.replace(/^\/+/g, '')
72+
73+
const originWithoutProtocol = origin.replace(/(^\w+:|^)\/\//, '')
74+
data.url = new URL(`${scope + originWithoutProtocol}/${data.pathname}`).toString()
75+
76+
if (!data.url.startsWith(`${scope + originWithoutProtocol}/`)) {
77+
throw new TypeError('[StreamSaver] bad `data.pathname`')
78+
}
79+
80+
const transferable = data.readableStream
81+
? [ports[0], data.readableStream]
82+
: [ports[0]]
83+
84+
keepAlive()
85+
86+
return sw.postMessage(data, transferable)
87+
}
88+
89+
if (window.opener) {
90+
window.opener.postMessage('StreamSaver::loadedPopup', '*')
91+
}
92+
93+
if (navigator.serviceWorker) {
94+
registerWorker().then(() => {
95+
window.onmessage = onMessage
96+
messages.forEach(window.onmessage)
97+
})
98+
} else {
99+
keepAlive()
100+
}
101+
</script>

web/public/streamsaver/sw.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/* global self, ReadableStream, Response */
2+
3+
self.addEventListener('install', () => {
4+
self.skipWaiting()
5+
})
6+
7+
self.addEventListener('activate', event => {
8+
event.waitUntil(self.clients.claim())
9+
})
10+
11+
const map = new Map()
12+
13+
self.onmessage = event => {
14+
if (event.data === 'ping') {
15+
if (event.waitUntil) {
16+
event.waitUntil(Promise.resolve())
17+
}
18+
19+
return
20+
}
21+
22+
const data = event.data
23+
const downloadUrl = data.url || self.registration.scope + Math.random() + '/' + (typeof data === 'string' ? data : data.filename)
24+
const port = event.ports[0]
25+
const metadata = new Array(3)
26+
27+
const startDownload = () => {
28+
map.set(downloadUrl, metadata)
29+
port.postMessage({ download: downloadUrl })
30+
}
31+
32+
metadata[1] = data
33+
metadata[2] = port
34+
35+
if (event.data.readableStream) {
36+
metadata[0] = event.data.readableStream
37+
startDownload()
38+
} else if (event.data.transferringReadable) {
39+
port.onmessage = event => {
40+
port.onmessage = null
41+
metadata[0] = event.data.readableStream
42+
startDownload()
43+
}
44+
} else {
45+
metadata[0] = createStream(port)
46+
startDownload()
47+
}
48+
}
49+
50+
function createStream (port) {
51+
return new ReadableStream({
52+
start (controller) {
53+
port.onmessage = ({ data }) => {
54+
if (data === 'end') {
55+
return controller.close()
56+
}
57+
58+
if (data === 'abort') {
59+
controller.error('Aborted the download')
60+
return
61+
}
62+
63+
controller.enqueue(data)
64+
}
65+
},
66+
cancel (reason) {
67+
console.log('user aborted', reason)
68+
port.postMessage({ abort: true })
69+
}
70+
})
71+
}
72+
73+
self.onfetch = event => {
74+
const url = event.request.url
75+
76+
if (url.endsWith('/ping')) {
77+
return event.respondWith(new Response('pong'))
78+
}
79+
80+
const metadata = map.get(url)
81+
82+
if (!metadata) {
83+
return null
84+
}
85+
86+
const [stream, data, port] = metadata
87+
88+
map.delete(url)
89+
90+
const responseHeaders = new Headers({
91+
'Content-Type': 'application/octet-stream; charset=utf-8',
92+
'Content-Security-Policy': "default-src 'none'",
93+
'X-Content-Security-Policy': "default-src 'none'",
94+
'X-WebKit-CSP': "default-src 'none'",
95+
'X-XSS-Protection': '1; mode=block'
96+
})
97+
98+
const headers = new Headers(data.headers || {})
99+
100+
if (headers.has('Content-Length')) {
101+
responseHeaders.set('Content-Length', headers.get('Content-Length'))
102+
}
103+
104+
if (headers.has('Content-Disposition')) {
105+
responseHeaders.set('Content-Disposition', headers.get('Content-Disposition'))
106+
}
107+
108+
event.respondWith(new Response(stream, { headers: responseHeaders }))
109+
110+
port.postMessage({ debug: 'Download started' })
111+
}

0 commit comments

Comments
 (0)