使用 Manifest V3 在 chrome 扩展中将 FormData/File 对象从内容脚本传递到后台脚本

Passing FormData/File Object from content script to background script in chrome extension with Manifest V3

我正在构建一个 chrome 扩展,我从用户那里获取一个文件作为输入并将其传递给我的 background.js(如果是清单 v3,则为服务工作者)以将其保存到我的后端。由于内容脚本阻止了跨源请求,因此我必须将其传递给我的 background.js 并使用 FETCH API 来保存文件。当我将 FormDataFile 对象传递给 chrome.runtime.sendMessage API 它使用 JSON 序列化并且我在 background.js 中收到的是空的目的。请参阅以下代码段。

//content-script.js

attachFile(event) {
 let file = event.target.files[0];

 // file has `File` object uploaded by the user with required contents. 
 chrome.runtime.sendMessage({ message: 'saveAttachment', attachment: file }); 
}

//background.js

chrome.runtime.onMessage.addListener((request, sender) => {
 if (request.message === 'saveAttachment') {
   let file = request.attachment; //here the value will be a plain object  {}
 }
});

即使我们从内容脚本中传递 FormData,也会发生同样的情况。

我参考了旧 Whosebug 问题建议的多个解决方案,使用 URL.createObjectURL(myfile); 并将 URL 传递给我的 background.js 并获取相同的文件。而 FETCH API 不支持 blob URL 来获取,并且 XMLHttpRequest 在 service worker 中也不支持 here 所推荐的。有人可以帮我解决这个问题吗?这种行为让我很受阻。

目前只有Firefox可以直接传输这些类型。 Chrome 或许可以在 future.

中完成

解决方法 1.

手动将对象的内容序列化为字符串,如果长度超过 64MB 消息大小限制,则可能在多条消息中发送,然后在后台脚本中重建对象。下面是一个没有拆分的简化示例,改编自 Violentmonkey。它相当慢(50MB 的编码和解码需要几秒钟)所以你可能想编写自己的版本,在内容脚本中构建一个 multipart/form-data 字符串,然后直接在后台脚本的 fetch 中发送它。

  • 内容脚本:

    async function serialize(src) {
      const cls = Object.prototype.toString.call(src).slice(8, -1);
      switch (cls) {
        case 'FormData': {
          return {
            cls,
            value: await Promise.all(Array.from(src.keys(), async key => [
              key,
              await Promise.all(src.getAll(key).map(serialize)),
            ])),
          };
        }
        case 'Blob':
        case 'File':
          return new Promise(resolve => {
            const { name, type, lastModified } = src;
            const reader = new FileReader();
            reader.onload = () => resolve({
              cls, name, type, lastModified,
              value: reader.result.slice(reader.result.indexOf(',') + 1),
            });
            reader.readAsDataURL(src);
          });
        default:
          return src == null ? undefined : {
            cls: 'json',
            value: JSON.stringify(src),
          };
      }
    }
    
  • 后台脚本:

    function deserialize(src) {
      switch (src.cls) {
        case 'FormData': {
          const fd = new FormData();
          for (const [key, items] of src.value) {
            for (const item of items) {
              fd.append(key, deserialize(item));
            }
          }
          return fd;
        }
        case 'Blob':
        case 'File': {
          const { type, name, lastModified } = src;
          const binStr = atob(src.value);
          const arr = new Uint8Array(binStr.length);
          for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
          const data = [arr.buffer];
          return src.cls === 'file'
            ? new File(data, name, {type, lastModified})
            : new Blob(data, {type});
        }
        case 'json':
          return JSON.parse(src.value);
      }
    }
    

解决方法 2。

使用 iframe 指向通过 web_accessible_resources 公开的扩展 中的 html 文件。 iframe 将能够执行扩展程序可以执行的所有操作,例如发出 CORS 请求。 File/Blob 和 other cloneable types can be transferred directly from the content script via postMessage。这些消息会暴露给页面中的任何脚本 运行,因此我们必须使用 chrome.runtime 消息添加请求的授权,这是安全的(直到有人找到通过 side 破坏内容脚本的方法-通道攻击,如 Spectre)。

