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

feat: allow headers to be sent with webContents.downloadURL()
This commit is contained in:
Shelley Vohr 2023-08-17 14:17:55 +02:00 committed by GitHub
parent 31dfde7fa6
commit 00746e662b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 392 additions and 269 deletions

View file

@ -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. with the [will-download](#event-will-download) event.
**Note:** This does not perform any security checks that relate to a page's origin, **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)` #### `ses.createInterruptedDownload(options)`

View file

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

View file

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

View file

@ -2444,12 +2444,25 @@ void WebContents::ReloadIgnoringCache() {
/* check_for_repost */ true); /* check_for_repost */ true);
} }
void WebContents::DownloadURL(const GURL& url) { void WebContents::DownloadURL(const GURL& url, gin::Arguments* args) {
auto* browser_context = web_contents()->GetBrowserContext(); std::map<std::string, std::string> headers;
auto* download_manager = browser_context->GetDownloadManager(); 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( std::unique_ptr<download::DownloadUrlParameters> download_params(
content::DownloadRequestUtils::CreateDownloadForWebContentsMainFrame( content::DownloadRequestUtils::CreateDownloadForWebContentsMainFrame(
web_contents(), url, MISSING_TRAFFIC_ANNOTATION)); 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)); download_manager->DownloadUrl(std::move(download_params));
} }

View file

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

View file

@ -827,7 +827,8 @@ describe('session module', () => {
fs.unlinkSync(downloadFilePath); fs.unlinkSync(downloadFilePath);
}; };
it('can download using session.downloadURL', (done) => { describe('session.downloadURL', () => {
it('can perform a download', (done) => {
session.defaultSession.once('will-download', function (e, item) { session.defaultSession.once('will-download', function (e, item) {
item.savePath = downloadFilePath; item.savePath = downloadFilePath;
item.on('done', function (e, state) { item.on('done', function (e, state) {
@ -842,7 +843,7 @@ describe('session module', () => {
session.defaultSession.downloadURL(`${url}:${port}`); session.defaultSession.downloadURL(`${url}:${port}`);
}); });
it('can download using session.downloadURL with a valid auth header', async () => { it('can perform a download with a valid auth header', async () => {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const { authorization } = req.headers; const { authorization } = req.headers;
if (!authorization || authorization !== 'Basic i-am-an-auth-header') { if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
@ -887,7 +888,7 @@ describe('session module', () => {
expect(item.getContentDisposition()).to.equal(contentDisposition); expect(item.getContentDisposition()).to.equal(contentDisposition);
}); });
it('throws when session.downloadURL is called with invalid headers', () => { it('throws when called with invalid headers', () => {
expect(() => { expect(() => {
session.defaultSession.downloadURL(`${url}:${port}`, { session.defaultSession.downloadURL(`${url}:${port}`, {
// @ts-ignore this line is intentionally incorrect // @ts-ignore this line is intentionally incorrect
@ -896,7 +897,7 @@ describe('session module', () => {
}).to.throw(/Invalid value for headers - must be an object/); }).to.throw(/Invalid value for headers - must be an object/);
}); });
it('can download using session.downloadURL with an invalid auth header', async () => { it('correctly handles a download with an invalid auth header', async () => {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const { authorization } = req.headers; const { authorization } = req.headers;
if (!authorization || authorization !== 'Basic i-am-an-auth-header') { if (!authorization || authorization !== 'Basic i-am-an-auth-header') {
@ -938,8 +939,10 @@ describe('session module', () => {
expect(item.getReceivedBytes()).to.equal(0); expect(item.getReceivedBytes()).to.equal(0);
expect(item.getTotalBytes()).to.equal(0); expect(item.getTotalBytes()).to.equal(0);
}); });
});
it('can download using WebContents.downloadURL', (done) => { describe('webContents.downloadURL', () => {
it('can perform a download', (done) => {
const w = new BrowserWindow({ show: false }); const w = new BrowserWindow({ show: false });
w.webContents.session.once('will-download', function (e, item) { w.webContents.session.once('will-download', function (e, item) {
item.savePath = downloadFilePath; item.savePath = downloadFilePath;
@ -955,7 +958,107 @@ describe('session module', () => {
w.webContents.downloadURL(`${url}:${port}`); w.webContents.downloadURL(`${url}:${port}`);
}); });
it('can download from custom protocols using WebContents.downloadURL', (done) => { 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<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);
});
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<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'
}
});
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 protocol = session.defaultSession.protocol;
const handler = (ignoredError: any, callback: Function) => { const handler = (ignoredError: any, callback: Function) => {
callback({ url: `${url}:${port}` }); callback({ url: `${url}:${port}` });
@ -976,30 +1079,6 @@ describe('session module', () => {
w.webContents.downloadURL(`${protocolName}://item`); 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) {
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) => { it('can cancel download', (done) => {
const w = new BrowserWindow({ show: false }); const w = new BrowserWindow({ show: false });
w.webContents.session.once('will-download', function (e, item) { w.webContents.session.once('will-download', function (e, item) {
@ -1102,6 +1181,33 @@ describe('session module', () => {
}); });
}); });
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);
});
});
});
describe('ses.createInterruptedDownload(options)', () => { describe('ses.createInterruptedDownload(options)', () => {
afterEach(closeAllWindows); afterEach(closeAllWindows);
it('can create an interrupted download item', async () => { it('can create an interrupted download item', async () => {