bug 1247793 - Implement download() options: method, headers and body, r?aswan draft 1247793-downloads-post
authorTomislav Jovanovic <tomica@gmail.com>
Sat, 22 Oct 2016 12:06:22 +0200
changeset 429313 2dd3deae8f818b97d49d4d150365f1bf5d396de9
parent 428476 215f9686117673a2c914ed207bc7da9bb8d741ad
child 534944 b52fbbb42eff975b5e5bc73931f2b481d01cffe4
push id33536
push userbmo:tomica@gmail.com
push dateTue, 25 Oct 2016 17:52:50 +0000
reviewersaswan
bugs1247793
milestone52.0a1
bug 1247793 - Implement download() options: method, headers and body, r?aswan MozReview-Commit-ID: 2muhcweY8Fo
toolkit/components/extensions/ext-downloads.js
toolkit/components/extensions/schemas/downloads.json
toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
--- 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();
+});