<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[214065] trunk/Websites/perf.webkit.org</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.webkit.org/projects/webkit/changeset/214065">214065</a></dd>
<dt>Author</dt> <dd>rniwa@webkit.org</dd>
<dt>Date</dt> <dd>2017-03-16 13:53:35 -0700 (Thu, 16 Mar 2017)</dd>
</dl>

<h3>Log Message</h3>
<pre>Add the file uploading capability to the perf dashboard.
https://bugs.webkit.org/show_bug.cgi?id=169737

Reviewed by Chris Dumez.

Added /privileged-api/upload-file to upload a file, and /api/uploaded-file/ to download the file
and retrieve its meta data based on its SHA256. We treat two files with the identical SHA256 as
identical since anyone who can upload a file using this mechanism can execute arbitrary code in
our bots anyway. This is important for avoiding uploading a large darwinup roots multiple times
to the server, saving both user's time/bandwidth and server's disk space.

* config.json: Added uploadDirectory, uploadFileLimitInMB, and uploadUserQuotaInMB as options.
* init-database.sql: Added uploaded_files table.

* public/api/uploaded-file.php: Added.
(main): /api/uploaded-file/N would download uploaded_file with id=N. /api/uploaded-file/?sha256=X
would return the meta data for uploaded_file with sha256=X.
(stream_file_content): Streams the file content in 64KB chunks. We support Range &amp; If-Range HTTP
request headers so that browsers can pause and resume downloading of a large root file.
(parse_range_header): Parses Range HTTP request header.

* public/include/json-header.php:
(remote_user_name): Use the default argument of NULL.

* public/include/manifest-generator.php:
(ManifestGenerator::generate): Include the maximum upload size in the manifest file to let the
frontend code preemptively check the file size before attempting to submit a file.

* public/include/uploaded-file-helpers.php: Added.
(format_uploaded_file):
(uploaded_file_path_for_row):

* public/privileged-api/upload-file-form.html: Added. For debugging purposes.
(fetchCSRFfToken):
(upload):

* public/privileged-api/upload-file.php: Added.
(main):
(query_total_file_size):
(create_uploaded_file_from_form_data):

* public/shared/common-remote.js:
(CommonRemoteAPI.prototype.postFormData): Added.
(CommonRemoteAPI.prototype.postFormDataWithStatus): Added.
(CommonRemoteAPI.prototype.sendHttpRequestWithFormData): Added.
(CommonRemoteAPI.prototype._asJSON): Throw an exception instead of calling a non-existent reject.

* public/v3/models/uploaded-file.js: Added.
(UploadedFile): Added.
(UploadedFile.uploadFile): Added.
(UploadedFile.fetchUnloadedFileWithIdenticalHash): Added. Finds the file with the same SHA256 in
the server to avoid uploading a large custom root multiple times.
(UploadedFile._computeSHA256Hash): Added.

* public/v3/privileged-api.js:
(PrivilegedAPI.prototype.sendRequest): Added the options dictionary as a third argument. For now,
only support useFormData boolean.

* public/v3/remote.js:
(BrowserRemoteAPI.prototype.sendHttpRequestWithFormData): Added.

* server-tests/api-manifest.js: Updated per the inclusion of fileUploadSizeLimit in the manifest.
* server-tests/api-uploaded-file.js: Added.
* server-tests/privileged-api-upload-file-tests.js: Added.

* server-tests/resources/temporary-file.js: Added.
(TemporaryFile): Added. A helper class for creating a temporary file to upload.
(TemporaryFile.makeTemporaryFileOfSizeInMB):
(TemporaryFile.makeTemporaryFile):
(TemporaryFile.inject):

* server-tests/resources/test-server.conf: Set upload_max_filesize and post_max_size for testing.
* server-tests/resources/test-server.js:
(TestServer.prototype.testConfig): Use uploadFileLimitInMB and uploadUserQuotaInMB of 2MB and 5MB.
(TestServer.prototype._ensureDataDirectory): Create a directory to store uploaded files inside
the data directory. In a production server, we can place it outside ServerRoot / DocumentRoot.
(TestServer.prototype.cleanDataDirectory): Delete the aforementioned directory as needed.

* tools/js/database.js:
(tableToPrefixMap): Added uploaded_files.

* tools/js/remote.js:
(NodeRemoteAPI.prototype.sendHttpRequest): Added a dictionary to specify request headers and
a callback to process the response as arguments. Fixed the bug that any 2xx code other than 200
was resulting in a rejected promise. Also include the response headers in the result for tests.
Finally, when content is a function, call that instead of writing the content since FormData
requires a custom logic.
(NodeRemoteAPI.prototype.sendHttpRequestWithFormData): Added.

* tools/js/v3-models.js: Include uploaded-file.js.