警告!该站点(或其他扩展程序)可以随时删除 iframe。

  • manifest.json:

    {
      "web_accessible_resources": [{
        "resources": ["sender.html"],
        "matches": ["<all_urls>"],
        "use_dynamic_url": true
      }],
      "host_permissions": [
        "https://your.backend.api.host/"
      ],
    

    请注意 use_dynamic_url 尚未实施。

  • content.js:

    var iframe;
    /**
     * @param {string} url
     * @param {'text'|'blob'|'json'|'arrayBuffer'|'formData'} [type]
     * @param {FetchEventInit} [init]
     */
    async function makeRequest(url, type = 'text', init) {
      if (!iframe || !document.contains(iframe)) {
        iframe = document.createElement('iframe');
        iframe.src = chrome.runtime.getURL('sender.html');
        iframe.style.cssText = 'display: none !important';
        document.body.appendChild(iframe);
        await new Promise(resolve => (iframe.onload = resolve));
      }
      const id = `${Math.random}.${performance.now()}`;
      const fWnd = iframe.contentWindow;
      const fOrigin = new URL(iframe.src).origin;
      fWnd.postMessage('authorize', fOrigin);
      await new Promise(resolve => {
        chrome.runtime.onMessage.addListener(function _(msg, sender, respond) {
          if (msg === 'authorizeRequest') {
            chrome.runtime.onMessage.removeListener(_);
            respond({id, url});
            resolve();
          }
        });
      });
      fWnd.postMessage({id, type, init}, fOrigin);
      return new Promise(resolve => {
        window.addEventListener('message', function onMessage(e) {
          if (e.source === fWnd && e.data?.id === id) {
            window.removeEventListener('message', onMessage);
            resolve(e.data.result);
          }
        });
      });
    }
    
  • sender.html:

    <script src="sender.js"></script>
    
  • sender.js:

    const authorizedRequests = new Map();
    window.onmessage = async e => {
      if (e.source !== parent) return;
      if (e.data === 'authorize') {
        chrome.tabs.getCurrent(tab => {
          chrome.tabs.sendMessage(tab.id, 'authorizeRequest', r => {
            authorizedRequests.set(r.id, r.url);
            setTimeout(() => authorizedRequests.delete(r.id), 60e3);
          });
        });
      } else if (e.data?.id) {
        const {id, type, init} = e.data;
        const url = authorizedRequests.get(id);
        if (url) {
          authorizedRequests.delete(id);
          const result = await (await fetch(url, init))[type];
          parent.postMessage({id, result}, '*', type === 'arrayBuffer' ? [result] : []);
        }
      }
    };
    

我有一个更好的解决方案:您实际上可以将 Blob 存储在 IndexedDB 中。

// client side (browser action or any page)
import { openDB } from 'idb';

const db = await openDB('upload', 1, {
  upgrade(openedDB) {
    openedDB.createObjectStore('files', {
      keyPath: 'id',
      autoIncrement: true,
    });
  },
});
await db.clear('files');

const fileID = await db.add('files', {
  uploadURL: 'https://yours3bucketendpoint',
  blob: file,
});

navigator.serviceWorker.controller.postMessage({
  type: 'UPLOAD_MY_FILE_PLEASE',
  payload: { fileID }
});


// Background Service worker
addEventListener('message', async (messageEvent) => {
  if (messageEvent.data?.type === 'UPLOAD_MY_FILE_PLEASE') {
    const db = await openDB('upload', 1);
    const file = await db.get('files', messageEvent.data?.payload?.fileID);
    const blob = file.blob;
    const uploadURL = file.uploadURL;
    
    // it's important here to use self.fetch
    // so the service worker stays alive as long as the request is not finished
    const response = await self.fetch(uploadURL, {
      method: 'put',
      body: blob,
    });
    if (response.ok) {
      // Bravo!
    }
  }
});

我找到了另一种将文件从内容页面(或弹出页面)传递给服务工作者的方法。 但是,可能,它并不适合所有情况,

您可以 intercept a fetch request 从服务工作者的内容或弹出页面发送。然后你可以通过service-worker发送这个请求,也可以修改

popup.js:

// simple fetch, but with a header indicating that the request should be intercepted
fetch(url, {
    headers: {
        'Some-Marker': 'true',
    },
});

background.js:

self.addEventListener('fetch', (event) => {
    // You can check that the request should be intercepted in other ways, for example, by the request URL
    if (event.request.headers.get('Some-Marker')) {
        event.respondWith((async () => {
            // event.request contains data from the original fetch that was sent from the content or popup page.
            // Here we make a request already in the background.js (service-worker page) and then we send the response to the content page, if it is still active
            // Also here you can modify the request hoy you want
            const result = await self.fetch(event.request);
            return result;
        })());
    }
    return null;
});