Browse Source

refactor: split bg requests.js (#1539)

tophf 3 years ago
parent
commit
13edba49ca

+ 1 - 0
src/background/index.js

@@ -19,6 +19,7 @@ import './utils/icon';
 import './utils/notifications';
 import './utils/script';
 import './utils/tabs';
+import './utils/tab-redirector';
 import './utils/tester';
 import './utils/update';
 

+ 190 - 0
src/background/utils/requests-core.js

@@ -0,0 +1,190 @@
+import { buffer2string, getUniqId, isEmpty, noop } from '#/common';
+import { forEachEntry } from '#/common/object';
+import ua from '#/common/ua';
+import { extensionRoot } from './init';
+
+let encoder;
+
+const VM_ORIGIN = extensionRoot.slice(0, -1);
+export const VM_VERIFY = getUniqId('VM-Verify');
+/** @typedef {{
+  anonymous: boolean,
+  blobbed: boolean,
+  cb: function(Object),
+  chunked: boolean,
+  coreId: number,
+  eventsToNotify: string[],
+  id: number,
+  noNativeCookie: boolean,
+  responseHeaders: string,
+  storeId: string,
+  tabId: number,
+  url: string,
+  xhr: XMLHttpRequest,
+}} VMHttpRequest */
+/** @type {Object<string,VMHttpRequest>} */
+export const requests = { __proto__: null };
+export const verify = { __proto__: null };
+export const FORBIDDEN_HEADER_RE = new RegExp(`^(proxy-|sec-|${[
+  'user-agent',
+  // https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
+  // https://cs.chromium.org/?q=file:cc+symbol:IsForbiddenHeader%5Cb
+  'accept-(charset|encoding)',
+  'access-control-request-(headers|method)',
+  'connection',
+  'content-length',
+  'cookie2?',
+  'date',
+  'dnt',
+  'expect',
+  'host',
+  'keep-alive',
+  'origin',
+  'referer',
+  'te',
+  'trailer',
+  'transfer-encoding',
+  'upgrade',
+  'via',
+].join('|')})$`, 'i');
+/** @type chrome.webRequest.RequestFilter */
+const API_FILTER = {
+  urls: ['<all_urls>'],
+  types: ['xmlhttprequest'],
+};
+const EXTRA_HEADERS = [
+  browser.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS,
+].filter(Boolean);
+const headersToInject = {};
+/** @param {chrome.webRequest.HttpHeader} header */
+const isVmVerify = header => header.name === VM_VERIFY;
+const isNotCookie = header => !/^cookie2?$/i.test(header.name);
+const isSendable = header => !isVmVerify(header)
+  && !(/^origin$/i.test(header.name) && header.value === VM_ORIGIN);
+const isSendableAnon = header => isSendable(header) && isNotCookie(header);
+const SET_COOKIE_RE = /^set-cookie2?$/i;
+const SET_COOKIE_VALUE_RE = /^\s*(?:__(Secure|Host)-)?([^=\s]+)\s*=\s*(")?([!#-+\--:<-[\]-~]*)\3(.*)/;
+const SET_COOKIE_ATTR_RE = /\s*;?\s*(\w+)(?:=(")?([!#-+\--:<-[\]-~]*)\2)?/y;
+const SAME_SITE_MAP = {
+  strict: 'strict',
+  lax: 'lax',
+  none: 'no_restriction',
+};
+const API_EVENTS = {
+  onBeforeSendHeaders: [
+    onBeforeSendHeaders, 'requestHeaders', 'blocking', ...EXTRA_HEADERS,
+  ],
+  onHeadersReceived: [
+    onHeadersReceived, 'responseHeaders', 'blocking', ...EXTRA_HEADERS,
+  ],
+};
+
+/** @param {chrome.webRequest.WebRequestHeadersDetails} details */
+function onHeadersReceived({ responseHeaders: headers, requestId, url }) {
+  const req = requests[verify[requestId]];
+  if (req) {
+    if (req.anonymous || req.storeId) {
+      headers = headers.filter(h => (
+        !SET_COOKIE_RE.test(h.name)
+        || !req.storeId
+        || setCookieInStore(h.value, req, url)
+      ));
+    }
+    req.responseHeaders = headers.map(encodeWebRequestHeader).join('');
+    return { responseHeaders: headers };
+  }
+}
+
+/** @param {chrome.webRequest.WebRequestHeadersDetails} details */
+function onBeforeSendHeaders({ requestHeaders: headers, requestId, url }) {
+  // only the first call during a redirect/auth chain will have VM-Verify header
+  const reqId = verify[requestId] || headers.find(isVmVerify)?.value;
+  const req = requests[reqId];
+  if (req) {
+    verify[requestId] = reqId;
+    req.coreId = requestId;
+    req.url = url; // remember redirected URL with #hash as it's stripped in XHR.responseURL
+    headers = (req.noNativeCookie ? headers.filter(isNotCookie) : headers)
+    .concat(headersToInject[reqId] || [])
+    .filter(req.anonymous ? isSendableAnon : isSendable);
+  }
+  return { requestHeaders: headers };
+}
+
+/**
+ * @param {string} headerValue
+ * @param {VMHttpRequest} req
+ * @param {string} url
+ */
+function setCookieInStore(headerValue, req, url) {
+  let m = SET_COOKIE_VALUE_RE.exec(headerValue);
+  if (m) {
+    const [, prefix, name, , value, optStr] = m;
+    const opt = {};
+    const isHost = prefix === 'Host';
+    SET_COOKIE_ATTR_RE.lastIndex = 0;
+    while ((m = SET_COOKIE_ATTR_RE.exec(optStr))) {
+      opt[m[1].toLowerCase()] = m[3];
+    }
+    const sameSite = opt.sameSite?.toLowerCase();
+    browser.cookies.set({
+      url,
+      name,
+      value,
+      domain: isHost ? undefined : opt.domain,
+      expirationDate: Math.max(0, +new Date(opt['max-age'] * 1000 || opt.expires)) || undefined,
+      httpOnly: 'httponly' in opt,
+      path: isHost ? '/' : opt.path,
+      sameSite: SAME_SITE_MAP[sameSite],
+      secure: url.startsWith('https:') && (!!prefix || sameSite === 'none' || 'secure' in opt),
+      storeId: req.storeId,
+    });
+  }
+}
+
+export function toggleHeaderInjector(reqId, headers) {
+  if (headers) {
+    // Adding even if empty so that the toggle-off `if` runs just once even when called many times
+    headersToInject[reqId] = headers;
+    // Listening even if `headers` is empty to get the request's id
+    API_EVENTS::forEachEntry(([name, [listener, ...options]]) => {
+      browser.webRequest[name].addListener(listener, API_FILTER, options);
+    });
+  } else if (reqId in headersToInject) {
+    delete headersToInject[reqId];
+    if (isEmpty(headersToInject)) {
+      API_EVENTS::forEachEntry(([name, [listener]]) => {
+        browser.webRequest[name].removeListener(listener);
+      });
+    }
+  }
+}
+
+/**
+ * Imitating https://developer.mozilla.org/docs/Web/API/XMLHttpRequest/getAllResponseHeaders
+ * Per the specification https://tools.ietf.org/html/rfc7230 the header name is within ASCII,
+ * but we'll try encoding it, if necessary, to handle invalid server responses.
+ */
+function encodeWebRequestHeader({ name, value, binaryValue }) {
+  return `${string2byteString(name)}: ${
+    binaryValue
+      ? buffer2string(binaryValue)
+      : string2byteString(value)
+  }\r\n`;
+}
+
+/**
+ * Returns a UTF8-encoded binary string i.e. one byte per character.
+ * Returns the original string in case it was already within ASCII.
+ */
+function string2byteString(str) {
+  if (!/[\u0080-\uFFFF]/.test(str)) return str;
+  if (!encoder) encoder = new TextEncoder();
+  return buffer2string(encoder.encode(str));
+}
+
+// Chrome 74-91 needs an extraHeaders listener at tab load start, https://crbug.com/1074282
+// We're attaching a no-op in non-blocking mode so it's very lightweight and fast.
+if (ua.chrome >= 74 && ua.chrome <= 91) {
+  browser.webRequest.onBeforeSendHeaders.addListener(noop, API_FILTER, EXTRA_HEADERS);
+}

+ 25 - 396
src/background/utils/requests.js

@@ -1,30 +1,13 @@
-import {
-  blob2base64, buffer2string, getUniqId, request, i18n, isEmpty, noop, sendTabCmd,
-  string2uint8array,
-} from '#/common';
-import { forEachEntry, objectPick } from '#/common/object';
+import { blob2base64, sendTabCmd, string2uint8array } from '#/common';
+import { forEachEntry, forEachValue, objectPick } from '#/common/object';
 import ua from '#/common/ua';
 import cache from './cache';
-import { isUserScript, parseMeta } from './script';
-import { extensionRoot } from './init';
 import { commands } from './message';
-
-const VM_ORIGIN = extensionRoot.slice(0, -1);
-const VM_VERIFY = 'VM-Verify';
-const CONFIRM_URL_BASE = `${extensionRoot}confirm/index.html#`;
-/** @type {Object<string,VMHttpRequest>} */
-const requests = {};
-const verify = {};
-const tabRequests = {};
-let encoder;
+import {
+  FORBIDDEN_HEADER_RE, VM_VERIFY, requests, toggleHeaderInjector, verify,
+} from './requests-core';
 
 Object.assign(commands, {
-  ConfirmInstall: confirmInstall,
-  async CheckInstallerTab(tabId, src) {
-    const tab = IS_FIREFOX && (src.url || '').startsWith('file:')
-      && await browser.tabs.get(tabId).catch(noop);
-    return tab && (tab.pendingUrl || tab.url || '').startsWith(CONFIRM_URL_BASE);
-  },
   /** @return {void} */
   HttpRequest(opts, src) {
     const { tab: { id: tabId }, frameId } = src;
@@ -35,8 +18,8 @@ Object.assign(commands, {
       eventsToNotify,
       xhr: new XMLHttpRequest(),
     };
-    (tabRequests[tabId] || (tabRequests[tabId] = {}))[id] = 1;
-    httpRequest(opts, src, res => requests[id] && (
+    // Returning will show JS exceptions during init phase in the tab console
+    return httpRequest(opts, src, res => requests[id] && (
       sendTabCmd(tabId, 'HttpRequested', res, { frameId })
     ));
   },
@@ -57,151 +40,6 @@ Object.assign(commands, {
   },
 });
 
-const specialHeaders = [
-  'user-agent',
-  // https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
-  // https://cs.chromium.org/?q=file:cc+symbol:IsForbiddenHeader%5Cb
-  'accept-charset',
-  'accept-encoding',
-  'access-control-request-headers',
-  'access-control-request-method',
-  'connection',
-  'content-length',
-  'cookie',
-  'cookie2',
-  'date',
-  'dnt',
-  'expect',
-  'host',
-  'keep-alive',
-  'origin',
-  'referer',
-  'te',
-  'trailer',
-  'transfer-encoding',
-  'upgrade',
-  'via',
-];
-// const tasks = {};
-const HeaderInjector = (() => {
-  /** @type chrome.webRequest.RequestFilter */
-  const apiFilter = {
-    urls: ['<all_urls>'],
-    types: ['xmlhttprequest'],
-  };
-  const EXTRA_HEADERS = [
-    browser.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS,
-  ].filter(Boolean);
-  const headersToInject = {};
-  /** @param {chrome.webRequest.HttpHeader} header */
-  const isVmVerify = ({ name }) => name.startsWith(VM_VERIFY) && (name in verify);
-  const isNotCookie = header => !/^cookie2?$/i.test(header.name);
-  const isSendable = header => !isVmVerify(header)
-    && !(/^origin$/i.test(header.name) && header.value === VM_ORIGIN);
-  const isSendableAnon = header => isSendable(header) && isNotCookie(header);
-  const RE_SET_COOKIE = /^set-cookie2?$/i;
-  const RE_SET_COOKIE_VALUE = /^\s*(?:__(Secure|Host)-)?([^=\s]+)\s*=\s*(")?([!#-+\--:<-[\]-~]*)\3(.*)/;
-  const RE_SET_COOKIE_ATTR = /\s*;?\s*(\w+)(?:=(")?([!#-+\--:<-[\]-~]*)\2)?/y;
-  const SAME_SITE_MAP = {
-    strict: 'strict',
-    lax: 'lax',
-    none: 'no_restriction',
-  };
-  /**
-   * @param {string} headerValue
-   * @param {VMHttpRequest} req
-   * @param {string} url
-   */
-  const setCookieInStore = (headerValue, req, url) => {
-    let m = RE_SET_COOKIE_VALUE.exec(headerValue);
-    if (m) {
-      const [, prefix, name, , value, optStr] = m;
-      const opt = {};
-      const isHost = prefix === 'Host';
-      RE_SET_COOKIE_ATTR.lastIndex = 0;
-      while ((m = RE_SET_COOKIE_ATTR.exec(optStr))) {
-        opt[m[1].toLowerCase()] = m[3];
-      }
-      const sameSite = opt.sameSite?.toLowerCase();
-      browser.cookies.set({
-        url,
-        name,
-        value,
-        domain: isHost ? undefined : opt.domain,
-        expirationDate: Math.max(0, +new Date(opt['max-age'] * 1000 || opt.expires)) || undefined,
-        httpOnly: 'httponly' in opt,
-        path: isHost ? '/' : opt.path,
-        sameSite: SAME_SITE_MAP[sameSite],
-        secure: url.startsWith('https:') && (!!prefix || sameSite === 'none' || 'secure' in opt),
-        storeId: req.storeId,
-      });
-    }
-  };
-  const apiEvents = {
-    onBeforeSendHeaders: {
-      options: ['requestHeaders', 'blocking', ...EXTRA_HEADERS],
-      /** @param {chrome.webRequest.WebRequestHeadersDetails} details */
-      listener({ requestHeaders: headers, requestId, url }) {
-        // only the first call during a redirect/auth chain will have VM-Verify header
-        const reqId = headers.find(isVmVerify)?.value || verify[requestId];
-        const req = reqId && requests[reqId];
-        if (reqId && req) {
-          verify[requestId] = reqId;
-          req.coreId = requestId;
-          req.url = url; // remember redirected URL with #hash as it's stripped in XHR.responseURL
-          headers = (req.noNativeCookie ? headers.filter(isNotCookie) : headers)
-          .concat(headersToInject[reqId] || [])
-          .filter(req.anonymous ? isSendableAnon : isSendable);
-        }
-        return { requestHeaders: headers };
-      },
-    },
-    onHeadersReceived: {
-      options: ['responseHeaders', 'blocking', ...EXTRA_HEADERS],
-      /** @param {chrome.webRequest.WebRequestHeadersDetails} details */
-      listener({ responseHeaders: headers, requestId, url }) {
-        const req = requests[verify[requestId]];
-        if (req) {
-          if (req.anonymous || req.storeId) {
-            headers = headers.filter(h => (
-              !RE_SET_COOKIE.test(h.name)
-              || !req.storeId
-              || setCookieInStore(h.value, req, url)
-            ));
-          }
-          req.responseHeaders = headers.map(encodeWebRequestHeader).join('');
-          return { responseHeaders: headers };
-        }
-      },
-    },
-  };
-  // Chrome 74-91 needs an extraHeaders listener at tab load start, https://crbug.com/1074282
-  // We're attaching a no-op in non-blocking mode so it's very lightweight and fast.
-  if (ua.chrome >= 74 && ua.chrome <= 91) {
-    browser.webRequest.onBeforeSendHeaders.addListener(noop, apiFilter, ['extraHeaders']);
-  }
-  return {
-    add(reqId, headers) {
-      // need to set the entry even if it's empty [] so that 'if' check in del() runs only once
-      headersToInject[reqId] = headers;
-      // need the listener to get the requestId
-      apiEvents::forEachEntry(([name, { listener, options }]) => {
-        browser.webRequest[name].addListener(listener, apiFilter, options);
-      });
-    },
-    del(reqId) {
-      if (reqId in headersToInject) {
-        delete headersToInject[reqId];
-        if (isEmpty(headersToInject)) {
-          apiEvents::forEachEntry(([name, { listener }]) => {
-            browser.webRequest[name].removeListener(listener);
-          });
-        }
-      }
-    },
-  };
-})();
-
 /* 1MB takes ~20ms to encode/decode so it doesn't block the process of the extension and web page,
  * which lets us and them be responsive to other events or user input. */
 const CHUNK_SIZE = 1e6;
@@ -302,12 +140,6 @@ function xhrCallbackWrapper(req) {
   };
 }
 
-function isSpecialHeader(lowerHeader) {
-  return specialHeaders.includes(lowerHeader)
-    || lowerHeader.startsWith('proxy-')
-    || lowerHeader.startsWith('sec-');
-}
-
 /**
  * @param {Object} opts
  * @param {chrome.runtime.MessageSender | browser.runtime.MessageSender} src
@@ -323,7 +155,6 @@ async function httpRequest(opts, src, cb) {
   req.anonymous = anonymous;
   const { xhr } = req;
   const vmHeaders = [];
-  const vmVerifyName = getUniqId(VM_VERIFY);
   // Firefox can send Blob/ArrayBuffer directly
   const chunked = !IS_FIREFOX && incognito;
   const blobbed = xhrType && !IS_FIREFOX && !incognito;
@@ -335,18 +166,16 @@ async function httpRequest(opts, src, cb) {
   // Both Chrome & FF need explicit routing of cookies in containers or incognito
   let shouldSendCookies = !anonymous && (incognito || IS_FIREFOX);
   xhr.open(opts.method || 'GET', url, true, opts.user || '', opts.password || '');
-  xhr.setRequestHeader(vmVerifyName, id);
-  verify[vmVerifyName] = null;
+  xhr.setRequestHeader(VM_VERIFY, id);
   if (contentType) xhr.setRequestHeader('Content-Type', contentType);
   opts.headers::forEachEntry(([name, value]) => {
-    const lowerName = name.toLowerCase();
-    if (isSpecialHeader(lowerName)) {
+    if (FORBIDDEN_HEADER_RE.test(name)) {
       vmHeaders.push({ name, value });
     } else {
       xhr.setRequestHeader(name, value);
     }
-    if (lowerName === 'cookie') {
-      shouldSendCookies = false;
+    if (shouldSendCookies) {
+      shouldSendCookies = !/^cookie$/i.test(name);
     }
   });
   xhr.responseType = (chunked || blobbed) && 'blob' || xhrType || 'text';
@@ -377,7 +206,7 @@ async function httpRequest(opts, src, cb) {
       });
     }
   }
-  HeaderInjector.add(id, vmHeaders);
+  toggleHeaderInjector(id, vmHeaders);
   const callback = xhrCallbackWrapper(req);
   req.eventsToNotify.forEach(evt => { xhr[`on${evt}`] = callback; });
   xhr.onloadend = callback; // always send it for the internal cleanup
@@ -385,11 +214,18 @@ async function httpRequest(opts, src, cb) {
 }
 
 /** @param {VMHttpRequest} req */
-function clearRequest(req) {
-  if (req.coreId) delete verify[req.coreId];
-  delete requests[req.id];
-  delete (tabRequests[req.tabId] || {})[req.id];
-  HeaderInjector.del(req.id);
+function clearRequest({ id, coreId }) {
+  delete verify[coreId];
+  delete requests[id];
+  toggleHeaderInjector(id, false);
+}
+
+export function clearRequestsByTabId(tabId) {
+  requests::forEachValue(req => {
+    if (req.tabId === tabId) {
+      commands.AbortRequest(req.id);
+    }
+  });
 }
 
 /** Polyfill for Chrome's inability to send complex types over extension messaging */
@@ -409,213 +245,6 @@ function decodeBody([body, type, wasBlob]) {
   return [body, type];
 }
 
-// Watch URL redirects
-// browser.webRequest.onBeforeRedirect.addListener(details => {
-//   const reqId = verify[details.requestId];
-//   if (reqId) {
-//     const req = requests[reqId];
-//     if (req) req.finalUrl = details.redirectUrl;
-//   }
-// }, {
-//   urls: ['<all_urls>'],
-//   types: ['xmlhttprequest'],
-// });
-
-// tasks are not necessary now, turned off
-// Stop redirects
-// browser.webRequest.onHeadersReceived.addListener(details => {
-//   const task = tasks[details.requestId];
-//   if (task) {
-//     delete tasks[details.requestId];
-//     if (task === 'Get-Location' && [301, 302, 303].includes(details.statusCode)) {
-//       const locationHeader = details.responseHeaders.find(
-//         header => header.name.toLowerCase() === 'location');
-//       const base64 = locationHeader && locationHeader.value;
-//       return {
-//         redirectUrl: `data:text/plain;charset=utf-8,${base64 || ''}`,
-//       };
-//     }
-//   }
-// }, {
-//   urls: ['<all_urls>'],
-//   types: ['xmlhttprequest'],
-// }, ['blocking', 'responseHeaders']);
-// browser.webRequest.onCompleted.addListener(details => {
-//   delete tasks[details.requestId];
-// }, {
-//   urls: ['<all_urls>'],
-//   types: ['xmlhttprequest'],
-// });
-// browser.webRequest.onErrorOccurred.addListener(details => {
-//   delete tasks[details.requestId];
-// }, {
-//   urls: ['<all_urls>'],
-//   types: ['xmlhttprequest'],
-// });
-
-async function confirmInstall({ code, from, url }, { tab = {} }) {
-  if (!code) code = (await request(url)).data;
-  // TODO: display the error in UI
-  if (!isUserScript(code)) throw i18n('msgInvalidScript');
-  cache.put(url, code, 3000);
-  const confirmKey = getUniqId();
-  const { active, id: tabId, incognito } = tab;
-  // Not testing tab.pendingUrl because it will be always equal to `url`
-  const canReplaceCurTab = (!incognito || IS_FIREFOX) && (
-    url === from
-    || cache.has(`autoclose:${tabId}`)
-    || /^(chrome:\/\/(newtab|startpage)\/|about:(home|newtab))$/.test(from));
-  /** @namespace VMConfirmCache */
-  cache.put(`confirm-${confirmKey}`, { incognito, url, from, tabId, ff: ua.firefox });
-  const confirmUrl = CONFIRM_URL_BASE + confirmKey;
-  const { windowId } = canReplaceCurTab
-    ? await browser.tabs.update(tabId, { url: confirmUrl })
-    : await commands.TabOpen({ url: confirmUrl, active: !!active }, { tab });
-  if (active && windowId !== tab.windowId) {
-    await browser.windows.update(windowId, { focused: true });
-  }
-}
-
-const whitelistRe = new RegExp(`^https://(${
-  [
-    'greasyfork\\.org/scripts/%/code/',
-    'openuserjs\\.org/install/%/',
-    'github\\.com/%/%/raw/%/',
-    'github\\.com/%/%/releases/%/download/',
-    'raw\\.githubusercontent\\.com(/%){3}/',
-    'gist\\.github\\.com/.*?/',
-  ].join('|')
-})%?\\.user\\.js([?#]|$)`.replace(/%/g, '[^/]*'));
-
-const blacklistRe = new RegExp(`^https://(${
-  [
-    '(gist\\.)?github\\.com',
-    'greasyfork\\.org',
-    'openuserjs\\.org',
-  ].join('|')
-})/`);
-
-const resolveVirtualUrl = url => (
-  `${extensionRoot}options/index.html#scripts/${+url.split('#')[1]}`
-);
-// FF can't intercept virtual .user.js URL via webRequest, so we redirect it explicitly
-const virtualUrlRe = IS_FIREFOX && new RegExp((
-  `^(view-source:)?(${extensionRoot.replace('://', '$&)?')}[^/]*\\.user\\.js#\\d+`
-));
-const maybeRedirectVirtualUrlFF = virtualUrlRe && ((tabId, src) => {
-  if (virtualUrlRe.test(src)) {
-    browser.tabs.update(tabId, { url: resolveVirtualUrl(src) });
-  }
-});
-if (virtualUrlRe) {
-  const listener = (tabId, { url }) => url && maybeRedirectVirtualUrlFF(tabId, url);
-  const apiEvent = browser.tabs.onUpdated;
-  const addListener = apiEvent.addListener.bind(apiEvent, listener);
-  try { addListener({ properties: ['url'] }); } catch (e) { addListener(); }
-}
-
-browser.tabs.onCreated.addListener((tab) => {
-  const { id, title, url } = tab;
-  /* Determining if this tab can be auto-closed (replaced, actually).
-     FF>=68 allows reading file: URL only in the tab's content script so the tab must stay open. */
-  if ((!url.startsWith('file:') || ua.firefox < 68)
-      && /\.user\.js([?#]|$)/.test(tab.pendingUrl || url)) {
-    cache.put(`autoclose:${id}`, true, 10e3);
-  }
-  if (virtualUrlRe && url === 'about:blank') {
-    maybeRedirectVirtualUrlFF(id, title);
-  }
-});
-
-browser.webRequest.onBeforeRequest.addListener((req) => {
-  const { method, tabId, url } = req;
-  if (method !== 'GET') {
-    return;
-  }
-  // open a real URL for simplified userscript URL listed in devtools of the web page
-  if (url.startsWith(extensionRoot)) {
-    return { redirectUrl: resolveVirtualUrl(url) };
-  }
-  if (!cache.has(`bypass:${url}`)
-  && (!blacklistRe.test(url) || whitelistRe.test(url))) {
-    maybeInstallUserJs(tabId, url);
-    return { redirectUrl: 'javascript:void 0' }; // eslint-disable-line no-script-url
-  }
-}, {
-  urls: [
-    // 1. *:// comprises only http/https
-    // 2. the API ignores #hash part
-    // 3. Firefox: onBeforeRequest does not work with file:// or moz-extension://
-    '*://*/*.user.js',
-    '*://*/*.user.js?*',
-    'file://*/*.user.js',
-    'file://*/*.user.js?*',
-    `${extensionRoot}*.user.js`,
-  ],
-  types: ['main_frame'],
-}, ['blocking']);
-
-async function maybeInstallUserJs(tabId, url) {
-  const { data: code } = await request(url).catch(noop) || {};
-  if (code && parseMeta(code).name) {
-    const tab = tabId >= 0 && await browser.tabs.get(tabId) || {};
-    confirmInstall({ code, url, from: tab.url }, { tab });
-  } else {
-    cache.put(`bypass:${url}`, true, 10e3);
-    if (tabId >= 0) browser.tabs.update(tabId, { url });
-  }
-}
-
 // In Firefox with production code of Violentmonkey, scripts can be injected before `tabs.onUpdated` is fired.
 // Ref: https://github.com/violentmonkey/violentmonkey/issues/1255
-
-browser.tabs.onRemoved.addListener((tabId) => {
-  clearRequestsByTabId(tabId);
-});
-
-export function clearRequestsByTabId(tabId) {
-  const set = tabRequests[tabId];
-  if (set) {
-    delete tabRequests[tabId];
-    set::forEachEntry(([id]) => commands.AbortRequest(id));
-  }
-}
-
-/**
- * Imitating https://developer.mozilla.org/docs/Web/API/XMLHttpRequest/getAllResponseHeaders
- * Per the specification https://tools.ietf.org/html/rfc7230 the header name is within ASCII,
- * but we'll try encoding it, if necessary, to handle invalid server responses.
- */
-function encodeWebRequestHeader({ name, value, binaryValue }) {
-  return `${string2byteString(name)}: ${
-    binaryValue
-      ? buffer2string(binaryValue)
-      : string2byteString(value)
-  }\r\n`;
-}
-
-/**
- * Returns a UTF8-encoded binary string i.e. one byte per character.
- * Returns the original string in case it was already within ASCII.
- */
-function string2byteString(str) {
-  if (!/[\u0080-\uFFFF]/.test(str)) return str;
-  if (!encoder) encoder = new TextEncoder();
-  return buffer2string(encoder.encode(str));
-}
-
-/** @typedef {{
-  anonymous: boolean,
-  blobbed: boolean,
-  cb: function(Object),
-  chunked: boolean,
-  coreId: number,
-  eventsToNotify: string[],
-  id: number,
-  noNativeCookie: boolean,
-  responseHeaders: string,
-  storeId: string,
-  tabId: number,
-  url: string,
-  xhr: XMLHttpRequest,
-}} VMHttpRequest */
+browser.tabs.onRemoved.addListener(clearRequestsByTabId);

+ 127 - 0
src/background/utils/tab-redirector.js

@@ -0,0 +1,127 @@
+import { request, noop, i18n, getUniqId } from '#/common';
+import ua from '#/common/ua';
+import cache from './cache';
+import { extensionRoot } from './init';
+import { commands } from './message';
+import { parseMeta, isUserScript } from './script';
+
+const CONFIRM_URL_BASE = `${extensionRoot}confirm/index.html#`;
+
+Object.assign(commands, {
+  async CheckInstallerTab(tabId, src) {
+    const tab = IS_FIREFOX && (src.url || '').startsWith('file:')
+      && await browser.tabs.get(tabId).catch(noop);
+    return tab && (tab.pendingUrl || tab.url || '').startsWith(CONFIRM_URL_BASE);
+  },
+  async ConfirmInstall({ code, from, url }, { tab = {} }) {
+    if (!code) code = (await request(url)).data;
+    // TODO: display the error in UI
+    if (!isUserScript(code)) throw i18n('msgInvalidScript');
+    cache.put(url, code, 3000);
+    const confirmKey = getUniqId();
+    const { active, id: tabId, incognito } = tab;
+    // Not testing tab.pendingUrl because it will be always equal to `url`
+    const canReplaceCurTab = (!incognito || IS_FIREFOX) && (
+      url === from
+      || cache.has(`autoclose:${tabId}`)
+      || /^(chrome:\/\/(newtab|startpage)\/|about:(home|newtab))$/.test(from));
+    /** @namespace VMConfirmCache */
+    cache.put(`confirm-${confirmKey}`, { incognito, url, from, tabId, ff: ua.firefox });
+    const confirmUrl = CONFIRM_URL_BASE + confirmKey;
+    const { windowId } = canReplaceCurTab
+      ? await browser.tabs.update(tabId, { url: confirmUrl })
+      : await commands.TabOpen({ url: confirmUrl, active: !!active }, { tab });
+    if (active && windowId !== tab.windowId) {
+      await browser.windows.update(windowId, { focused: true });
+    }
+  },
+});
+
+const whitelistRe = new RegExp(`^https://(${
+  [
+    'greasyfork\\.org/scripts/%/code/',
+    'openuserjs\\.org/install/%/',
+    'github\\.com/%/%/raw/%/',
+    'github\\.com/%/%/releases/%/download/',
+    'raw\\.githubusercontent\\.com(/%){3}/',
+    'gist\\.github\\.com/.*?/',
+  ].join('|')
+})%?\\.user\\.js([?#]|$)`.replace(/%/g, '[^/]*'));
+const blacklistRe = new RegExp(`^https://(${
+  [
+    '(gist\\.)?github\\.com',
+    'greasyfork\\.org',
+    'openuserjs\\.org',
+  ].join('|')
+})/`);
+const resolveVirtualUrl = url => (
+  `${extensionRoot}options/index.html#scripts/${+url.split('#')[1]}`
+);
+// FF can't intercept virtual .user.js URL via webRequest, so we redirect it explicitly
+const virtualUrlRe = IS_FIREFOX && new RegExp((
+  `^(view-source:)?(${extensionRoot.replace('://', '$&)?')}[^/]*\\.user\\.js#\\d+`
+));
+const maybeRedirectVirtualUrlFF = virtualUrlRe && ((tabId, src) => {
+  if (virtualUrlRe.test(src)) {
+    browser.tabs.update(tabId, { url: resolveVirtualUrl(src) });
+  }
+});
+
+async function maybeInstallUserJs(tabId, url) {
+  const { data: code } = await request(url).catch(noop) || {};
+  if (code && parseMeta(code).name) {
+    const tab = tabId >= 0 && await browser.tabs.get(tabId) || {};
+    commands.ConfirmInstall({ code, url, from: tab.url }, { tab });
+  } else {
+    cache.put(`bypass:${url}`, true, 10e3);
+    if (tabId >= 0) browser.tabs.update(tabId, { url });
+  }
+}
+
+if (virtualUrlRe) {
+  const listener = (tabId, { url }) => url && maybeRedirectVirtualUrlFF(tabId, url);
+  const apiEvent = browser.tabs.onUpdated;
+  const addListener = apiEvent.addListener.bind(apiEvent, listener);
+  try { addListener({ properties: ['url'] }); } catch (e) { addListener(); }
+}
+
+browser.tabs.onCreated.addListener((tab) => {
+  const { id, title, url } = tab;
+  /* Determining if this tab can be auto-closed (replaced, actually).
+     FF>=68 allows reading file: URL only in the tab's content script so the tab must stay open. */
+  if ((!url.startsWith('file:') || ua.firefox < 68)
+      && /\.user\.js([?#]|$)/.test(tab.pendingUrl || url)) {
+    cache.put(`autoclose:${id}`, true, 10e3);
+  }
+  if (virtualUrlRe && url === 'about:blank') {
+    maybeRedirectVirtualUrlFF(id, title);
+  }
+});
+
+browser.webRequest.onBeforeRequest.addListener((req) => {
+  const { method, tabId, url } = req;
+  if (method !== 'GET') {
+    return;
+  }
+  // open a real URL for simplified userscript URL listed in devtools of the web page
+  if (url.startsWith(extensionRoot)) {
+    return { redirectUrl: resolveVirtualUrl(url) };
+  }
+  if (!cache.has(`bypass:${url}`)
+  && (!blacklistRe.test(url) || whitelistRe.test(url))) {
+    maybeInstallUserJs(tabId, url);
+    return { redirectUrl: 'javascript:void 0' }; // eslint-disable-line no-script-url
+  }
+}, {
+  urls: [
+    // 1. *:// comprises only http/https
+    // 2. the API ignores #hash part
+    // 3. Firefox: onBeforeRequest does not work with file:// or moz-extension://
+    '*://*/*.user.js',
+    '*://*/*.user.js?*',
+    'file://*/*.user.js',
+    'file://*/*.user.js?*',
+    `${extensionRoot}*.user.js`,
+  ],
+  types: ['main_frame'],
+}, ['blocking']);