Browse Source

feat: allow headers to be sent with `webContents.downloadURL()` (#39562)

feat: allow headers to be sent with webContents.downloadURL()

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Shelley Vohr <[email protected]>
trop[bot] 1 year ago
parent
commit
bf1dbaffcc

+ 1 - 1
docs/api/session.md

@@ -1311,7 +1311,7 @@ The API will generate a [DownloadItem](download-item.md) that can be accessed
 with the [will-download](#event-will-download) event.
 
 **Note:** This does not perform any security checks that relate to a page's origin,
-unlike [`webContents.downloadURL`](web-contents.md#contentsdownloadurlurl).
+unlike [`webContents.downloadURL`](web-contents.md#contentsdownloadurlurl-options).
 
 #### `ses.createInterruptedDownload(options)`
 

+ 3 - 1
docs/api/web-contents.md

@@ -1046,9 +1046,11 @@ const win = new BrowserWindow()
 win.loadFile('src/index.html')
 ```
 
-#### `contents.downloadURL(url)`
+#### `contents.downloadURL(url[, options])`
 
 * `url` string
+* `options` Object (optional)
+  * `headers` Record<string, string> (optional) - HTTP request headers.
 
 Initiates a download of the resource at `url` without navigating. The
 `will-download` event of `session` will be triggered.

+ 3 - 1
docs/api/webview-tag.md

@@ -280,9 +280,11 @@ if the page fails to load (see
 Loads the `url` in the webview, the `url` must contain the protocol prefix,
 e.g. the `http://` or `file://`.
 
-### `<webview>.downloadURL(url)`
+### `<webview>.downloadURL(url[, options])`
 
 * `url` string
+* `options` Object (optional)
+  * `headers` Record<string, string> (optional) - HTTP request headers.
 
 Initiates a download of the resource at `url` without navigating.
 

+ 16 - 3
shell/browser/api/electron_api_web_contents.cc

@@ -2462,12 +2462,25 @@ void WebContents::ReloadIgnoringCache() {
                                          /* check_for_repost */ true);
 }
 
-void WebContents::DownloadURL(const GURL& url) {
-  auto* browser_context = web_contents()->GetBrowserContext();
-  auto* download_manager = browser_context->GetDownloadManager();
+void WebContents::DownloadURL(const GURL& url, gin::Arguments* args) {
+  std::map<std::string, std::string> headers;
+  gin_helper::Dictionary options;
+  if (args->GetNext(&options)) {
+    if (options.Has("headers") && !options.Get("headers", &headers)) {
+      args->ThrowTypeError("Invalid value for headers - must be an object");
+      return;
+    }
+  }
+
   std::unique_ptr<download::DownloadUrlParameters> download_params(
       content::DownloadRequestUtils::CreateDownloadForWebContentsMainFrame(
           web_contents(), url, MISSING_TRAFFIC_ANNOTATION));
+  for (const auto& [name, value] : headers) {
+    download_params->add_request_header(name, value);
+  }
+
+  auto* download_manager =
+      web_contents()->GetBrowserContext()->GetDownloadManager();
   download_manager->DownloadUrl(std::move(download_params));
 }
 

+ 1 - 1
shell/browser/api/electron_api_web_contents.h

@@ -172,7 +172,7 @@ class WebContents : public ExclusiveAccessContext,
   void LoadURL(const GURL& url, const gin_helper::Dictionary& options);
   void Reload();
   void ReloadIgnoringCache();
-  void DownloadURL(const GURL& url);
+  void DownloadURL(const GURL& url, gin::Arguments* args);
   GURL GetURL() const;
   std::u16string GetTitle() const;
   bool IsLoading() const;

+ 320 - 214
spec/api-session-spec.ts

@@ -827,277 +827,383 @@ describe('session module', () => {
       fs.unlinkSync(downloadFilePath);
     };
 
-    it('can download using session.downloadURL', (done) => {
-      session.defaultSession.once('will-download', function (e, item) {
-        item.savePath = downloadFilePath;
-        item.on('done', function (e, state) {
-          try {
-            assertDownload(state, item);
-            done();
-          } catch (e) {
-            done(e);
-          }
+    describe('session.downloadURL', () => {
+      it('can perform a download', (done) => {
+        session.defaultSession.once('will-download', function (e, item) {
+          item.savePath = downloadFilePath;
+          item.on('done', function (e, state) {
+            try {
+              assertDownload(state, item);
+              done();
+            } catch (e) {
+              done(e);
+            }
+          });
         });
+        session.defaultSession.downloadURL(`${url}:${port}`);
       });
-      session.defaultSession.downloadURL(`${url}:${port}`);
-    });
 
-    it('can download using session.downloadURL with a valid auth header', async () => {
-      const server = http.createServer((req, res) => {
-        const { authorization } = req.headers;
-        if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
-          res.statusCode = 401;
-          res.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
-          res.end();
-        } else {
-          res.writeHead(200, {
-            'Content-Length': mockPDF.length,
-            'Content-Type': 'application/pdf',
-            'Content-Disposition': req.url === '/?testFilename' ? 'inline' : contentDisposition
-          });
-          res.end(mockPDF);
-        }
-      });
+      it('can perform a download with a valid auth header', async () => {
+        const server = http.createServer((req, res) => {
+          const { authorization } = req.headers;
+          if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
+            res.statusCode = 401;
+            res.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
+            res.end();
+          } else {
+            res.writeHead(200, {
+              'Content-Length': mockPDF.length,
+              'Content-Type': 'application/pdf',
+              'Content-Disposition': req.url === '/?testFilename' ? 'inline' : contentDisposition
+            });
+            res.end(mockPDF);
+          }
+        });
 
-      const { port } = await listen(server);
+        const { port } = await listen(server);
 
-      const downloadDone: Promise<Electron.DownloadItem> = new Promise((resolve) => {
-        session.defaultSession.once('will-download', (e, item) => {
-          item.savePath = downloadFilePath;
-          item.on('done', () => {
-            try {
-              resolve(item);
-            } catch {}
+        const downloadDone: Promise<Electron.DownloadItem> = new Promise((resolve) => {
+          session.defaultSession.once('will-download', (e, item) => {
+            item.savePath = downloadFilePath;
+            item.on('done', () => {
+              try {
+                resolve(item);
+              } catch { }
+            });
           });
         });
+
+        session.defaultSession.downloadURL(`${url}:${port}`, {
+          headers: {
+            Authorization: 'Basic i-am-an-auth-header'
+          }
+        });
+
+        const item = await downloadDone;
+        expect(item.getState()).to.equal('completed');
+        expect(item.getFilename()).to.equal('mock.pdf');
+        expect(item.getMimeType()).to.equal('application/pdf');
+        expect(item.getReceivedBytes()).to.equal(mockPDF.length);
+        expect(item.getTotalBytes()).to.equal(mockPDF.length);
+        expect(item.getContentDisposition()).to.equal(contentDisposition);
       });
 
-      session.defaultSession.downloadURL(`${url}:${port}`, {
-        headers: {
-          Authorization: 'Basic i-am-an-auth-header'
-        }
+      it('throws when called with invalid headers', () => {
+        expect(() => {
+          session.defaultSession.downloadURL(`${url}:${port}`, {
+            // @ts-ignore this line is intentionally incorrect
+            headers: 'i-am-a-bad-header'
+          });
+        }).to.throw(/Invalid value for headers - must be an object/);
       });
 
-      const item = await downloadDone;
-      expect(item.getState()).to.equal('completed');
-      expect(item.getFilename()).to.equal('mock.pdf');
-      expect(item.getMimeType()).to.equal('application/pdf');
-      expect(item.getReceivedBytes()).to.equal(mockPDF.length);
-      expect(item.getTotalBytes()).to.equal(mockPDF.length);
-      expect(item.getContentDisposition()).to.equal(contentDisposition);
-    });
+      it('correctly handles a download with an invalid auth header', async () => {
+        const server = http.createServer((req, res) => {
+          const { authorization } = req.headers;
+          if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
+            res.statusCode = 401;
+            res.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
+            res.end();
+          } else {
+            res.writeHead(200, {
+              'Content-Length': mockPDF.length,
+              'Content-Type': 'application/pdf',
+              'Content-Disposition': req.url === '/?testFilename' ? 'inline' : contentDisposition
+            });
+            res.end(mockPDF);
+          }
+        });
+
+        const { port } = await listen(server);
+
+        const downloadFailed: Promise<Electron.DownloadItem> = new Promise((resolve) => {
+          session.defaultSession.once('will-download', (_, item) => {
+            item.savePath = downloadFilePath;
+            item.on('done', (e, state) => {
+              console.log(state);
+              try {
+                resolve(item);
+              } catch { }
+            });
+          });
+        });
 
-    it('throws when session.downloadURL is called with invalid headers', () => {
-      expect(() => {
         session.defaultSession.downloadURL(`${url}:${port}`, {
-          // @ts-ignore this line is intentionally incorrect
-          headers: 'i-am-a-bad-header'
+          headers: {
+            Authorization: 'wtf-is-this'
+          }
         });
-      }).to.throw(/Invalid value for headers - must be an object/);
-    });
 
-    it('can download using session.downloadURL with an invalid auth header', async () => {
-      const server = http.createServer((req, res) => {
-        const { authorization } = req.headers;
-        if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
-          res.statusCode = 401;
-          res.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
-          res.end();
-        } else {
-          res.writeHead(200, {
-            'Content-Length': mockPDF.length,
-            'Content-Type': 'application/pdf',
-            'Content-Disposition': req.url === '/?testFilename' ? 'inline' : contentDisposition
-          });
-          res.end(mockPDF);
-        }
+        const item = await downloadFailed;
+        expect(item.getState()).to.equal('interrupted');
+        expect(item.getReceivedBytes()).to.equal(0);
+        expect(item.getTotalBytes()).to.equal(0);
       });
+    });
 
-      const { port } = await listen(server);
-
-      const downloadFailed: Promise<Electron.DownloadItem> = new Promise((resolve) => {
-        session.defaultSession.once('will-download', (_, item) => {
+    describe('webContents.downloadURL', () => {
+      it('can perform a download', (done) => {
+        const w = new BrowserWindow({ show: false });
+        w.webContents.session.once('will-download', function (e, item) {
           item.savePath = downloadFilePath;
-          item.on('done', (e, state) => {
-            console.log(state);
+          item.on('done', function (e, state) {
             try {
-              resolve(item);
-            } catch {}
+              assertDownload(state, item);
+              done();
+            } catch (e) {
+              done(e);
+            }
           });
         });
+        w.webContents.downloadURL(`${url}:${port}`);
       });
 
-      session.defaultSession.downloadURL(`${url}:${port}`, {
-        headers: {
-          Authorization: 'wtf-is-this'
-        }
-      });
+      it('can perform a download with a valid auth header', async () => {
+        const server = http.createServer((req, res) => {
+          const { authorization } = req.headers;
+          if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
+            res.statusCode = 401;
+            res.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
+            res.end();
+          } else {
+            res.writeHead(200, {
+              'Content-Length': mockPDF.length,
+              'Content-Type': 'application/pdf',
+              'Content-Disposition': req.url === '/?testFilename' ? 'inline' : contentDisposition
+            });
+            res.end(mockPDF);
+          }
+        });
 
-      const item = await downloadFailed;
-      expect(item.getState()).to.equal('interrupted');
-      expect(item.getReceivedBytes()).to.equal(0);
-      expect(item.getTotalBytes()).to.equal(0);
-    });
+        const { port } = await listen(server);
 
-    it('can download using WebContents.downloadURL', (done) => {
-      const w = new BrowserWindow({ show: false });
-      w.webContents.session.once('will-download', function (e, item) {
-        item.savePath = downloadFilePath;
-        item.on('done', function (e, state) {
-          try {
-            assertDownload(state, item);
-            done();
-          } catch (e) {
-            done(e);
+        const w = new BrowserWindow({ show: false });
+        const downloadDone: Promise<Electron.DownloadItem> = new Promise((resolve) => {
+          w.webContents.session.once('will-download', (e, item) => {
+            item.savePath = downloadFilePath;
+            item.on('done', () => {
+              try {
+                resolve(item);
+              } catch { }
+            });
+          });
+        });
+
+        w.webContents.downloadURL(`${url}:${port}`, {
+          headers: {
+            Authorization: 'Basic i-am-an-auth-header'
           }
         });
+
+        const item = await downloadDone;
+        expect(item.getState()).to.equal('completed');
+        expect(item.getFilename()).to.equal('mock.pdf');
+        expect(item.getMimeType()).to.equal('application/pdf');
+        expect(item.getReceivedBytes()).to.equal(mockPDF.length);
+        expect(item.getTotalBytes()).to.equal(mockPDF.length);
+        expect(item.getContentDisposition()).to.equal(contentDisposition);
       });
-      w.webContents.downloadURL(`${url}:${port}`);
-    });
 
-    it('can download from custom protocols using WebContents.downloadURL', (done) => {
-      const protocol = session.defaultSession.protocol;
-      const handler = (ignoredError: any, callback: Function) => {
-        callback({ url: `${url}:${port}` });
-      };
-      protocol.registerHttpProtocol(protocolName, handler);
-      const w = new BrowserWindow({ show: false });
-      w.webContents.session.once('will-download', function (e, item) {
-        item.savePath = downloadFilePath;
-        item.on('done', function (e, state) {
-          try {
-            assertDownload(state, item, true);
-            done();
-          } catch (e) {
-            done(e);
+      it('throws when called with invalid headers', () => {
+        const w = new BrowserWindow({ show: false });
+        expect(() => {
+          w.webContents.downloadURL(`${url}:${port}`, {
+            // @ts-ignore this line is intentionally incorrect
+            headers: 'i-am-a-bad-header'
+          });
+        }).to.throw(/Invalid value for headers - must be an object/);
+      });
+
+      it('correctly handles a download and an invalid auth header', async () => {
+        const server = http.createServer((req, res) => {
+          const { authorization } = req.headers;
+          if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
+            res.statusCode = 401;
+            res.setHeader('WWW-Authenticate', 'Basic realm="Restricted"');
+            res.end();
+          } else {
+            res.writeHead(200, {
+              'Content-Length': mockPDF.length,
+              'Content-Type': 'application/pdf',
+              'Content-Disposition': req.url === '/?testFilename' ? 'inline' : contentDisposition
+            });
+            res.end(mockPDF);
           }
         });
-      });
-      w.webContents.downloadURL(`${protocolName}://item`);
-    });
 
-    it('can download using WebView.downloadURL', async () => {
-      const w = new BrowserWindow({ show: false, webPreferences: { webviewTag: true } });
-      await w.loadURL('about:blank');
-      function webviewDownload ({ fixtures, url, port }: {fixtures: string, url: string, port: string}) {
-        const webview = new (window as any).WebView();
-        webview.addEventListener('did-finish-load', () => {
-          webview.downloadURL(`${url}:${port}/`);
+        const { port } = await listen(server);
+
+        const w = new BrowserWindow({ show: false });
+        const downloadFailed: Promise<Electron.DownloadItem> = new Promise((resolve) => {
+          w.webContents.session.once('will-download', (_, item) => {
+            item.savePath = downloadFilePath;
+            item.on('done', (e, state) => {
+              console.log(state);
+              try {
+                resolve(item);
+              } catch { }
+            });
+          });
+        });
+
+        w.webContents.downloadURL(`${url}:${port}`, {
+          headers: {
+            Authorization: 'wtf-is-this'
+          }
         });
-        webview.src = `file://${fixtures}/api/blank.html`;
-        document.body.appendChild(webview);
-      }
-      const done: Promise<[string, Electron.DownloadItem]> = new Promise(resolve => {
+
+        const item = await downloadFailed;
+        expect(item.getState()).to.equal('interrupted');
+        expect(item.getReceivedBytes()).to.equal(0);
+        expect(item.getTotalBytes()).to.equal(0);
+      });
+
+      it('can download from custom protocols', (done) => {
+        const protocol = session.defaultSession.protocol;
+        const handler = (ignoredError: any, callback: Function) => {
+          callback({ url: `${url}:${port}` });
+        };
+        protocol.registerHttpProtocol(protocolName, handler);
+        const w = new BrowserWindow({ show: false });
         w.webContents.session.once('will-download', function (e, item) {
           item.savePath = downloadFilePath;
           item.on('done', function (e, state) {
-            resolve([state, item]);
+            try {
+              assertDownload(state, item, true);
+              done();
+            } catch (e) {
+              done(e);
+            }
           });
         });
+        w.webContents.downloadURL(`${protocolName}://item`);
       });
-      await w.webContents.executeJavaScript(`(${webviewDownload})(${JSON.stringify({ fixtures, url, port })})`);
-      const [state, item] = await done;
-      assertDownload(state, item);
-    });
 
-    it('can cancel download', (done) => {
-      const w = new BrowserWindow({ show: false });
-      w.webContents.session.once('will-download', function (e, item) {
-        item.savePath = downloadFilePath;
-        item.on('done', function (e, state) {
-          try {
-            expect(state).to.equal('cancelled');
-            expect(item.getFilename()).to.equal('mock.pdf');
-            expect(item.getMimeType()).to.equal('application/pdf');
-            expect(item.getReceivedBytes()).to.equal(0);
-            expect(item.getTotalBytes()).to.equal(mockPDF.length);
-            expect(item.getContentDisposition()).to.equal(contentDisposition);
-            done();
-          } catch (e) {
-            done(e);
-          }
+      it('can cancel download', (done) => {
+        const w = new BrowserWindow({ show: false });
+        w.webContents.session.once('will-download', function (e, item) {
+          item.savePath = downloadFilePath;
+          item.on('done', function (e, state) {
+            try {
+              expect(state).to.equal('cancelled');
+              expect(item.getFilename()).to.equal('mock.pdf');
+              expect(item.getMimeType()).to.equal('application/pdf');
+              expect(item.getReceivedBytes()).to.equal(0);
+              expect(item.getTotalBytes()).to.equal(mockPDF.length);
+              expect(item.getContentDisposition()).to.equal(contentDisposition);
+              done();
+            } catch (e) {
+              done(e);
+            }
+          });
+          item.cancel();
         });
-        item.cancel();
+        w.webContents.downloadURL(`${url}:${port}/`);
       });
-      w.webContents.downloadURL(`${url}:${port}/`);
-    });
 
-    it('can generate a default filename', function (done) {
-      if (process.env.APPVEYOR === 'True') {
-        // FIXME(alexeykuzmin): Skip the test.
-        // this.skip()
-        return done();
-      }
+      it('can generate a default filename', function (done) {
+        if (process.env.APPVEYOR === 'True') {
+          // FIXME(alexeykuzmin): Skip the test.
+          // this.skip()
+          return done();
+        }
 
-      const w = new BrowserWindow({ show: false });
-      w.webContents.session.once('will-download', function (e, item) {
-        item.savePath = downloadFilePath;
-        item.on('done', function () {
-          try {
-            expect(item.getFilename()).to.equal('download.pdf');
-            done();
-          } catch (e) {
-            done(e);
-          }
+        const w = new BrowserWindow({ show: false });
+        w.webContents.session.once('will-download', function (e, item) {
+          item.savePath = downloadFilePath;
+          item.on('done', function () {
+            try {
+              expect(item.getFilename()).to.equal('download.pdf');
+              done();
+            } catch (e) {
+              done(e);
+            }
+          });
+          item.cancel();
         });
-        item.cancel();
+        w.webContents.downloadURL(`${url}:${port}/?testFilename`);
       });
-      w.webContents.downloadURL(`${url}:${port}/?testFilename`);
-    });
-
-    it('can set options for the save dialog', (done) => {
-      const filePath = path.join(__dirname, 'fixtures', 'mock.pdf');
-      const options = {
-        window: null,
-        title: 'title',
-        message: 'message',
-        buttonLabel: 'buttonLabel',
-        nameFieldLabel: 'nameFieldLabel',
-        defaultPath: '/',
-        filters: [{
-          name: '1', extensions: ['.1', '.2']
-        }, {
-          name: '2', extensions: ['.3', '.4', '.5']
-        }],
-        showsTagField: true,
-        securityScopedBookmarks: true
-      };
 
-      const w = new BrowserWindow({ show: false });
-      w.webContents.session.once('will-download', function (e, item) {
-        item.setSavePath(filePath);
-        item.setSaveDialogOptions(options);
-        item.on('done', function () {
-          try {
-            expect(item.getSaveDialogOptions()).to.deep.equal(options);
-            done();
-          } catch (e) {
-            done(e);
-          }
-        });
-        item.cancel();
-      });
-      w.webContents.downloadURL(`${url}:${port}`);
-    });
+      it('can set options for the save dialog', (done) => {
+        const filePath = path.join(__dirname, 'fixtures', 'mock.pdf');
+        const options = {
+          window: null,
+          title: 'title',
+          message: 'message',
+          buttonLabel: 'buttonLabel',
+          nameFieldLabel: 'nameFieldLabel',
+          defaultPath: '/',
+          filters: [{
+            name: '1', extensions: ['.1', '.2']
+          }, {
+            name: '2', extensions: ['.3', '.4', '.5']
+          }],
+          showsTagField: true,
+          securityScopedBookmarks: true
+        };
 
-    describe('when a save path is specified and the URL is unavailable', () => {
-      it('does not display a save dialog and reports the done state as interrupted', (done) => {
         const w = new BrowserWindow({ show: false });
         w.webContents.session.once('will-download', function (e, item) {
-          item.savePath = downloadFilePath;
-          if (item.getState() === 'interrupted') {
-            item.resume();
-          }
-          item.on('done', function (e, state) {
+          item.setSavePath(filePath);
+          item.setSaveDialogOptions(options);
+          item.on('done', function () {
             try {
-              expect(state).to.equal('interrupted');
+              expect(item.getSaveDialogOptions()).to.deep.equal(options);
               done();
             } catch (e) {
               done(e);
             }
           });
+          item.cancel();
+        });
+        w.webContents.downloadURL(`${url}:${port}`);
+      });
+
+      describe('when a save path is specified and the URL is unavailable', () => {
+        it('does not display a save dialog and reports the done state as interrupted', (done) => {
+          const w = new BrowserWindow({ show: false });
+          w.webContents.session.once('will-download', function (e, item) {
+            item.savePath = downloadFilePath;
+            if (item.getState() === 'interrupted') {
+              item.resume();
+            }
+            item.on('done', function (e, state) {
+              try {
+                expect(state).to.equal('interrupted');
+                done();
+              } catch (e) {
+                done(e);
+              }
+            });
+          });
+          w.webContents.downloadURL(`file://${path.join(__dirname, 'does-not-exist.txt')}`);
+        });
+      });
+    });
+
+    describe('WebView.downloadURL', () => {
+      it('can perform a download', async () => {
+        const w = new BrowserWindow({ show: false, webPreferences: { webviewTag: true } });
+        await w.loadURL('about:blank');
+        function webviewDownload ({ fixtures, url, port }: { fixtures: string, url: string, port: string }) {
+          const webview = new (window as any).WebView();
+          webview.addEventListener('did-finish-load', () => {
+            webview.downloadURL(`${url}:${port}/`);
+          });
+          webview.src = `file://${fixtures}/api/blank.html`;
+          document.body.appendChild(webview);
+        }
+        const done: Promise<[string, Electron.DownloadItem]> = new Promise(resolve => {
+          w.webContents.session.once('will-download', function (e, item) {
+            item.savePath = downloadFilePath;
+            item.on('done', function (e, state) {
+              resolve([state, item]);
+            });
+          });
         });
-        w.webContents.downloadURL(`file://${path.join(__dirname, 'does-not-exist.txt')}`);
+        await w.webContents.executeJavaScript(`(${webviewDownload})(${JSON.stringify({ fixtures, url, port })})`);
+        const [state, item] = await done;
+        assertDownload(state, item);
       });
     });
   });