Browse Source

fix: support FormData and Blob in GM_xmlhttpRequest

fix #126
Gerald 8 years ago
parent
commit
207142d2e9

+ 27 - 1
src/background/utils/requests.js

@@ -98,7 +98,9 @@ export function httpRequest(details, cb) {
     ]
     .forEach(evt => { xhr[`on${evt}`] = callback; });
     // req.finalUrl = details.url;
-    xhr.send(details.data);
+    const { data } = details;
+    const body = data ? decodeBody(data) : null;
+    xhr.send(body);
   } catch (e) {
     console.warn(e);
   }
@@ -117,6 +119,30 @@ export function abortRequest(id) {
   }
 }
 
+function decodeBody(obj) {
+  const { cls, value } = obj;
+  if (cls === 'formdata') {
+    const result = new FormData();
+    if (value) {
+      Object.keys(value).forEach(key => {
+        value[key].forEach(item => {
+          result.append(key, decodeBody(item));
+        });
+      });
+    }
+    return result;
+  }
+  if (['blob', 'file'].includes(cls)) {
+    const { type, name, lastModified } = obj;
+    const array = new Uint8Array(value.length);
+    for (let i = 0; i < value.length; i += 1) array[i] = value.charCodeAt(i);
+    const data = [array.buffer];
+    if (cls === 'file') return new File(data, name, { type, lastModified });
+    return new Blob(data, { type });
+  }
+  if (value) return JSON.parse(value);
+}
+
 // Watch URL redirects
 // browser.webRequest.onBeforeRedirect.addListener(details => {
 //   const reqId = verify[details.requestId];

+ 1 - 0
src/common/index.js

@@ -147,6 +147,7 @@ export function request(url, options = {}) {
       const res = getResponse(xhr, { status: -1 });
       reject(res);
     };
+    xhr.onabort = xhr.onerror;
     xhr.ontimeout = xhr.onerror;
     xhr.send(body);
   });

+ 78 - 25
src/injected/bridge.js

@@ -2,15 +2,26 @@
  * All functions to be injected into web page must be independent.
  * They must be assigned to `bridge` so that they can be serialized.
  */
-import { noop, getUniqId, postData } from './utils';
+import { getUniqId, postData, noop } from './utils';
 
 function post(data) {
   const bridge = this;
   bridge.postData(bridge.destId, data);
 }
 
-function bindEvents(src, dest) {
+function prepare(src, dest) {
   const bridge = this;
+  const { helpers } = bridge;
+  const arrayProto = Array.prototype;
+  const bindThis = func => (thisObj, ...args) => func.apply(thisObj, args);
+  helpers.forEach = bindThis(arrayProto.forEach);
+  helpers.map = bindThis(arrayProto.map);
+  helpers.indexOf = bindThis(arrayProto.indexOf);
+  helpers.includes = arrayProto.includes
+  ? bindThis(arrayProto.includes)
+  : (arr, item) => helpers.indexOf(arr, item) >= 0;
+  helpers.toString = bindThis(Object.prototype.toString);
+
   const { vmid } = bridge;
   const srcId = vmid + src;
   bridge.destId = vmid + dest;
@@ -20,36 +31,78 @@ function bindEvents(src, dest) {
   }, false);
 }
 
