bug 1247793 - Implement download() options: method, headers and body, r?aswan
MozReview-Commit-ID: 2muhcweY8Fo
--- a/toolkit/components/extensions/ext-downloads.js
+++ b/toolkit/components/extensions/ext-downloads.js
@@ -33,16 +33,24 @@ const DOWNLOAD_ITEM_FIELDS = ["id", "url
"bytesReceived", "totalBytes",
"fileSize", "exists",
"byExtensionId", "byExtensionName"];
// Fields that we generate onChanged events for.
const DOWNLOAD_ITEM_CHANGE_FIELDS = ["endTime", "state", "paused", "canResume",
"error", "exists"];
+// From https://fetch.spec.whatwg.org/#forbidden-header-name
+const FORBIDDEN_HEADERS = ["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 FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i;
class DownloadItem {
constructor(id, download, extension) {
this.id = id;
this.download = download;
this.extension = extension;
this.prechange = {};
}
@@ -413,16 +421,48 @@ extensions.registerSchemaAPI("downloads"
}
}
if (options.conflictAction == "prompt") {
// TODO
return Promise.reject({message: "conflictAction prompt not yet implemented"});
}
+ if (options.headers) {
+ for (let {name} of options.headers) {
+ if (FORBIDDEN_HEADERS.includes(name.toUpperCase()) || name.match(FORBIDDEN_PREFIXES)) {
+ return Promise.reject({message: "Forbidden request header name"});
+ }
+ }
+ }
+
+ // Handle method, headers and body options.
+ function adjustChannel(channel) {
+ if (channel instanceof Ci.nsIHttpChannel) {
+ const method = options.method || "GET";
+ channel.requestMethod = method;
+
+ if (options.headers) {
+ for (let {name, value} of options.headers) {
+ channel.setRequestHeader(name, value, false);
+ }
+ }
+
+ if (options.body != null) {
+ const stream = Cc["@mozilla.org/io/string-input-stream;1"]
+ .createInstance(Ci.nsIStringInputStream);
+ stream.setData(options.body, options.body.length);
+
+ channel.QueryInterface(Ci.nsIUploadChannel2);
+ channel.explicitSetUploadStream(stream, null, -1, method, false);
+ }
+ }
+ return Promise.resolve();
+ }
+
function createTarget(downloadsDir) {
let target;
if (filename) {
target = OS.Path.join(downloadsDir, filename);
} else {
let uri = NetUtil.newURI(options.url);
let remote = "download";
@@ -476,23 +516,33 @@ extensions.registerSchemaAPI("downloads"
});
});
});
}
let download;
return Downloads.getPreferredDownloadsDirectory()
.then(downloadsDir => createTarget(downloadsDir))
- .then(target => Downloads.createDownload({
- source: options.url,
- target: {
- path: target,
- partFilePath: target + ".part",
- },
- })).then(dl => {
+ .then(target => {
+ const source = {
+ url: options.url,
+ };
+
+ if (options.method || options.headers || options.body) {
+ source.adjustChannel = adjustChannel;
+ }
+
+ return Downloads.createDownload({
+ source,
+ target: {
+ path: target,
+ partFilePath: target + ".part",
+ },
+ });
+ }).then(dl => {
download = dl;
return DownloadMap.getDownloadList();
}).then(list => {
list.add(download);
// This is necessary to make pause/resume work.
download.tryToKeepPartialData = true;
download.start();
--- a/toolkit/components/extensions/schemas/downloads.json
+++ b/toolkit/components/extensions/schemas/downloads.json
@@ -381,27 +381,25 @@
"optional": true
},
"saveAs": {
"description": "Use a file-chooser to allow the user to select a filename.",
"optional": true,
"type": "boolean"
},
"method": {
- "unsupported": true,
"description": "The HTTP method to use if the URL uses the HTTP[S] protocol.",
"enum": [
"GET",
"POST"
],
"optional": true,
"type": "string"
},
"headers": {
- "unsupported": true,
"optional": true,
"type": "array",
"description": "Extra HTTP headers to send with the request if the URL uses the HTTP[s] protocol. Each header is represented as a dictionary containing the keys <code>name</code> and either <code>value</code> or <code>binaryValue</code>, restricted to those allowed by XMLHttpRequest.",
"items": {
"type": "object",
"properties": {
"name": {
"description": "Name of the HTTP header.",
@@ -410,17 +408,16 @@
"value": {
"description": "Value of the HTTP header.",
"type": "string"
}
}
}
},
"body": {
- "unsupported": true,
"description": "Post body.",
"optional": true,
"type": "string"
}
}
},
{
"name": "callback",
--- a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
@@ -250,8 +250,106 @@ add_task(function* test_downloads() {
// Try to download a blob url without a given filename
yield testDownload({
blobme: [BLOB_STRING],
}, "download", BLOB_STRING.length, "blob url with no filename");
extension.sendMessage("killTheBlob");
yield extension.unload();
});
+
+add_task(function* test_download_post() {
+ const server = createHttpServer();
+ const url = `http://localhost:${server.identity.primaryPort}/post-log`;
+
+ let received;
+ server.registerPathHandler("/post-log", request => {
+ received = request;
+ });
+
+ // Confirm received vs. expected values.
+ function confirm(method, headers = {}, body) {
+ equal(received.method, method, "method is correct");
+
+ for (let name in headers) {
+ ok(received.hasHeader(name), `header ${name} received`);
+ equal(received.getHeader(name), headers[name], `header ${name} is correct`);
+ }
+
+ if (body) {
+ const str = NetUtil.readInputStreamToString(received.bodyInputStream,
+ received.bodyInputStream.available());
+ equal(str, body, "body is correct");
+ }
+ }
+
+ function background() {
+ browser.test.onMessage.addListener(options => {
+ Promise.resolve()
+ .then(() => browser.downloads.download(options))
+ .catch(err => browser.test.sendMessage("done", {err: err.message}));
+ });
+ browser.downloads.onChanged.addListener(({state}) => {
+ if (state && state.current === "complete") {
+ browser.test.sendMessage("done", {ok: true});
+ }
+ });
+ }
+
+ const manifest = {permissions: ["downloads"]};
+ const extension = ExtensionTestUtils.loadExtension({background, manifest});
+ yield extension.startup();
+
+ function download(options) {
+ options.url = url;
+ options.conflictAction = "overwrite";
+
+ extension.sendMessage(options);
+ return extension.awaitMessage("done");
+ }
+
+ // Test method option.
+ let result = yield download({});
+ ok(result.ok, "download works without the method option, defaults to GET");
+ confirm("GET");
+
+ result = yield download({method: "PUT"});
+ ok(!result.ok, "download rejected with PUT method");
+ ok(/method: Invalid enumeration/.test(result.err), "descriptive error message");
+
+ result = yield download({method: "POST"});
+ ok(result.ok, "download works with POST method");
+ confirm("POST");
+
+ // Test body option values.
+ result = yield download({body: []});
+ ok(!result.ok, "download rejected because of non-string body");
+ ok(/body: Expected string/.test(result.err), "descriptive error message");
+
+ result = yield download({method: "POST", body: "of work"});
+ ok(result.ok, "download works with POST method and body");
+ confirm("POST", {"Content-Length": 7}, "of work");
+
+ // Test custom headers.
+ result = yield download({headers: [{name: "X-Custom"}]});
+ ok(!result.ok, "download rejected because of missing header value");
+ ok(/"value" is required/.test(result.err), "descriptive error message");
+
+ result = yield download({headers: [{name: "X-Custom", value: "13"}]});
+ ok(result.ok, "download works with a custom header");
+ confirm("GET", {"X-Custom": "13"});
+
+ // Test forbidden headers.
+ result = yield download({headers: [{name: "DNT", value: "1"}]});
+ ok(!result.ok, "download rejected because of forbidden header name DNT");
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ result = yield download({headers: [{name: "Proxy-Connection", value: "keep"}]});
+ ok(!result.ok, "download rejected because of forbidden header name prefix Proxy-");
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ result = yield download({headers: [{name: "Sec-ret", value: "13"}]});
+ ok(!result.ok, "download rejected because of forbidden header name prefix Sec-");
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ remove("post-log");
+ yield extension.unload();
+});