* tools/run-tests.py:
(main): Add form-data as a new dependency.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgChangeLog">trunk/Websites/perf.webkit.org/ChangeLog</a></li>
<li><a href="#trunkWebsitesperfwebkitorgconfigjson">trunk/Websites/perf.webkit.org/config.json</a></li>
<li><a href="#trunkWebsitesperfwebkitorginitdatabasesql">trunk/Websites/perf.webkit.org/init-database.sql</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludejsonheaderphp">trunk/Websites/perf.webkit.org/public/include/json-header.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludemanifestgeneratorphp">trunk/Websites/perf.webkit.org/public/include/manifest-generator.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicsharedcommonremotejs">trunk/Websites/perf.webkit.org/public/shared/common-remote.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3privilegedapijs">trunk/Websites/perf.webkit.org/public/v3/privileged-api.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3remotejs">trunk/Websites/perf.webkit.org/public/v3/remote.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapimanifestjs">trunk/Websites/perf.webkit.org/server-tests/api-manifest.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsresourcestestserverconf">trunk/Websites/perf.webkit.org/server-tests/resources/test-server.conf</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsresourcestestserverjs">trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsdatabasejs">trunk/Websites/perf.webkit.org/tools/js/database.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsremotejs">trunk/Websites/perf.webkit.org/tools/js/remote.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsv3modelsjs">trunk/Websites/perf.webkit.org/tools/js/v3-models.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsruntestspy">trunk/Websites/perf.webkit.org/tools/run-tests.py</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgpublicapiuploadedfilephp">trunk/Websites/perf.webkit.org/public/api/uploaded-file.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludeuploadedfilehelpersphp">trunk/Websites/perf.webkit.org/public/include/uploaded-file-helpers.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicprivilegedapiuploadfileformhtml">trunk/Websites/perf.webkit.org/public/privileged-api/upload-file-form.html</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicprivilegedapiuploadfilephp">trunk/Websites/perf.webkit.org/public/privileged-api/upload-file.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelsuploadedfilejs">trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapiuploadedfilejs">trunk/Websites/perf.webkit.org/server-tests/api-uploaded-file.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsprivilegedapiuploadfiletestsjs">trunk/Websites/perf.webkit.org/server-tests/privileged-api-upload-file-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsresourcestemporaryfilejs">trunk/Websites/perf.webkit.org/server-tests/resources/temporary-file.js</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunkWebsitesperfwebkitorgChangeLog"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/ChangeLog (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/ChangeLog        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/ChangeLog        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -1,3 +1,99 @@
</span><ins>+2017-03-16  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
+
+        Add the file uploading capability to the perf dashboard.
+        https://bugs.webkit.org/show_bug.cgi?id=169737
+
+        Reviewed by Chris Dumez.
+
+        Added /privileged-api/upload-file to upload a file, and /api/uploaded-file/ to download the file
+        and retrieve its meta data based on its SHA256. We treat two files with the identical SHA256 as
+        identical since anyone who can upload a file using this mechanism can execute arbitrary code in
+        our bots anyway. This is important for avoiding uploading a large darwinup roots multiple times
+        to the server, saving both user's time/bandwidth and server's disk space.
+
+        * config.json: Added uploadDirectory, uploadFileLimitInMB, and uploadUserQuotaInMB as options.
+        * init-database.sql: Added uploaded_files table.
+
+        * public/api/uploaded-file.php: Added.
+        (main): /api/uploaded-file/N would download uploaded_file with id=N. /api/uploaded-file/?sha256=X
+        would return the meta data for uploaded_file with sha256=X.
+        (stream_file_content): Streams the file content in 64KB chunks. We support Range &amp; If-Range HTTP
+        request headers so that browsers can pause and resume downloading of a large root file.
+        (parse_range_header): Parses Range HTTP request header.
+
+        * public/include/json-header.php:
+        (remote_user_name): Use the default argument of NULL.
+
+        * public/include/manifest-generator.php:
+        (ManifestGenerator::generate): Include the maximum upload size in the manifest file to let the
+        frontend code preemptively check the file size before attempting to submit a file.
+
+        * public/include/uploaded-file-helpers.php: Added.
+        (format_uploaded_file):
+        (uploaded_file_path_for_row):
+
+        * public/privileged-api/upload-file-form.html: Added. For debugging purposes.
+        (fetchCSRFfToken):
+        (upload):
+
+        * public/privileged-api/upload-file.php: Added.
+        (main):
+        (query_total_file_size):
+        (create_uploaded_file_from_form_data):
+
+        * public/shared/common-remote.js:
+        (CommonRemoteAPI.prototype.postFormData): Added.
+        (CommonRemoteAPI.prototype.postFormDataWithStatus): Added.
+        (CommonRemoteAPI.prototype.sendHttpRequestWithFormData): Added.
+        (CommonRemoteAPI.prototype._asJSON): Throw an exception instead of calling a non-existent reject.
+
+        * public/v3/models/uploaded-file.js: Added.
+        (UploadedFile): Added.
+        (UploadedFile.uploadFile): Added.
+        (UploadedFile.fetchUnloadedFileWithIdenticalHash): Added. Finds the file with the same SHA256 in
+        the server to avoid uploading a large custom root multiple times.
+        (UploadedFile._computeSHA256Hash): Added.
+
+        * public/v3/privileged-api.js:
+        (PrivilegedAPI.prototype.sendRequest): Added the options dictionary as a third argument. For now,
+        only support useFormData boolean.
+
+        * public/v3/remote.js:
+        (BrowserRemoteAPI.prototype.sendHttpRequestWithFormData): Added.
+
+        * server-tests/api-manifest.js: Updated per the inclusion of fileUploadSizeLimit in the manifest.
+        * server-tests/api-uploaded-file.js: Added.
+        * server-tests/privileged-api-upload-file-tests.js: Added.
+
+        * server-tests/resources/temporary-file.js: Added.
+        (TemporaryFile): Added. A helper class for creating a temporary file to upload.
+        (TemporaryFile.makeTemporaryFileOfSizeInMB):
+        (TemporaryFile.makeTemporaryFile):
+        (TemporaryFile.inject):
+
+        * server-tests/resources/test-server.conf: Set upload_max_filesize and post_max_size for testing.
+        * server-tests/resources/test-server.js:
+        (TestServer.prototype.testConfig): Use uploadFileLimitInMB and uploadUserQuotaInMB of 2MB and 5MB.
+        (TestServer.prototype._ensureDataDirectory): Create a directory to store uploaded files inside
+        the data directory. In a production server, we can place it outside ServerRoot / DocumentRoot.
+        (TestServer.prototype.cleanDataDirectory): Delete the aforementioned directory as needed.
+
+        * tools/js/database.js:
+        (tableToPrefixMap): Added uploaded_files.
+
+        * tools/js/remote.js:
+        (NodeRemoteAPI.prototype.sendHttpRequest): Added a dictionary to specify request headers and
+        a callback to process the response as arguments. Fixed the bug that any 2xx code other than 200
+        was resulting in a rejected promise. Also include the response headers in the result for tests.
+        Finally, when content is a function, call that instead of writing the content since FormData
+        requires a custom logic.
+        (NodeRemoteAPI.prototype.sendHttpRequestWithFormData): Added.
+
+        * tools/js/v3-models.js: Include uploaded-file.js.
+
+        * tools/run-tests.py:
+        (main): Add form-data as a new dependency.
+
</ins><span class="cx"> 2017-03-15  Dewei Zhu  &lt;dewei_zhu@apple.com&gt;
</span><span class="cx"> 
</span><span class="cx">         Fix unit test and bug fix for 'pull-os-versions.js' script.
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgconfigjson"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/config.json (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/config.json        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/config.json        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -3,6 +3,9 @@
</span><span class="cx">     &quot;debug&quot;: true,
</span><span class="cx">     &quot;jsonCacheMaxAge&quot;: 600,
</span><span class="cx">     &quot;dataDirectory&quot;: &quot;public/data/&quot;,
</span><ins>+    &quot;uploadDirectory&quot;: &quot;uploaded&quot;,
+    &quot;uploadFileLimitInMB&quot;: 800,
+    &quot;uploadUserQuotaInMB&quot;: 8192,
</ins><span class="cx">     &quot;database&quot;: {
</span><span class="cx">         &quot;host&quot;: &quot;localhost&quot;,
</span><span class="cx">         &quot;port&quot;: &quot;5432&quot;,
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorginitdatabasesql"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/init-database.sql (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/init-database.sql        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/init-database.sql        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -24,6 +24,7 @@
</span><span class="cx"> DROP TABLE IF EXISTS build_triggerables CASCADE;
</span><span class="cx"> DROP TABLE IF EXISTS triggerable_configurations CASCADE;
</span><span class="cx"> DROP TABLE IF EXISTS triggerable_repositories CASCADE;
</span><ins>+DROP TABLE IF EXISTS uploaded_files CASCADE;
</ins><span class="cx"> DROP TABLE IF EXISTS bugs CASCADE;
</span><span class="cx"> DROP TABLE IF EXISTS analysis_test_groups CASCADE;
</span><span class="cx"> DROP TABLE IF EXISTS commit_sets CASCADE;
</span><span class="lines">@@ -241,6 +242,19 @@
</span><span class="cx">     trigconfig_triggerable integer REFERENCES build_triggerables NOT NULL,
</span><span class="cx">     CONSTRAINT triggerable_must_be_unique_for_test_and_platform UNIQUE(trigconfig_test, trigconfig_platform));
</span><span class="cx"> 
</span><ins>+CREATE TABLE uploaded_files (
+    file_id serial PRIMARY KEY,
+    file_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
+    file_deleted_at timestamp,
+    file_mime varchar(64),
+    file_filename varchar(1024) NOT NULL,
+    file_extension varchar(16),
+    file_author varchar(256),
+    file_size bigint NOT NULL,
+    file_sha256 char(64) NOT NULL);
+CREATE INDEX file_author_index ON uploaded_files(file_author);
+CREATE UNIQUE INDEX file_sha256_index ON uploaded_files(file_sha256) WHERE file_deleted_at is NULL;
+
</ins><span class="cx"> CREATE TABLE analysis_test_groups (
</span><span class="cx">     testgroup_id serial PRIMARY KEY,
</span><span class="cx">     testgroup_task integer REFERENCES analysis_tasks NOT NULL,
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicapiuploadedfilephp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/api/uploaded-file.php (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/api/uploaded-file.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/api/uploaded-file.php        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,122 @@
</span><ins>+&lt;?php
+
+require('../include/json-header.php');
+require('../include/uploaded-file-helpers.php');
+
+function main($path)
+{
+    if (count($path) &gt; 1)
+        exit_with_error('InvalidRequest');
+
+    $db = connect();
+    if (count($path) &amp;&amp; $path[0]) {
+        $file_id = intval($path[0]);
+        $file_row = $db-&gt;select_first_row('uploaded_files', 'file', array('id' =&gt; $file_id));
+        if (!$file_row)
+            exit_with_404();
+        $file_path = uploaded_file_path_for_row($file_row);
+        return stream_file_content($file_path, $file_row['file_sha256'], $file_row['file_filename']);
+    }
+
+    $sha256 = array_get($_GET, 'sha256');
+    if ($sha256) {
+        $file_row = $db-&gt;select_first_row('uploaded_files', 'file', array('sha256' =&gt; $sha256, 'deleted_at' =&gt; null));
+        if (!$file_row)
+            exit_with_error('NotFound');
+        exit_with_success(array('uploadedFile' =&gt; format_uploaded_file($file_row)));
+    }
+    exit_with_error('InvalidArguments');
+}
+
+define('STREAM_CHUNK_SIZE', 64 * 1024);
+
+function stream_file_content($uploaded_file, $etag, $disposition_name)
+{
+    if (!file_exists($uploaded_file))
+        exit_with_404();
+
+    $file_size = filesize($uploaded_file);
+    $last_modified = gmdate('D, d M Y H:i:s', filemtime($uploaded_file)) . ' GMT';
+    $file_handle = fopen($uploaded_file, &quot;rb&quot;);
+    if (!$file_handle)
+        exit_with_404();
+
+    $headers = getallheaders();
+
+    // We don't support multi-part range request. e.g. bytes=1-3,4-5
+    $range = parse_range_header(array_get($headers, 'Range'), $file_size);
+    if ($range &amp;&amp; (!array_key_exists('If-Range', $headers) || $headers['If-Range'] == $last_modified || $headers['If-Range'] == $etag)) {
+        assert($range['start'] &gt;= 0);
+        if ($range['start'] &gt; $range['end'] || $range['end'] &gt;= $file_size) {
+            header('HTTP/1.1 416 Range Not Satisfiable');
+            header(&quot;Content-Range: bytes */$file_size&quot;);
+            exit(416);
+        }
+        $start = $range['start'];
+        $end = $range['end'];
+        $content_length = $end - $start + 1;
+        header('HTTP/1.1 206 Partial Content');
+        header(&quot;Content-Range: bytes $start-$end/$file_size&quot;);
+        fseek($file_handle, $start);
+    } else {
+        $content_length = $file_size;
+        header(&quot;Accept-Ranges: bytes&quot;);
+        header(&quot;ETag: $etag&quot;);
+    }
+
+    $output_buffer = fopen('php://output', 'wb');
+    $encoded_filename = urlencode($disposition_name);
+
+    header('Content-Type: application/octet-stream');
+    header('Content-Length: ' . $content_length);
+    header(&quot;Content-Disposition: attachment; filename*=utf-8''$encoded_filename&quot;);
+    header(&quot;Last-Modified: $last_modified&quot;);
+
+    set_time_limit(0);
+    while (!feof($file_handle) &amp;&amp; $content_length) {
+        $is_end = $content_length &lt; STREAM_CHUNK_SIZE;
+        $chunk_size = $is_end ? $content_length : STREAM_CHUNK_SIZE;
+        $chunk = fread($file_handle, $chunk_size);
+        $content_length -= $chunk_size;
+        fwrite($output_buffer, $chunk, $chunk_size);
+        flush();
+    }
+
+    exit(0);
+}
+
+function parse_range_header($range_header, $file_size)
+{
+    // We don't support multi-part range request. e.g. bytes=1-3,4-5
+    $matches = array();
+    $end_byte = $file_size;
+    if (!$range_header || !preg_match('/^\s*bytes\s*=\s*((\d+)-(\d*)|-(\d+))\s*$/', $range_header, $matches))
+        return NULL;
+
+    $end_byte = $file_size - 1;
+    if ($matches[2]) {
+        $start_byte = intval($matches[2]);
+        if ($matches[3])
+            $end_byte = intval($matches[3]);
+        else
+            $end_byte = $file_size - 1;
+    } else {
+        $suffix_length = intval($matches[4]);
+        if ($file_size &lt; $suffix_length)
+            $start_byte = 0;
+        else
+            $start_byte = $file_size - $suffix_length;
+    }
+
+    return array('start' =&gt; $start_byte, 'end' =&gt; $end_byte);
+}
+
+function exit_with_404()
+{
+    header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
+    exit_with_error('NotFound');
+}
+
+main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array());
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludejsonheaderphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/json-header.php (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/json-header.php        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/public/include/json-header.php        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -1,6 +1,7 @@
</span><span class="cx"> &lt;?php
</span><span class="cx"> 
</span><span class="cx"> require_once('db.php');
</span><ins>+
</ins><span class="cx"> require_once('test-path-resolver.php');
</span><span class="cx"> 
</span><span class="cx"> header('Content-type: application/json');
</span><span class="lines">@@ -116,8 +117,8 @@
</span><span class="cx">     return $data;
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function remote_user_name($data) {
-    return should_authenticate_as_slave($data) ? NULL : array_get($_SERVER, 'REMOTE_USER');
</del><ins>+function remote_user_name($data = NULL) {
+    return $data &amp;&amp; should_authenticate_as_slave($data) ? NULL : array_get($_SERVER, 'REMOTE_USER');
</ins><span class="cx"> }
</span><span class="cx"> 
</span><span class="cx"> function should_authenticate_as_slave($data) {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludemanifestgeneratorphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/manifest-generator.php (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/manifest-generator.php        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/public/include/manifest-generator.php        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -43,6 +43,7 @@
</span><span class="cx">             'triggerables'=&gt; (object)$this-&gt;triggerables(),
</span><span class="cx">             'dashboards' =&gt; (object)config('dashboards'),
</span><span class="cx">             'summaryPages' =&gt; config('summaryPages'),
</span><ins>+            'fileUploadSizeLimit' =&gt; config('uploadFileLimitInMB', 0) * 1024 * 1024,
</ins><span class="cx">         );
</span><span class="cx"> 
</span><span class="cx">         $this-&gt;manifest['elapsedTime'] = (microtime(true) - $start_time) * 1000;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludeuploadedfilehelpersphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/include/uploaded-file-helpers.php (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/uploaded-file-helpers.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/include/uploaded-file-helpers.php        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,20 @@
</span><ins>+&lt;?php
+
+function format_uploaded_file($file_row)
+{
+    return array(
+        'id' =&gt; $file_row['file_id'],
+        'size' =&gt; $file_row['file_size'],
+        'createdAt' =&gt; $file_row['file_created_at'],
+        'mime' =&gt; $file_row['file_mime'],
+        'filename' =&gt; $file_row['file_filename'],
+        'author' =&gt; $file_row['file_author'],
+        'sha256' =&gt; $file_row['file_sha256']);
+}
+
+function uploaded_file_path_for_row($file_row)
+{
+    return config_path('uploadDirectory', $file_row['file_id'] . $file_row['file_extension']);
+}
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicprivilegedapiuploadfileformhtml"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/privileged-api/upload-file-form.html (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/privileged-api/upload-file-form.html                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/privileged-api/upload-file-form.html        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,59 @@
</span><ins>+&lt;!DOCTYPE html&gt;
+&lt;html&gt;
+&lt;body&gt;
+&lt;script&gt;
+
+function fetchCSRFfToken(fileInput, progressElement) {
+    return new Promise((resolve, reject) =&gt; {
+        const xhr = new XMLHttpRequest();
+        xhr.open('POST', 'generate-csrf-token', true);
+        xhr.onload = () =&gt; {
+            let content;
+            try {
+                content = JSON.parse(xhr.responseText);
+            } catch (error) {
+                return reject(error + ':' + xhr.responseText);
+            }
+            if (content['status'] != 'OK')
+                reject(content['status']);
+            else
+                resolve(content['token']);
+        }
+        xhr.onerror = reject;
+        xhr.send('{}');
+    });
+}
+
+function upload(fileInput, progressElement) {
+    fetchCSRFfToken().then((token) =&gt; {
+        const xhr = new XMLHttpRequest();
+        const formData = new FormData();
+        formData.append('token', token);
+        formData.append('newFile', fileInput.files[0]);
+
+        xhr.open('POST', 'upload-file', true);
+        xhr.onload = function () {
+            alert(xhr.response);
+        }
+        xhr.onerror = function () {
+            alert('error: ' + xhr.response);
+        }
+        xhr.upload.onprogress = function (event) {
+            if (event.lengthComputable) {
+                progressElement.max = event.total;
+                progressElement.value = event.loaded;
+            }
+        }
+        xhr.send(formData);
+    }, (error) =&gt; {
+        alert(`Failed to fetch the CSRF token: ${error}`);
+    });
+}
+
+&lt;/script&gt;
+&lt;p&gt;Upload a new custom root. &lt;b&gt;This is for debuging purpose only. The uploaded file will be deleted.&lt;/b&gt;&lt;/p&gt;
+&lt;input id=file type=&quot;file&quot;&gt;
+&lt;button onclick=&quot;upload(file, p)&quot;&gt;Upload&lt;/button&gt;
+&lt;progress id=p&gt;&lt;/progress&gt;
+&lt;/body&gt;
+&lt;/html&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicprivilegedapiuploadfilephp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/privileged-api/upload-file.php (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/privileged-api/upload-file.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/privileged-api/upload-file.php        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,99 @@
</span><ins>+&lt;?php
+
+ini_set('upload_max_filesize', '1025M');
+ini_set('post_max_size', '1025M');
+require_once('../include/json-header.php');
+require_once('../include/uploaded-file-helpers.php');
+
+define('MEGABYTES', 1024 * 1024);
+
+function main()
+{
+    if (array_get($_SERVER, 'CONTENT_LENGTH') &amp;&amp; empty($_POST) &amp;&amp; empty($_FILES))
+        exit_with_error('FileSizeLimitExceeded2');
+
+    if (!verify_token(array_get($_POST, 'token')))
+        exit_with_error('InvalidToken');
+
+    if (!is_dir(config_path('uploadDirectory', '')))
+        exit_with_error('NotSupported');
+
+    $input_file = array_get($_FILES, 'newFile');
+    if (!$input_file)
+        exit_with_error('NoFileSpecified');
+
+    if ($input_file['error'] != UPLOAD_ERR_OK)
+        exit_with_error('FailedToUploadFile', array('name' =&gt; $input_file['name'], 'error' =&gt; $input_file['error']));
+
+    if (config('uploadFileLimitInMB') * MEGABYTES &lt; $input_file['size'])
+        exit_with_error('FileSizeLimitExceeded');
+
+    $uploaded_file = create_uploaded_file_from_form_data($input_file);
+
+    $current_user = remote_user_name();
+    $db = connect();
+
+    // FIXME: Cleanup old files.
+
+    if (config('uploadUserQuotaInMB') * MEGABYTES - query_total_file_size($db, $current_user) &lt; $input_file['size'])
+        exit_with_error('FileSizeQuotaExceeded');
+
+    $db-&gt;begin_transaction();
+    $file_row = $db-&gt;select_or_insert_row('uploaded_files', 'file',
+        array('sha256' =&gt; $uploaded_file['sha256'], 'deleted_at' =&gt; null), $uploaded_file, '*');
+    if (!$file_row)
+        exit_with_error('FailedToInsertFileData');
+
+    // A concurrent session may have inserted another file.
+    if (config('uploadUserQuotaInMB') * MEGABYTES &lt; query_total_file_size($db, $current_user)) {
+        $db-&gt;rollback_transaction();
+        exit_with_error('FileSizeQuotaExceeded');
+    }
+
+    $new_path = uploaded_file_path_for_row($file_row);
+    if (!move_uploaded_file($input_file['tmp_name'], $new_path)) {
+        $db-&gt;rollback_transaction();
+        exit_with_error('FailedToMoveUploadedFile');
+    }
+    $db-&gt;commit_transaction();
+
+    exit_with_success(array('uploadedFile' =&gt; format_uploaded_file($file_row)));
+}
+
+function query_total_file_size($db, $user)
+{
+    if ($user)
+        $count_result = $db-&gt;query_and_fetch_all('SELECT sum(file_size) as &quot;sum&quot; FROM uploaded_files WHERE file_deleted_at IS NULL AND file_author = $1', array($user));
+    else
+        $count_result = $db-&gt;query_and_fetch_all('SELECT sum(file_size) as &quot;sum&quot; FROM uploaded_files WHERE file_deleted_at IS NULL AND file_author IS NULL');
+    if (!$count_result)
+        return FALSE;
+    return intval($count_result[0][&quot;sum&quot;]);
+}
+
+function create_uploaded_file_from_form_data($input_file)
+{
+    $file_sha256 = hash_file('sha256', $input_file['tmp_name']);
+    if (!$file_sha256)
+        exit_with_error('FailedToComputeSHA256');
+
+    $matches = array();
+    $file_extension = null;
+    if (preg_match('/(\.[a-zA-Z0-9]{1,5}){1,2}$/', $input_file['name'], $matches)) {
+        $file_extension = $matches[0];
+        assert(strlen($file_extension) &lt;= 16);
+    }
+
+    return array(
+        'author' =&gt; remote_user_name(),
+        'filename' =&gt; $input_file['name'],
+        'extension' =&gt; $file_extension,
+        'mime' =&gt; $input_file['type'], // Sanitize MIME types.
+        'size' =&gt; $input_file['size'],
+        'sha256' =&gt; $file_sha256
+    );
+}
+
+main();
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicsharedcommonremotejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/shared/common-remote.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/shared/common-remote.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/public/shared/common-remote.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -11,6 +11,19 @@
</span><span class="cx">         return this._checkStatus(this.postJSON(path, data));
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    postFormData(path, data)
+    {
+        const formData = new FormData();
+        for (let key in data)
+            formData.append(key, data[key]);
+        return this._asJSON(this.sendHttpRequestWithFormData(path, formData));
+    }
+
+    postFormDataWithStatus(path, data)
+    {
+        return this._checkStatus(this.postFormData(path, data));
+    }
+
</ins><span class="cx">     getJSON(path)
</span><span class="cx">     {
</span><span class="cx">         return this._asJSON(this.sendHttpRequest(path, 'GET', null, null));
</span><span class="lines">@@ -26,6 +39,11 @@
</span><span class="cx">         throw 'NotImplemented';
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    sendHttpRequestWithFormData(path, formData)
+    {
+        throw 'NotImplemented';
+    }
+
</ins><span class="cx">     _asJSON(promise)
</span><span class="cx">     {
</span><span class="cx">         return promise.then((result) =&gt; {
</span><span class="lines">@@ -33,7 +51,7 @@
</span><span class="cx">                 return JSON.parse(result.responseText);
</span><span class="cx">             } catch (error) {
</span><span class="cx">                 console.error(result.responseText);
</span><del>-                reject(result.statusCode + ', ' + error);
</del><ins>+                throw `{result.statusCode}: ${error}`;
</ins><span class="cx">             }
</span><span class="cx">         });
</span><span class="cx">     }
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelsuploadedfilejs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/v3/models/uploaded-file.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,54 @@
</span><ins>+
+class UploadedFile extends DataModelObject {
+
+    constructor(id, object)
+    {
+        super(id, object);
+        this._createdAt = new Date(object.createdAt);
+        this._filename = object.filename;
+        this._author = object.author;
+        this._size = object.size;
+        this._sha256 = object.sha256;
+        this.ensureNamedStaticMap('sha256')[object.sha256] = this;
+    }
+
+    static uploadFile(file)
+    {
+        return PrivilegedAPI.sendRequest('upload-file', {'newFile': file}, {useFormData: true}).then((rawData) =&gt; {
+            return UploadedFile.ensureSingleton(rawData['uploadedFile'].id, rawData['uploadedFile']);
+        });
+    }
+
+    static fetchUnloadedFileWithIdenticalHash(file)
+    {
+        return new Promise((resolve, reject) =&gt; {
+            const reader = new FileReader();
+            reader.onload = () =&gt; resolve(reader.result);
+            reader.onerror = () =&gt; reject();
+            reader.readAsArrayBuffer(file);
+        }).then((content) =&gt; {
+            return this._computeSHA256Hash(content);
+        }).then((sha256) =&gt; {
+            const map = this.namedStaticMap('sha256');
+            if (map &amp;&amp; sha256 in map)
+                return map[sha256];
+            return RemoteAPI.getJSONWithStatus(`../api/uploaded-file?sha256=${sha256}`).then((rawData) =&gt; {
+                if (!rawData['uploadedFile'])
+                    return null;
+                return UploadedFile.ensureSingleton(rawData['uploadedFile'].id, rawData['uploadedFile']);
+            });
+        });
+    }
+
+    static _computeSHA256Hash(content)
+    {
+        return crypto.subtle.digest('SHA-256', content).then((digest) =&gt; {
+            return Array.from(new Uint8Array(digest)).map((byte) =&gt; {
+                if (byte &lt; 0x10)
+                    return '0' + byte.toString(16);
+                return byte.toString(16);
+            }).join('');
+        });
+    }
+
+}
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3privilegedapijs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/privileged-api.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/privileged-api.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/public/v3/privileged-api.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -2,7 +2,7 @@
</span><span class="cx"> 
</span><span class="cx"> class PrivilegedAPI {
</span><span class="cx"> 
</span><del>-    static sendRequest(path, data)
</del><ins>+    static sendRequest(path, data, options = {useFormData: false})
</ins><span class="cx">     {
</span><span class="cx">         const clonedData = {};
</span><span class="cx">         for (let key in data)
</span><span class="lines">@@ -9,7 +9,9 @@
</span><span class="cx">             clonedData[key] = data[key];
</span><span class="cx"> 
</span><span class="cx">         const fullPath = '/privileged-api/' + path;
</span><del>-        const post = () =&gt; RemoteAPI.postJSONWithStatus(fullPath, clonedData);
</del><ins>+        const post = options.useFormData
+            ? () =&gt; RemoteAPI.postFormDataWithStatus(fullPath, clonedData)
+            : () =&gt; RemoteAPI.postJSONWithStatus(fullPath, clonedData);
</ins><span class="cx"> 
</span><span class="cx">         return this.requestCSRFToken().then((token) =&gt; {
</span><span class="cx">             clonedData['token'] = token;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3remotejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/remote.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/remote.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/public/v3/remote.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -36,6 +36,11 @@
</span><span class="cx">         });
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    sendHttpRequestWithFormData(path, formData)
+    {
+        return this.sendHttpRequest(path, 'POST', null, formData); // Content-type is set by the browser.
+    }
+
</ins><span class="cx"> }
</span><span class="cx"> 
</span><span class="cx"> const RemoteAPI = new BrowserRemoteAPI;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapimanifestjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/api-manifest.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-manifest.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/server-tests/api-manifest.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -14,7 +14,7 @@
</span><span class="cx">     it(&quot;should generate an empty manifest when database is empty&quot;, () =&gt; {
</span><span class="cx">         return TestServer.remoteAPI().getJSON('/api/manifest').then((manifest) =&gt; {
</span><span class="cx">             assert.deepEqual(Object.keys(manifest).sort(), ['all', 'bugTrackers', 'builders', 'dashboard', 'dashboards',
</span><del>-                'elapsedTime', 'metrics', 'repositories', 'siteTitle', 'status', 'summaryPages', 'tests', 'triggerables']);
</del><ins>+                'elapsedTime', 'fileUploadSizeLimit', 'metrics', 'repositories', 'siteTitle', 'status', 'summaryPages', 'tests', 'triggerables']);
</ins><span class="cx"> 
</span><span class="cx">             assert.equal(typeof(manifest.elapsedTime), 'number');
</span><span class="cx">             delete manifest.elapsedTime;
</span><span class="lines">@@ -26,6 +26,7 @@
</span><span class="cx">                 builders: {},
</span><span class="cx">                 dashboard: {},
</span><span class="cx">                 dashboards: {},
</span><ins>+                fileUploadSizeLimit: 2097152, // 2MB during testing.
</ins><span class="cx">                 metrics: {},
</span><span class="cx">                 repositories: {},
</span><span class="cx">                 tests: {},
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapiuploadedfilejs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/server-tests/api-uploaded-file.js (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-uploaded-file.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/server-tests/api-uploaded-file.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,309 @@
</span><ins>+'use strict';
+
+require('../tools/js/v3-models.js');
+
+const assert = require('assert');
+global.FormData = require('form-data');
+
+const TestServer = require('./resources/test-server.js');
+const TemporaryFile = require('./resources/temporary-file.js').TemporaryFile;
+
+describe('/api/uploaded-file', function () {
+    this.timeout(5000);
+    TestServer.inject();
+
+    TemporaryFile.inject();
+
+    it('should return &quot;InvalidArguments&quot; when neither path nor sha256 query is set', () =&gt; {
+        return TestServer.remoteAPI().getJSON('/api/uploaded-file').then((content) =&gt; {
+            assert.equal(content['status'], 'InvalidArguments');
+            return TestServer.remoteAPI().getJSON('/api/uploaded-file/');
+        }).then((content) =&gt; {
+            assert.equal(content['status'], 'InvalidArguments');
+        });
+    });
+
+    it('should return 404 when there is no file with the specified ID', () =&gt; {
+        return TestServer.remoteAPI().getJSON('/api/uploaded-file/1').then((content) =&gt; {
+            assert(false, 'should never be reached');
+        }, (error) =&gt; {
+            assert.equal(error, 404);
+        });
+    });
+
+    it('should return 404 when the specified ID is not a valid integer', () =&gt; {
+        return TestServer.remoteAPI().getJSON('/api/uploaded-file/foo').then((content) =&gt; {
+            assert(false, 'should never be reached');
+        }, (error) =&gt; {
+            assert.equal(error, 404);
+        });
+    });
+
+    it('should return the file content matching the specified file ID', () =&gt; {
+        let uploadedFile;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile = response['uploadedFile'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${uploadedFile['id']}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            assert.equal(response.responseText, 'some content');
+        });
+    });
+
+    it('should return &quot;NotFound&quot; when the specified SHA256 is invalid', () =&gt; {
+        return TestServer.remoteAPI().getJSON('/api/uploaded-file/?sha256=abc').then((content) =&gt; {
+            assert.equal(content['status'], 'NotFound');
+        });
+    });
+
+    it('should return &quot;NotFound&quot; when there is no file matching the specified SHA256 ', () =&gt; {
+        return TestServer.remoteAPI().getJSON('/api/uploaded-file/?sha256=5256ec18f11624025905d057d6befb03d77b243511ac5f77ed5e0221ce6d84b5').then((content) =&gt; {
+            assert.equal(content['status'], 'NotFound');
+        });
+    });
+
+    it('should return the meta data of the file with the specified SHA256', () =&gt; {
+        let uploadedFile;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile = response['uploadedFile'];
+            return TestServer.remoteAPI().getJSON(`/api/uploaded-file/?sha256=${uploadedFile['sha256']}`);
+        }).then((response) =&gt; {
+            assert.deepEqual(uploadedFile, response['uploadedFile']);
+        });
+    });
+
+    it('should return &quot;NotFound&quot; when the file matching the specified SHA256 had already been deleted', () =&gt; {
+        let uploadedFile;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile = response['uploadedFile'];
+            const db = TestServer.database();
+            return db.connect().then(() =&gt; db.query(`UPDATE uploaded_files SET file_deleted_at = now() at time zone 'utc'`));
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().getJSON(`/api/uploaded-file/?sha256=${uploadedFile['sha256']}`);
+        }).then((content) =&gt; {
+            assert.equal(content['status'], 'NotFound');
+        });
+    });
+
+
+    it('should respond with ETag, Acccept-Ranges, Content-Disposition, Content-Length, and Last-Modified headers', () =&gt; {
+        let uploadedFile;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile = response['uploadedFile'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${uploadedFile['id']}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            const headers = response.headers;
+
+            assert(Object.keys(headers).includes('etag'));
+            assert.equal(headers['etag'], uploadedFile['sha256']);
+
+            assert(Object.keys(headers).includes('accept-ranges'));
+            assert.equal(headers['accept-ranges'], 'bytes');
+
+            assert(Object.keys(headers).includes('content-disposition'));
+            assert.equal(headers['content-disposition'], `attachment; filename*=utf-8''some.dat`);
+
+            assert(Object.keys(headers).includes('content-length'));
+            assert.equal(headers['content-length'], uploadedFile['size']);
+
+            assert(Object.keys(headers).includes('last-modified'));
+        });
+    });
+
+    it('should respond with the same Last-Modified each time', () =&gt; {
+        let id;
+        let lastModified;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            lastModified = response.headers['last-modified'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            assert.equal(response.headers['last-modified'], lastModified);
+        });
+    });
+
+    it('should respond with Content-Range when requested after X bytes', () =&gt; {
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            const id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null, {'Range': 'bytes=5-'});
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], 'bytes 5-11/12');
+            assert.equal(response.responseText, 'content');
+        });
+    });
+
+    it('should respond with Content-Range when requested between X-Y bytes', () =&gt; {
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            const id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null, {'Range': 'bytes=4-9'});
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], 'bytes 4-9/12');
+            assert.equal(response.responseText, ' conte');
+        });
+    });
+
+    it('should respond with Content-Range when requested for the last X bytes', () =&gt; {
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            const id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null, {'Range': 'bytes=-4'});
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], 'bytes 8-11/12');
+            assert.equal(response.responseText, 'tent');
+        });
+    });
+
+    it('should respond with Content-Range for the whole content when the suffix length is larger than the content', () =&gt; {
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            const id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null, {'Range': 'bytes=-100'});
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], 'bytes 0-11/12');
+            assert.equal(response.responseText, 'some content');
+        });
+    });
+
+    it('should return 416 when the starting byte is after the file size', () =&gt; {
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            const id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null, {'Range': 'bytes=12-'})
+                .then(() =&gt; assert(false, 'should never be reached'), (error) =&gt; assert.equal(error, 416));
+        });
+    });
+
+    it('should return 416 when the starting byte after the ending byte', () =&gt; {
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            const id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null, {'Range': 'bytes=2-1'})
+                .then(() =&gt; assert(false, 'should never be reached'), (error) =&gt; assert.equal(error, 416));
+        });
+    });
+
+    it('should respond with Content-Range when If-Range matches the last modified date', () =&gt; {
+        let id;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            assert.equal(response.statusCode, 200);
+            assert.equal(response.responseText, 'some content');
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null,
+                {'Range': 'bytes = 9-10', 'If-Range': response.headers['last-modified']});
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], 'bytes 9-10/12');
+            assert.equal(response.responseText, 'en');
+        });
+    });
+
+    it('should respond with Content-Range when If-Range matches ETag', () =&gt; {
+        let id;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            assert.equal(response.statusCode, 200);
+            assert.equal(response.responseText, 'some content');
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null,
+                {'Range': 'bytes = 9-10', 'If-Range': response.headers['etag']});
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], 'bytes 9-10/12');
+            assert.equal(response.responseText, 'en');
+        });
+    });
+
+    it('should return the full content when If-Range does not match the last modified date or ETag', () =&gt; {
+        let id;
+        return TemporaryFile.makeTemporaryFile('some.dat', 'some content').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null);
+        }).then((response) =&gt; {
+            assert.equal(response.statusCode, 200);
+            assert.equal(response.responseText, 'some content');
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null,
+                {'Range': 'bytes = 9-10', 'If-Range': 'foo'});
+        }).then((response) =&gt; {
+            assert.equal(response.statusCode, 200);
+            assert.equal(response.responseText, 'some content');
+        });
+    });
+
+    it('should respond with Content-Range across 64KB streaming chunks', () =&gt; {
+        let id;
+        const fileSize = 256 * 1024;
+        const tokens = &quot;0123456789abcdefghijklmnopqrstuvwxyz&quot;;
+        let buffer = Buffer.allocUnsafe(fileSize);
+        for (let i = 0; i &lt; fileSize; i++)
+            buffer[i] = Math.floor(Math.random() * 256);
+        let startByte = 63 * 1024;
+        let endByte = 128 * 1024 - 1;
+
+        let responseBufferList = [];
+        const responseHandler = (response) =&gt; {
+            response.on('data', (chunk) =&gt; responseBufferList.push(chunk));
+        };
+
+        function verifyBuffer()
+        {
+            const responseBuffer = Buffer.concat(responseBufferList);
+            for (let i = 0; i &lt; endByte - startByte + 1; i++) {
+                const actual = responseBuffer[i];
+                const expected = buffer[startByte + i];
+                assert.equal(actual, expected, `The byte at index ${i} should be identical. Expected ${expected} but got ${actual}`);
+            }
+        }
+
+        return TemporaryFile.makeTemporaryFile('some.dat', buffer).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            id = response['uploadedFile']['id'];
+            return TestServer.remoteAPI().sendHttpRequest(`/api/uploaded-file/${id}`, 'GET', null, null,
+                {'Range': `bytes = ${startByte}-${endByte}`}, responseHandler);
+        }).then((response) =&gt; {
+            const headers = response.headers;
+            assert.equal(response.statusCode, 206);
+            assert.equal(headers['content-range'], `bytes ${startByte}-${endByte}/${fileSize}`);
+            verifyBuffer();
+        });
+    });
+
+});
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsprivilegedapiuploadfiletestsjs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/server-tests/privileged-api-upload-file-tests.js (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/privileged-api-upload-file-tests.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/server-tests/privileged-api-upload-file-tests.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,192 @@
</span><ins>+'use strict';
+
+require('../tools/js/v3-models.js');
+
+const assert = require('assert');
+global.FormData = require('form-data');
+
+const TestServer = require('./resources/test-server.js');
+const TemporaryFile = require('./resources/temporary-file.js').TemporaryFile;
+
+describe('/privileged-api/upload-file', function () {
+    this.timeout(5000);
+    TestServer.inject();
+
+    TemporaryFile.inject();
+
+    it('should return &quot;NotFileSpecified&quot; when newFile not is specified', () =&gt; {
+        return PrivilegedAPI.sendRequest('upload-file', {}, {useFormData: true}).then(() =&gt; {
+            assert(false, 'should never be reached');
+        }, (error) =&gt; {
+            assert.equal(error, 'NoFileSpecified');
+        });
+    });
+
+    it('should return &quot;FileSizeLimitExceeded&quot; when the file is too big', () =&gt; {
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.dat', TestServer.testConfig().uploadFileLimitInMB + 1).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true}).then(() =&gt; {
+                assert(false, 'should never be reached');
+            }, (error) =&gt; {
+                assert.equal(error, 'FileSizeLimitExceeded');
+            });
+        });
+    });
+
+    it('should upload a file when the filesize is smaller than the limit', () =&gt; {
+        const db = TestServer.database();
+        const limitInMB = TestServer.testConfig().uploadFileLimitInMB;
+        let uploadedFile;
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.dat', limitInMB).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile = response['uploadedFile'];
+            return db.connect().then(() =&gt; db.selectAll('uploaded_files', 'id'));
+        }).then((rows) =&gt; {
+            assert.equal(rows.length, 1);
+            assert.equal(rows[0].id, uploadedFile.id);
+            assert.equal(rows[0].size, limitInMB * 1024 * 1024);
+            assert.equal(rows[0].size, uploadedFile.size);
+            assert.equal(rows[0].filename, 'some.dat');
+            assert.equal(rows[0].filename, uploadedFile.filename);
+            assert.equal(rows[0].extension, '.dat');
+            assert.equal(rows[0].sha256, '5256ec18f11624025905d057d6befb03d77b243511ac5f77ed5e0221ce6d84b5');
+            assert.equal(rows[0].sha256, uploadedFile.sha256);
+        });
+    });
+
+    it('should not create a duplicate files when the identical files are uploaded', () =&gt; {
+        const db = TestServer.database();
+        const limitInMB = TestServer.testConfig().uploadFileLimitInMB;
+        let uploadedFile1;
+        let uploadedFile2;
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.dat', limitInMB).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile1 = response['uploadedFile'];
+            return TemporaryFile.makeTemporaryFileOfSizeInMB('other.dat', limitInMB);
+        }).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile2 = response['uploadedFile'];
+            return db.connect().then(() =&gt; db.selectAll('uploaded_files', 'id'));
+        }).then((rows) =&gt; {
+            assert.deepEqual(uploadedFile1, uploadedFile2);
+            assert.equal(rows.length, 1);
+            assert.equal(rows[0].id, uploadedFile1.id);
+            assert.equal(rows[0].size, limitInMB * 1024 * 1024);
+            assert.equal(rows[0].size, uploadedFile1.size);
+            assert.equal(rows[0].filename, 'some.dat');
+            assert.equal(rows[0].filename, uploadedFile1.filename);
+            assert.equal(rows[0].extension, '.dat');
+            assert.equal(rows[0].sha256, '5256ec18f11624025905d057d6befb03d77b243511ac5f77ed5e0221ce6d84b5');
+            assert.equal(rows[0].sha256, uploadedFile1.sha256);
+        });
+    });
+
+    it('should not create a duplicate files when the identical files are uploaded', () =&gt; {
+        const db = TestServer.database();
+        const limitInMB = TestServer.testConfig().uploadFileLimitInMB;
+        let uploadedFile1;
+        let uploadedFile2;
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.dat', limitInMB).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile1 = response['uploadedFile'];
+            return TemporaryFile.makeTemporaryFileOfSizeInMB('other.dat', limitInMB);
+        }).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile2 = response['uploadedFile'];
+            return db.connect().then(() =&gt; db.selectAll('uploaded_files', 'id'));
+        }).then((rows) =&gt; {
+            assert.deepEqual(uploadedFile1, uploadedFile2);
+            assert.equal(rows.length, 1);
+            assert.equal(rows[0].id, uploadedFile1.id);
+            assert.equal(rows[0].size, limitInMB * 1024 * 1024);
+            assert.equal(rows[0].size, uploadedFile1.size);
+            assert.equal(rows[0].filename, 'some.dat');
+            assert.equal(rows[0].filename, uploadedFile1.filename);
+            assert.equal(rows[0].extension, '.dat');
+            assert.equal(rows[0].sha256, '5256ec18f11624025905d057d6befb03d77b243511ac5f77ed5e0221ce6d84b5');
+            assert.equal(rows[0].sha256, uploadedFile1.sha256);
+        });
+    });
+
+    it('should re-upload the file when the previously uploaded file had been deleted', () =&gt; {
+        const db = TestServer.database();
+        const limitInMB = TestServer.testConfig().uploadFileLimitInMB;
+        let uploadedFile1;
+        let uploadedFile2;
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.dat', limitInMB).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile1 = response['uploadedFile'];
+            return db.connect().then(() =&gt; db.query(`UPDATE uploaded_files SET file_deleted_at = now() at time zone 'utc'`));
+        }).then(() =&gt; {
+            return TemporaryFile.makeTemporaryFileOfSizeInMB('other.dat', limitInMB);
+        }).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then((response) =&gt; {
+            uploadedFile2 = response['uploadedFile'];
+            return db.connect().then(() =&gt; db.selectAll('uploaded_files', 'id'));
+        }).then((rows) =&gt; {
+            assert.notEqual(uploadedFile1.id, uploadedFile2.id);
+            assert.equal(rows.length, 2);
+            assert.equal(rows[0].id, uploadedFile1.id);
+            assert.equal(rows[1].id, uploadedFile2.id);
+
+            assert.equal(rows[0].filename, 'some.dat');
+            assert.equal(rows[0].filename, uploadedFile1.filename);
+            assert.equal(rows[1].filename, 'other.dat');
+            assert.equal(rows[1].filename, uploadedFile2.filename);
+
+            assert.equal(rows[0].size, limitInMB * 1024 * 1024);
+            assert.equal(rows[0].size, uploadedFile1.size);
+            assert.equal(rows[0].size, uploadedFile2.size);
+            assert.equal(rows[0].size, rows[1].size);
+            assert.equal(rows[0].sha256, '5256ec18f11624025905d057d6befb03d77b243511ac5f77ed5e0221ce6d84b5');
+            assert.equal(rows[0].sha256, uploadedFile1.sha256);
+            assert.equal(rows[0].sha256, uploadedFile2.sha256);
+            assert.equal(rows[0].sha256, rows[1].sha256);
+            assert.equal(rows[0].extension, '.dat');
+            assert.equal(rows[1].extension, '.dat');
+        });
+    });
+
+    it('should pick up at most two file extensions', () =&gt; {
+        const db = TestServer.database();
+        const limitInMB = TestServer.testConfig().uploadFileLimitInMB;
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.other.tar.gz', limitInMB).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then(() =&gt; {
+            return db.connect().then(() =&gt; db.selectAll('uploaded_files', 'id'))
+        }).then((rows) =&gt; {
+            assert.equal(rows.length, 1);
+            assert.equal(rows[0].size, limitInMB * 1024 * 1024);
+            assert.equal(rows[0].mime, 'application/octet-stream');
+            assert.equal(rows[0].filename, 'some.other.tar.gz');
+            assert.equal(rows[0].extension, '.tar.gz');
+            assert.equal(rows[0].sha256, '5256ec18f11624025905d057d6befb03d77b243511ac5f77ed5e0221ce6d84b5');
+        });
+    });
+
+    it('should return &quot;FileSizeQuotaExceeded&quot; when the total file size exceeds the quota allowed per user', () =&gt; {
+        const db = TestServer.database();
+        const limitInMB = TestServer.testConfig().uploadFileLimitInMB;
+        return TemporaryFile.makeTemporaryFileOfSizeInMB('some.dat', limitInMB, 'a').then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then(() =&gt; {
+            return TemporaryFile.makeTemporaryFileOfSizeInMB('other.dat', limitInMB, 'b');
+        }).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true});
+        }).then(() =&gt; {
+            return TemporaryFile.makeTemporaryFileOfSizeInMB('other.dat', limitInMB, 'c');
+        }).then((stream) =&gt; {
+            return PrivilegedAPI.sendRequest('upload-file', {newFile: stream}, {useFormData: true}).then(() =&gt; {
+                assert(false, 'should never be reached');
+            }, (error) =&gt; {
+                assert.equal(error, 'FileSizeQuotaExceeded');
+            });
+        });
+    });
+});
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsresourcestemporaryfilejs"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/server-tests/resources/temporary-file.js (0 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/resources/temporary-file.js                                (rev 0)
+++ trunk/Websites/perf.webkit.org/server-tests/resources/temporary-file.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -0,0 +1,49 @@
</span><ins>+
+const assert = require('assert');
+const childProcess = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const Config = require('../../tools/js/config.js');
+
+class TemporaryFile {
+    static makeTemporaryFileOfSizeInMB(name, sizeInMB, characterToFill = 'a')
+    {
+        let megabyteString = characterToFill;
+        for (let i = 0; i &lt; 20; i++)
+            megabyteString = megabyteString + megabyteString;
+        assert.equal(megabyteString.length, 1024 * 1024);
+
+        let content = '';
+        for (let i = 0; i &lt; sizeInMB; i++)
+            content += megabyteString;
+        
+        return this.makeTemporaryFile(name, content);
+    }
+
+    static makeTemporaryFile(name, content)
+    {
+        const newPath = path.resolve(TemporaryFile._tempDir, name);
+        return new Promise((resolve) =&gt; {
+            return fs.writeFile(newPath, content, () =&gt; {
+                resolve(fs.createReadStream(newPath));
+            });
+        });
+    }
+
+    static inject()
+    {
+        beforeEach(() =&gt; {
+            this._tempDir = fs.mkdtempSync(path.resolve(Config.path('dataDirectory'), 'temp/'));
+        });
+
+        afterEach(() =&gt; {
+            childProcess.execFileSync('rm', ['-rf', this._tempDir]);
+            this._tempDir = null;
+        });
+    }
+}
+TemporaryFile._tempDir = null;
+
+if (typeof module != 'undefined')
+    module.exports.TemporaryFile = TemporaryFile;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsresourcestestserverconf"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/resources/test-server.conf (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/resources/test-server.conf        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/server-tests/resources/test-server.conf        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -52,6 +52,9 @@
</span><span class="cx"> &lt;IfModule php5_module&gt;
</span><span class="cx">     AddType application/x-httpd-php .php
</span><span class="cx">     AddType application/x-httpd-php-source .phps
</span><ins>+
+    php_value upload_max_filesize 5M
+    php_value post_max_size 5M
</ins><span class="cx"> &lt;/IfModule&gt;
</span><span class="cx"> 
</span><span class="cx"> Include /private/etc/apache2/extra/httpd-mpm.conf
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsresourcestestserverjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/server-tests/resources/test-server.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -76,6 +76,9 @@
</span><span class="cx">                 'password': Config.value('database.password'),
</span><span class="cx">                 'name': Config.value('testDatabaseName'),
</span><span class="cx">             },
</span><ins>+            'uploadFileLimitInMB': 2,
+            'uploadUserQuotaInMB': 5,
+            'uploadDirectory': Config.value('dataDirectory') + '/uploaded',
</ins><span class="cx">             'universalSlavePassword': null,
</span><span class="cx">             'maintenanceMode': false,
</span><span class="cx">             'clusterStart': [2000, 1, 1, 0, 0],
</span><span class="lines">@@ -96,6 +99,7 @@
</span><span class="cx">         } else if (fs.existsSync(backupPath)) // Assume this is a backup from the last failed run
</span><span class="cx">             this._backupDataPath = backupPath;
</span><span class="cx">         fs.mkdirSync(this._dataDirectory, 0o755);
</span><ins>+        fs.mkdirSync(path.resolve(this._dataDirectory, 'uploaded'), 0o755);
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     _restoreDataDirectory()
</span><span class="lines">@@ -108,8 +112,13 @@
</span><span class="cx">     cleanDataDirectory()
</span><span class="cx">     {
</span><span class="cx">         let fileList = fs.readdirSync(this._dataDirectory);
</span><ins>+        for (let filename of fileList) {
+            if (filename != 'uploaded')
+                fs.unlinkSync(path.resolve(this._dataDirectory, filename));
+        }
+        fileList = fs.readdirSync(path.resolve(this._dataDirectory, 'uploaded'));
</ins><span class="cx">         for (let filename of fileList)
</span><del>-            fs.unlinkSync(path.resolve(this._dataDirectory, filename));
</del><ins>+            fs.unlinkSync(path.resolve(this._dataDirectory, 'uploaded', filename));
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     _ensureTestDatabase()
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsdatabasejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/database.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/database.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/tools/js/database.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -152,6 +152,7 @@
</span><span class="cx">     'commit_sets': 'commitset',
</span><span class="cx">     'commit_set_relationships': 'commitset',
</span><span class="cx">     'run_iterations': 'iteration',
</span><ins>+    'uploaded_files': 'file',
</ins><span class="cx"> }
</span><span class="cx"> 
</span><span class="cx"> if (typeof module != 'undefined')
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsremotejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/remote.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/remote.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/tools/js/remote.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -64,7 +64,7 @@
</span><span class="cx">         });
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    sendHttpRequest(path, method, contentType, content)
</del><ins>+    sendHttpRequest(path, method, contentType, content, headers = {}, responseHandler = null)
</ins><span class="cx">     {
</span><span class="cx">         let server = this._server;
</span><span class="cx">         return new Promise((resolve, reject) =&gt; {
</span><span class="lines">@@ -78,10 +78,14 @@
</span><span class="cx"> 
</span><span class="cx">             let request = (server.scheme == 'http' ? http : https).request(options, (response) =&gt; {
</span><span class="cx">                 let responseText = '';
</span><del>-                response.setEncoding('utf8');
-                response.on('data', (chunk) =&gt; { responseText += chunk; });
</del><ins>+                if (responseHandler)
+                    responseHandler(response);
+                else {
+                    response.setEncoding('utf8');
+                    response.on('data', (chunk) =&gt; { responseText += chunk; });
+                }
</ins><span class="cx">                 response.on('end', () =&gt; {
</span><del>-                    if (response.statusCode != 200)
</del><ins>+                    if (response.statusCode &lt; 200 || response.statusCode &gt;= 300)
</ins><span class="cx">                         return reject(response.statusCode);
</span><span class="cx"> 
</span><span class="cx">                     if ('set-cookie' in response.headers) {
</span><span class="lines">@@ -90,7 +94,7 @@
</span><span class="cx">                             this._cookies.set(nameValue[0], nameValue[1]);
</span><span class="cx">                         }
</span><span class="cx">                     }
</span><del>-                    resolve({statusCode: response.statusCode, responseText: responseText});
</del><ins>+                    resolve({statusCode: response.statusCode, responseText: responseText, headers: response.headers});
</ins><span class="cx">                 });
</span><span class="cx">             });
</span><span class="cx"> 
</span><span class="lines">@@ -102,12 +106,26 @@
</span><span class="cx">             if (this._cookies.size)
</span><span class="cx">                 request.setHeader('Cookie', Array.from(this._cookies.keys()).map((key) =&gt; `${key}=${this._cookies.get(key)}`).join('; '));
</span><span class="cx"> 
</span><del>-            if (content)
-                request.write(content);
</del><ins>+            for (let headerName in headers)
+                request.setHeader(headerName, headers[headerName]);
</ins><span class="cx"> 
</span><del>-            request.end();
</del><ins>+            if (content instanceof Function)
+                content(request);
+            else {
+                if (content)
+                    request.write(content);
+                request.end();
+            }
</ins><span class="cx">         });
</span><span class="cx">     }
</span><ins>+
+    sendHttpRequestWithFormData(path, formData)
+    {
+        return this.sendHttpRequest(path, 'POST', `multipart/form-data; boundary=${formData.getBoundary()}`, (request) =&gt; {
+            formData.pipe(request);
+        });
+    }
+
</ins><span class="cx"> };
</span><span class="cx"> 
</span><span class="cx"> if (typeof module != 'undefined')
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsv3modelsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/v3-models.js (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/v3-models.js        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/tools/js/v3-models.js        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -28,6 +28,7 @@
</span><span class="cx"> importFromV3('models/test-group.js', 'TestGroup');
</span><span class="cx"> importFromV3('models/time-series.js', 'TimeSeries');
</span><span class="cx"> importFromV3('models/triggerable.js', 'Triggerable');
</span><ins>+importFromV3('models/uploaded-file.js', 'UploadedFile');
</ins><span class="cx"> 
</span><span class="cx"> importFromV3('privileged-api.js', 'PrivilegedAPI');
</span><span class="cx"> importFromV3('instrumentation.js', 'Instrumentation');
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsruntestspy"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/run-tests.py (214064 => 214065)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/run-tests.py        2017-03-16 20:50:36 UTC (rev 214064)
+++ trunk/Websites/perf.webkit.org/tools/run-tests.py        2017-03-16 20:53:35 UTC (rev 214065)
</span><span class="lines">@@ -11,7 +11,7 @@
</span><span class="cx">     node_modules_dir = os.path.join(root_dir, 'node_modules')
</span><span class="cx"> 
</span><span class="cx">     os.chdir(root_dir)
</span><del>-    packages = ['mocha', 'pg']
</del><ins>+    packages = ['mocha', 'pg', 'form-data']
</ins><span class="cx">     for package_name in packages:
</span><span class="cx">         target_dir = os.path.join(node_modules_dir, package_name)
</span><span class="cx">         if not os.path.isdir(target_dir):
</span></span></pre>
</div>
</div>

</body>
</html>