-// Array functions
-// Notice: avoid using prototype functions since they may be changed by page scripts
-function forEach(arr, func) {
-  const length = arr && arr.length;
-  for (let i = 0; i < length; i += 1) func(arr[i], i, arr);
-}
-function includes(arr, item) {
-  const length = arr && arr.length;
-  for (let i = 0; i < length; i += 1) {
-    if (arr[i] === item) return true;
+function encodeBody(body) {
+  const helpers = this;
+  const cls = helpers.getType(body);
+  let result;
+  if (cls === 'formdata') {
+    // FormData#keys is supported in Chrome >= 50
+    if (!body.keys) return {};
+    const promises = [];
+    const iterator = body.keys();
+    while (1) { // eslint-disable-line no-constant-condition
+      const item = iterator.next();
+      if (item.done) break;
+      const key = item.value;
+      const promise = Promise.all(body.getAll(key).map(value => helpers.encodeBody(value)))
+      .then(values => ({ key, values }));
+      promises.push(promise);
+    }
+    result = Promise.all(promises)
+    .then(items => items.reduce((res, item) => {
+      res[item.key] = item.values;
+      return res;
+    }, {}))
+    .then(value => ({ cls, value }));
+  } else if (helpers.includes(['blob', 'file'], cls)) {
+    const bufsize = 8192;
+    result = new Promise(resolve => {
+      const reader = new FileReader();
+      reader.onload = () => {
+        let value = '';
+        const array = new Uint8Array(reader.result);
+        for (let i = 0; i < array.length; i += bufsize) {
+          value += String.fromCharCode.apply(null, array.subarray(i, i + bufsize));
+        }
+        resolve({
+          cls,
+          value,
+          type: body.type,
+          name: body.name,
+          lastModified: body.lastModified,
+        });
+      };
+      reader.readAsArrayBuffer(body);
+    });
+  } else if (body) {
+    result = {
+      cls,
+      value: JSON.stringify(body),
+    };
   }
-  return false;
+  return Promise.resolve(result);
 }
-function map(arr, func) {
-  const bridge = this;
-  const res = [];
-  bridge.forEach(arr, (item, i) => {
-    res.push(func(item, i, arr));
-  });
-  return res;
+
+function initialize(src, dest) {
+  this.prepare(src, dest);
 }
 
 export default {
   postData,
   post,
   getUniqId,
-  forEach,
-  includes,
-  map,
-  noop,
-  bindEvents,
+  prepare,
+  initialize,
+  helpers: {
+    noop,
+    encodeBody,
+    getType(obj) {
+      const helpers = this;
+      const type = typeof obj;
+      if (type !== 'object') return type;
+      const typeString = helpers.toString(obj); // [object TYPENAME]
+      return typeString.slice(8, -1).toLowerCase();
+    },
+  },
   vmid: `VM_${getUniqId()}`,
 };

+ 2 - 7
src/injected/content.js

@@ -7,13 +7,12 @@ import { tabOpen, tabClose } from './tabs';
 const ids = [];
 const menus = [];
 
-const bridge = Object.assign({
-  initialize,
+const bridge = Object.assign({}, base, {
   getPopup,
   ids,
   menus,
   handle: handleContent,
-}, base);
+});
 
 export default bridge;
 
@@ -85,7 +84,3 @@ function handleContent(req) {
   const handle = handlers[req.cmd];
   if (handle) handle(req.data);
 }
-
-function initialize(src, dest) {
-  bridge.bindEvents(src, dest);
-}

+ 8 - 6
src/injected/index.js

@@ -1,5 +1,5 @@
 import 'src/common/browser';
-import { inject, objEncode, getUniqId, sendMessage } from './utils';
+import { inject, encodeObject, getUniqId, sendMessage } from './utils';
 import { onNotificationClick, onNotificationClose } from './notification';
 import { httpRequested } from './requests';
 import { tabClosed } from './tabs';
@@ -59,7 +59,7 @@ import webBridgeObj from './web';
     const contentId = getUniqId();
     const webId = getUniqId();
     const args = [
-      objEncode(webBridgeObj),
+      encodeObject(webBridgeObj),
       JSON.stringify(webId),
       JSON.stringify(contentId),
       JSON.stringify(Object.getOwnPropertyNames(window)),
@@ -68,10 +68,12 @@ import webBridgeObj from './web';
     bridge.initialize(contentId, webId);
     sendMessage({ cmd: 'GetInjected', data: location.href })
     .then(data => {
-      bridge.forEach(data.scripts, script => {
-        bridge.ids.push(script.id);
-        if (script.enabled) badge.number += 1;
-      });
+      if (data.scripts) {
+        data.scripts.forEach(script => {
+          bridge.ids.push(script.id);
+          if (script.enabled) badge.number += 1;
+        });
+      }
       bridge.post({ cmd: 'LoadScripts', data });
       badge.ready = true;
       bridge.getPopup();

+ 20 - 11
src/injected/utils.js

@@ -1,6 +1,4 @@
-import { sendMessage, noop } from 'src/common';
-
-export { sendMessage, noop };
+export { sendMessage, noop } from 'src/common';
 
 export function postData(destId, data) {
   // Firefox issue: data must be stringified to avoid cross-origin problem
@@ -20,14 +18,25 @@ export function inject(code) {
   }
 }
 
-export function objEncode(obj) {
-  const list = Object.keys(obj).map(name => {
-    const value = obj[name];
-    const jsonKey = JSON.stringify(name);
-    if (typeof value === 'function') return `${jsonKey}:${value.toString()}`;
-    return `${jsonKey}:${JSON.stringify(value)}`;
-  });
-  return `{${list.join(',')}}`;
+export function encodeObject(obj) {
+  if (Array.isArray(obj)) {
+    return obj.map(encodeObject).join(',');
+  }
+  if (typeof obj === 'function') {
+    let str = obj.toString();
+    const prefix = str.slice(0, str.indexOf('{'));
+    if (prefix.indexOf('=>') < 0 && prefix.indexOf('function ') < 0) {
+      // method definition
+      str = `function ${str}`;
+    }
+    return str;
+  }
+  if (obj && typeof obj === 'object') {
+    const pairs = Object.keys(obj)
+    .map(key => `${JSON.stringify(key)}:${encodeObject(obj[key])}`);
+    return `{${pairs.join(',')}}`;
+  }
+  return JSON.stringify(obj);
 }
 
 export function getUniqId() {

+ 37 - 26
src/injected/web.js

@@ -4,7 +4,7 @@
  */
 import base from './bridge';
 
-export default Object.assign({
+export default Object.assign({}, base, {
   utf8decode,
   getRequest,
   getTab,
@@ -16,7 +16,7 @@ export default Object.assign({
   initialize,
   state: 0,
   handle: handleWeb,
-}, base);
+});
 
 /**
  * http://www.webtoolkit.info/javascript-utf8.html
@@ -98,7 +98,8 @@ function onLoadScripts(data) {
   bridge.notif = {};
   bridge.ainject = {};
   bridge.version = data.version;
-  if (bridge.includes([
+  const { helpers } = bridge;
+  if (helpers.includes([
     'greasyfork.org',
   ], location.host)) {
     bridge.exposeVM();
@@ -110,7 +111,7 @@ function onLoadScripts(data) {
     setTimeout(run, 0, idle);
   };
   bridge.checkLoad = () => {
-    if (!bridge.state && bridge.includes(['interactive', 'complete'], document.readyState)) bridge.state = 1;
+    if (!bridge.state && helpers.includes(['interactive', 'complete'], document.readyState)) bridge.state = 1;
     if (bridge.state) bridge.load();
   };
   const listMap = {
@@ -118,7 +119,7 @@ function onLoadScripts(data) {
     'document-idle': idle,
     'document-end': end,
   };
-  bridge.forEach(data.scripts, script => {
+  helpers.forEach(data.scripts, script => {
     bridge.values[script.uri] = data.values[script.uri] || {};
     if (script && script.enabled) {
       const list = listMap[
@@ -135,9 +136,9 @@ function onLoadScripts(data) {
     const wrapper = bridge.wrapGM(script, data.cache);
     // Must use Object.getOwnPropertyNames to list unenumerable properties
     const wrapperKeys = Object.getOwnPropertyNames(wrapper);
-    const wrapperInit = bridge.map(wrapperKeys, name => `this["${name}"]=${name}`).join(';');
+    const wrapperInit = helpers.map(wrapperKeys, name => `this["${name}"]=${name}`).join(';');
     const codeSlices = [`${wrapperInit};with(this)!function(){`];
-    bridge.forEach(requireKeys, key => {
+    helpers.forEach(requireKeys, key => {
       const requireCode = data.require[key];
       if (requireCode) {
         codeSlices.push(requireCode);
@@ -150,7 +151,7 @@ function onLoadScripts(data) {
     codeSlices.push('}.call(this);');
     const code = codeSlices.join('\n');
     const name = script.custom.name || script.meta.name || script.id;
-    const args = bridge.map(wrapperKeys, key => wrapper[key]);
+    const args = helpers.map(wrapperKeys, key => wrapper[key]);
     const thisObj = wrapper.window || wrapper;
     const id = bridge.getUniqId();
     bridge.ainject[id] = [name, args, thisObj];
@@ -238,6 +239,7 @@ function runCode(name, func, args, thisObj) {
 
 function getRequest(arg) {
   const bridge = this;
+  const { helpers } = bridge;
   init();
   return bridge.getRequest(arg);
   function init() {
@@ -305,12 +307,10 @@ function getRequest(arg) {
   }
   function start(req, id) {
     const { details } = req;
-    const data = {
+    const payload = {
       id,
       method: details.method,
       url: details.url,
-      data: details.data,
-      // async: !details.synchronous,
       user: details.user,
       password: details.password,
       headers: details.headers,
@@ -318,10 +318,17 @@ function getRequest(arg) {
     };
     req.id = id;
     bridge.requests.map[id] = req;
-    if (bridge.includes(['arraybuffer', 'blob'], details.responseType)) {
-      data.responseType = 'blob';
+    if (helpers.includes(['arraybuffer', 'blob'], details.responseType)) {
+      payload.responseType = 'blob';
     }
-    bridge.post({ cmd: 'HttpRequest', data });
+    helpers.encodeBody(details.data)
+    .then(body => {
+      payload.data = body;
+      bridge.post({
+        cmd: 'HttpRequest',
+        data: payload,
+      });
+    });
   }
   function getFullUrl(url) {
     const a = document.createElement('a');
@@ -365,9 +372,10 @@ function wrapGM(script, cache) {
   } else {
     gm.window = bridge.getWrapper();
   }
-  if (!bridge.includes(grant, 'unsafeWindow')) grant.push('unsafeWindow');
-  if (!bridge.includes(grant, 'GM_info')) grant.push('GM_info');
-  if (bridge.includes(grant, 'window.close')) gm.window.close = () => { bridge.post({ cmd: 'TabClose' }); };
+  const { helpers } = bridge;
+  if (!helpers.includes(grant, 'unsafeWindow')) grant.push('unsafeWindow');
+  if (!helpers.includes(grant, 'GM_info')) grant.push('GM_info');
+  if (helpers.includes(grant, 'window.close')) gm.window.close = () => { bridge.post({ cmd: 'TabClose' }); };
   const resources = script.meta.resources || {};
   const dataEncoders = {
     o: val => JSON.stringify(val),
@@ -490,7 +498,8 @@ function wrapGM(script, cache) {
     },
     GM_log: {
       value(data) {
-        console.log(`[Violentmonkey][${script.meta.name || 'No name'}]`, data);  // eslint-disable-line no-console
+        // eslint-disable-next-line no-console
+        console.log(`[Violentmonkey][${script.meta.name || 'No name'}]`, data);
       },
     },
     GM_openInTab: {
@@ -547,7 +556,7 @@ function wrapGM(script, cache) {
       },
     },
   };
-  bridge.forEach(grant, name => {
+  helpers.forEach(grant, name => {
     const prop = gmFunctions[name];
     if (prop) addProperty(name, prop, gm);
   });
@@ -576,20 +585,21 @@ function wrapGM(script, cache) {
 }
 
 /**
- * @desc Wrap methods to prevent unexpected modifications.
+ * @desc Wrap helpers to prevent unexpected modifications.
  */
 function getWrapper() {
   // http://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects
   // http://developer.mozilla.org/docs/Web/API/Window
   const bridge = this;
   const wrapper = {};
-  bridge.forEach([
+  const { helpers } = bridge;
+  helpers.forEach([
     // `eval` should be called directly so that it is run in current scope
     'eval',
   ], name => {
     wrapper[name] = window[name];
   });
-  bridge.forEach([
+  helpers.forEach([
     // 'uneval',
     'isFinite',
     'isNaN',
@@ -667,7 +677,7 @@ function getWrapper() {
     });
   }
   // Wrap properties
-  bridge.forEach(bridge.props, name => {
+  helpers.forEach(bridge.props, name => {
     if (name in wrapper) return;
     if (name.slice(0, 2) === 'on') defineReactedProperty(name);
     else defineProtectedProperty(name);
@@ -677,8 +687,9 @@ function getWrapper() {
 
 function initialize(src, dest, props) {
   const bridge = this;
+  bridge.prepare(src, dest);
   bridge.props = props;
-  bridge.load = bridge.noop;
-  bridge.checkLoad = bridge.noop;
-  bridge.bindEvents(src, dest);
+  const { noop } = bridge.helpers;
+  bridge.load = noop;
+  bridge.checkLoad = noop;
 }