add streaming download

This commit is contained in:
Kieran 2019-05-14 00:56:26 +08:00
parent c071c7d56a
commit 4a2c4edafd
9 changed files with 2316 additions and 33 deletions

View File

@ -1,6 +1,6 @@
{
"name": "void.cat",
"version": "1.2.0-beta1",
"version": "3.2.0-beta1",
"description": "Free file hosting website",
"repository": {
"type": "git",
@ -8,7 +8,8 @@
},
"scripts": {
"build": "sass src/css/style.scss dist/style.css && webpack-cli --entry ./src/js/index.js --mode production --output ./dist/bundle.js",
"debug": "webpack-cli --entry ./src/js/index.js --mode development --watch --output ./dist/bundle.js"
"debug": "webpack-cli --entry ./src/js/index.js --mode development --watch --output ./dist/bundle.js",
"debug-worker": "webpack-cli --entry ./src/js/Worker.js --mode development --watch --output ./sw.js"
},
"keywords": [
"upload",

26
src/js/Worker.js Normal file
View File

@ -0,0 +1,26 @@
import { Util, Log, Api } from './modules/Util.js';
import { ViewManager } from './modules/ViewManager.js';
import { FileDownloader } from './modules/FileDownloader.js';
const VoidFetch = function (event) {
let vm = new ViewManager();
let hs = vm.ParseFrag(new URL(event.request.url).pathname.substr(1));
if (hs !== null) {
Log.I(`Worker taking request: ${hs.id}`);
event.respondWith(async function () {
const client = await clients.get(event.clientId);
let fi = await Api.GetFileInfo(hs.id);
if (fi.ok) {
let fd = new FileDownloader(fi.data, hs.key, hs.iv);
return await fd.StreamResponse();
} else {
return Response.error();
}
}());
}
}
self.addEventListener('fetch', VoidFetch);

View File

@ -1,3 +1,11 @@
import * as App from './modules/App.js';
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function (registration) {
console.log(`ServiceWorker registration successful with scope: ${registration.scope}`);
}, function (err) {
console.error(`ServiceWorker registration failed: ${err}`);
});
}
App.Init();

View File

