From 00746e662bf2cc128cbcade58b525fffe717d2e2 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 17 Aug 2023 14:17:55 +0200 Subject: [PATCH] feat: allow headers to be sent with `webContents.downloadURL()` (#39455) feat: allow headers to be sent with webContents.downloadURL() --- docs/api/session.md | 2 +- docs/api/web-contents.md | 4 +- docs/api/webview-tag.md | 4 +- .../browser/api/electron_api_web_contents.cc | 19 +- shell/browser/api/electron_api_web_contents.h | 2 +- spec/api-session-spec.ts | 630 ++++++++++-------- 6 files changed, 392 insertions(+), 269 deletions(-) diff --git a/docs/api/session.md b/docs/api/session.md index d157e3464a..d13ccf009c 100644 --- a/docs/api/session.md +++ b/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)` diff --git a/docs/api/web-contents.md b/docs/api/web-contents.md index de9c66ed5e..715885369a 100644 --- a/docs/api/web-contents.md +++ b/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 (optional) - HTTP request headers. Initiates a download of the resource at `url` without navigating. The `will-download` event of `session` will be triggered. diff --git a/docs/api/webview-tag.md b/docs/api/webview-tag.md index 340365b78c..825921b9be 100644 --- a/docs/api/webview-tag.md +++ b/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://`. -### `.downloadURL(url)` +### `.downloadURL(url[, options])` * `url` string +* `options` Object (optional) + * `headers` Record (optional) - HTTP request headers. Initiates a download of the resource at `url` without navigating. diff --git a/shell/browser/api/electron_api_web_contents.cc b/shell/browser/api/electron_api_web_contents.cc index 993861fe58..069b777856 100644 --- a/shell/browser/api/electron_api_web_contents.cc +++ b/shell/browser/api/electron_api_web_contents.cc @@ -2444,12 +2444,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 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_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)); } diff --git a/shell/browser/api/electron_api_web_contents.h b/shell/browser/api/electron_api_web_contents.h index ae3d9ecd19..d0c65fd4b4 100644 --- a/shell/browser/api/electron_api_web_contents.h +++ b/shell/browser/api/electron_api_web_contents.h @@ -169,7 +169,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; diff --git a/spec/api-session-spec.ts b/spec/api-session-spec.ts index 592d43eb69..614ec6cba6 100644 --- a/spec/api-session-spec.ts +++ b/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); - } - }); - }); - 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); - } - }); - - const { port } = await listen(server); - - const downloadDone: Promise = 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); - }); - - 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' - }); - }).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 { port } = await listen(server); - - const downloadFailed: Promise = new Promise((resolve) => { - session.defaultSession.once('will-download', (_, item) => { - item.savePath = downloadFilePath; - item.on('done', (e, state) => { - console.log(state); - try { - resolve(item); - } catch {} - }); - }); - }); - - session.defaultSession.downloadURL(`${url}:${port}`, { - headers: { - Authorization: 'wtf-is-this' - } - }); - - 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 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); - } - }); - }); - 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); - } - }); - }); - 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}/`); - }); - 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) { + 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) { - resolve([state, 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); - } - }); - item.cancel(); - }); - 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(); - } - - 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(); - }); - 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}`); - }); - - 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'); + assertDownload(state, item); done(); } catch (e) { done(e); } }); }); - w.webContents.downloadURL(`file://${path.join(__dirname, 'does-not-exist.txt')}`); + session.defaultSession.downloadURL(`${url}:${port}`); + }); + + 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 downloadDone: Promise = 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); + }); + + 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/); + }); + + 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 = new Promise((resolve) => { + session.defaultSession.once('will-download', (_, item) => { + item.savePath = downloadFilePath; + item.on('done', (e, state) => { + console.log(state); + try { + resolve(item); + } catch { } + }); + }); + }); + + session.defaultSession.downloadURL(`${url}:${port}`, { + headers: { + Authorization: 'wtf-is-this' + } + }); + + const item = await downloadFailed; + expect(item.getState()).to.equal('interrupted'); + expect(item.getReceivedBytes()).to.equal(0); + expect(item.getTotalBytes()).to.equal(0); + }); + }); + + 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', function (e, state) { + try { + assertDownload(state, item); + done(); + } catch (e) { + done(e); + } + }); + }); + w.webContents.downloadURL(`${url}:${port}`); + }); + + 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 w = new BrowserWindow({ show: false }); + const downloadDone: Promise = 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); + }); + + 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); + } + }); + + const { port } = await listen(server); + + const w = new BrowserWindow({ show: false }); + const downloadFailed: Promise = 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' + } + }); + + 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) { + try { + assertDownload(state, item, true); + done(); + } catch (e) { + done(e); + } + }); + }); + w.webContents.downloadURL(`${protocolName}://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); + } + }); + item.cancel(); + }); + 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(); + } + + 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(); + }); + 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}`); + }); + + 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]); + }); + }); + }); + await w.webContents.executeJavaScript(`(${webviewDownload})(${JSON.stringify({ fixtures, url, port })})`); + const [state, item] = await done; + assertDownload(state, item); }); }); });