@ -1,6 +1,7 @@
import * as Const from './Const.js';
import { VBF } from './VBF.js';
import { XHR, Utils, Log } from './Util.js';
import { bytes_to_base64, HmacSha256, AES_CBC } from 'asmcrypto.js';
/**
* File download and decryption class
@ -23,6 +24,14 @@ function FileDownloader(fileinfo, key, iv) {
lastProgress: 0
};
/**
* Gets the url for downloading
* @returns {string} URL to download from
*/
this.GetLink = function () {
return (this.fileinfo.DownloadHost !== null ? `${self.location.protocol}//${this.fileinfo.DownloadHost}` : '') + `/${this.fileinfo.FileId}`;
};
/**
* Handles progress messages from file download
*/
@ -61,14 +70,147 @@ function FileDownloader(fileinfo, key, iv) {
}
};
/**
* Streams the file download response
* @returns {Promise<Response>} The response object to decrypt the download
*/
this.StreamResponse = async function () {
let link = this.GetLink();
let response = await fetch(link, {
mode: 'cors'
});
let void_download = {
body: response.body,
fileinfo: this.fileinfo,
aes: new AES_CBC(new Uint8Array(Utils.HexToArray(this.key)), new Uint8Array(Utils.HexToArray(this.iv)), true),
hmac: new HmacSha256(new Uint8Array(Utils.HexToArray(this.key))),
buff: new Uint8Array(),
pull(controller) {
if (this.reader === undefined) {
this.reader = this.body.getReader();
}
return (async function () {
Log.I(`${this.fileinfo.FileId} Starting..`);
var isStart = true;
var decOffset = 0;
var headerLen = 0;
var fileHeader = null;
var hmacBytes = null;
while (true) {
let { done, value } = await this.reader.read();
if (done) {
if (this.buff.byteLength > 0) {
//pad the remaining data with PKCS#7
var toDecrypt = null;
let padding = 16 - (this.buff.byteLength % 16);
if(padding !== 0){
let tmpBuff = new Uint8Array(this.buff.byteLength + padding);
tmpBuff.fill(padding);
tmpBuff.set(this.buff, 0);
this.buff = null;
this.buff = tmpBuff;
}
let decBytes = this.aes.AES_Decrypt_process(this.buff);
this.hmac.process(decBytes);
controller.enqueue(decBytes);
this.buff = null;
}
let last = this.aes.AES_Decrypt_finish();
this.hmac.process(last);
this.hmac.finish();
controller.enqueue(last);
//check hmac
let h1 = Utils.ArrayToHex(hmacBytes);
let h2 = Utils.ArrayToHex(this.hmac.result)
if (h1 === h2) {
Log.I(`HMAC verify ok!`);
} else {
Log.E(`HMAC verify failed (${h1} !== ${h2})`);
//controller.cancel();
//return;
}
Log.I(`${this.fileinfo.FileId} Download complete!`);
controller.close();
return;
}
var sliceStart = 0;
var sliceEnd = value.byteLength;
//!Slice this only once!!
var toDecrypt = value;
if (isStart) {
let header = VBF.ParseStart(value.buffer);
if (header !== null) {
Log.I(`${this.fileinfo.FileId} blob header version is ${header.version} uploaded on ${header.uploaded} (Magic: ${Utils.ArrayToHex(header.magic)})`);
sliceStart = VBF.SliceToEncryptedPart(header.version, value);
} else {
throw "Invalid VBF header";
}
} else if (fileHeader != null && decOffset + toDecrypt.byteLength + headerLen + 2 >= fileHeader.len) {
sliceEnd -= 32; //hash is on the end (un-encrypted)
hmacBytes = toDecrypt.slice(sliceEnd);
}
const GetAdjustedLen = function () {
return sliceEnd - sliceStart;
};
//decrypt
//append last remaining buffer if any
if (this.buff.byteLength > 0) {
let tmpd = new Uint8Array(this.buff.byteLength + GetAdjustedLen());
tmpd.set(this.buff, 0);
tmpd.set(toDecrypt.slice(sliceStart, sliceEnd), this.buff.byteLength);
sliceEnd += this.buff.byteLength;
toDecrypt = tmpd;
this.buff = new Uint8Array();
}
let blkRem = GetAdjustedLen() % 16;
if (blkRem !== 0) {
//save any remaining data into our buffer
this.buff = toDecrypt.slice(sliceEnd - blkRem, sliceEnd);
sliceEnd -= blkRem;
}
let encBytes = toDecrypt.slice(sliceStart, sliceEnd);
let decBytes = this.aes.AES_Decrypt_process(encBytes);
decOffset += decBytes.byteLength;
//read header
if (isStart) {
headerLen = new Uint16Array(decBytes.slice(0, 2))[0];
let header = new TextDecoder('utf-8').decode(decBytes.slice(2, 2 + headerLen));
Log.I(`${this.fileinfo.FileId} got header ${header}`);
fileHeader = JSON.parse(header);
decBytes = decBytes.slice(2 + headerLen);
}
//Log.I(`${this.fileinfo.FileId} Decrypting ${toDecrypt.byteLength} bytes, got ${decBytes.byteLength} bytes`);
this.hmac.process(decBytes);
controller.enqueue(decBytes);
isStart = false;
}
}.bind(this))();
}
}
let sr = new ReadableStream(void_download);
return new Response(sr);
};
/**
* Downloads the file
* @returns {Promise<File>} The loaded and decripted file
*/
this.DownloadFile = async function () {
let link = (this.fileinfo.DownloadHost !== null ? `${window.location.protocol}//${this.fileinfo.DownloadHost}` : '') + `/${this.fileinfo.FileId}`;
let link = this.GetLink();
Log.I(`Starting download from: ${link}`);
if(this.fileinfo.IsLegacyUpload) {
if (this.fileinfo.IsLegacyUpload) {
return {
isLegacy: true,
name: this.fileinfo.LegacyFilename,

View File

@ -412,7 +412,7 @@ function FileUpload(file, host) {
this.self.HandleProgress('state-load-start');
this.offset = 0;
this.chunkSize = 16 * 1024;
this.aes = new AES_CBC(this.self.key, this.self.iv);
this.aes = new AES_CBC(this.self.key, this.self.iv, true);
this.hmac = new HmacSha256(this.self.key);
//encode the header to bytes for encryption

View File

@ -21,9 +21,22 @@ export const Log = {
* @returns {Promise<XMLHttpRequest>} The completed request
*/
export async function JsonXHR(method, url, data) {
return await XHR(method, url, JSON.stringify(data), {
'Content-Type': 'application/json'
});
if ('fetch' in self) {
let resp = await fetch(url, {
method: method,
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json"
}
});
return {
response: await resp.text()
};
} else {
return await XHR(method, url, JSON.stringify(data), {
'Content-Type': 'application/json'
});
}
};
/**

View File

@ -41,7 +41,7 @@ const VBF = {
return upload_payload;
},
/**
* Creates a VBF2 file with the specified encryptedData
* @param {BufferSource} hash
@ -98,6 +98,21 @@ const VBF = {
}
},
/**
* Returns a position to slice from for the start of encrypted data
* @param {number} version
* @param {ArrayBuffer} blob
* @returns {number} The position to slice from
*/
SliceToEncryptedPart: function (version, blob) {
switch (version) {
case 1:
return 37;
case 2:
return 12;
}
},
/**
* Parses the header of the raw file
* @param {ArrayBuffer} data - Raw data from the server
@ -127,6 +142,38 @@ const VBF = {
magic
};
}
},
/**
* Parses a buffer as the start of a VBF file
* @param {ArrayBuffer} data - The start of a VBF file
* @returns {*} VBF info object
*/
ParseStart: function (data) {
let version = new Uint8Array(data)[0];
if (version === 1 && data.byteLength >= 37) {
let hmac = data.slice(1, 33);
let uploaded = new DataView(data.slice(33, 37)).getUint32(0, true);
return {
version,
hmac,
uploaded,
magic: null
};
} else if (version === 2 && data.byteLength >= 12) {
let magic = data.slice(1, 8);
let uploaded = new DataView(data.slice(8, 12)).getUint32(0, true);
return {
version,
hmac: null,
uploaded,
magic
};
}
return null;
}
};

View File

@ -5,27 +5,45 @@ import { base64_to_bytes } from 'asmcrypto.js';
/**
* @constructor Creates an instance of the ViewManager
*/
export function ViewManager() {
function ViewManager() {
this.id = null;
this.key = null;
this.iv = null;
this.ParseUrlHash = function () {
if (window.location.hash.indexOf(':') !== -1) {
let hs = window.location.hash.substr(1).split(':');
this.id = hs[0];
this.key = hs[1];
this.iv = hs[2];
} else if (window.location.hash.length === Utils.Base64Len(52) + 1) { //base64 encoded #id:key:iv
let hs = base64_to_bytes(window.location.hash.substr(1));
this.id = Utils.ArrayToHex(hs.slice(0, 20));
this.key = Utils.ArrayToHex(hs.slice(20, 36));
this.iv = Utils.ArrayToHex(hs.slice(36));
/**
* Parse URL hash fragment and return its components
* @param {string} frag - The Url hash fragment excluding the #
* @returns {*} - The details decoded from the hash
*/
this.ParseFrag = function (frag) {
if (frag.indexOf(':') !== -1) {
let hs = frag.split(':');
return {
id: hs[0],
key: hs[1],
iv: hs[2]
}
} else if (frag.length === Utils.Base64Len(52)) { //base64 encoded id:key:iv
let hs = base64_to_bytes(frag);
return {
id: Utils.ArrayToHex(hs.slice(0, 20)),
key: Utils.ArrayToHex(hs.slice(20, 36)),
iv: Utils.ArrayToHex(hs.slice(36))
};
}
return null;
};
/**
* Loads the view for downloading files
*/
this.LoadView = async function () {
this.ParseUrlHash();
let uh = this.ParseFrag(window.location.hash.substr(1));
if (uh !== null) {
this.id = uh.id;
this.key = uh.key;
this.iv = uh.iv;
}
let fi = await Api.GetFileInfo(this.id);
@ -39,6 +57,23 @@ export function ViewManager() {
}
};
/**
* Starts the browser download action
* @param {string} url - The url to download
* @param {string?} name - The filename to donwload
*/
this.DownloadLink = function(url, name) {
var dl_link = document.createElement('a');
dl_link.href = url;
if (name !== undefined) {
dl_link.download = name;
}
dl_link.style.display = "none";
let lnk = document.body.appendChild(dl_link);
lnk.click();
document.body.removeChild(lnk);
};
this.ShowPreview = async function (fileinfo) {
let cap_info = await Api.CaptchaInfo();
@ -54,7 +89,35 @@ export function ViewManager() {
nelm.querySelector('.view-key').textContent = this.key;
nelm.querySelector('.view-iv').textContent = this.iv;
}
nelm.querySelector('.btn-download').addEventListener('click', function () {
nelm.querySelector('.btn-download').addEventListener('click', async function () {
//detect if the service worker is installed
if ('serviceWorker' in navigator) {
let swreg = await navigator.serviceWorker.getRegistration();
if (swreg !== null) {
Log.I(`Service worker detected, using ${swreg.scope} for download..`);
let elm_bar_label = document.querySelector('.view-download-progress div:nth-child(1)');
let elm_bar = document.querySelector('.view-download-progress div:nth-child(2)');
navigator.serviceWorker.addEventListener('message', event => {
let msg = event.data;
switch(msg.type) {
case "progress": {
elm_bar.style.width = `${100 * msg.value}%`;
elm_bar_label.textContent = `${(100 * msg.value).toFixed(0)}%`;
break;
}
case "info": {
document.querySelector('.view-download-label-speed').textContent = msg.value;
break;
}
}
});
let link = (this.fileinfo.DownloadHost !== null ? `${window.location.protocol}//${this.fileinfo.DownloadHost}` : '') + `/${window.location.hash.substr(1)}`;
this.self.DownloadLink(link);
return;
}
}
let fd = new FileDownloader(this.fileinfo, this.self.key, this.self.iv);
fd.onprogress = function (x) {
this.elm_bar.style.width = `${100 * x}%`;
@ -88,15 +151,7 @@ export function ViewManager() {
fd.DownloadFile().then(function (file) {
if (file !== null) {
var objurl = file.isLegacy !== undefined ? file.url : URL.createObjectURL(file.blob);
var dl_link = document.createElement('a');
dl_link.href = objurl;
if (file.isLegacy === undefined) {
dl_link.download = file.name;
}
dl_link.style.display = "none";
let lnk = document.body.appendChild(dl_link);
lnk.click();
document.body.removeChild(lnk);
DownloadLink(objurl, file.isLegacy === undefined ? file.name : undefined);
}
}).catch(function (err) {
alert(err);
@ -115,4 +170,6 @@ export function ViewManager() {
document.body.appendChild(st);
}
};
};
};
export { ViewManager };

1989
sw.js Normal file

File diff suppressed because one or more lines are too long