Repository: freedomofpress/securedrop

Issue:
Support range requests (RFC 7233) on journalist interface
## Description

The journalist interface should support range requests so user agents can continue downloads that failed part way through. We have the most control over this on the journalist workstation, so we would see the most benefit there.

### Current behavior

```http
HTTP/1.0 200 OK
Cache-Control: public, max-age=43200
Content-Disposition: attachment; filename=1-barren_unfamiliarity-msg.gpg
Content-Length: 604
Content-Type: application/pgp-encrypted
Date: Fri, 26 Oct 2018 08:44:49 GMT
Etag: "sha256:25aef0be2cb6900a88a2590b2ee3637117eb51a733a105ae692e212623450f7f"
Expires: Fri, 26 Oct 2018 20:44:49 GMT
Last-Modified: Fri, 26 Oct 2018 08:42:46 GMT
Server: Werkzeug/0.14.1 Python/2.7.6
Set-Cookie: js=eyJleHBpcmVzIjp7IiBkIjoiRnJpLCAyNiBPY3QgMjAxOCAxMDo0NDo0OSBHTVQifX0.DrRlgQ.17vemUmgcvLR2QvisGT0TOiZfyU; HttpOnly; Path=/
Vary: Cookie
```

### Proposed Behavior

```http
HTTP/1.0 200 OK
Cache-Control: public, max-age=43200
Content-Disposition: attachment; filename=1-barren_unfamiliarity-msg.gpg
Content-Length: 604
Content-Type: application/pgp-encrypted
Date: Fri, 26 Oct 2018 08:44:49 GMT
Accept-Ranges: bytes
Etag: "sha256:25aef0be2cb6900a88a2590b2ee3637117eb51a733a105ae692e212623450f7f"
Last-Modified: Fri, 26 Oct 2018 08:42:46 GMT
Server: Werkzeug/0.14.1 Python/2.7.6
Vary: Authorization
```

## User Stories

As a journalist downloading large documents on a flaky Tor connection, I want downloads to pickup where they left off.


Discussion:
note that we would also need to make some non-trivial changes to the [securedrop-proxy](https://github.com/freedomofpress/securedrop-proxy) to use this - right now for file downloads we wait for the entire encrypted file to download and then `qvm-move` it into the SVS 

Resolving patch:
diff --git a/securedrop/journalist_app/utils.py b/securedrop/journalist_app/utils.py
--- a/securedrop/journalist_app/utils.py
+++ b/securedrop/journalist_app/utils.py
@@ -371,7 +371,6 @@ def col_delete_data(cols_selected: List[str]) -> werkzeug.Response:
             "error",
         )
     else:
-
         for filesystem_id in cols_selected:
             delete_source_files(filesystem_id)
 
@@ -535,4 +534,5 @@ def serve_file_with_etag(db_obj: Union[Reply, Submission]) -> flask.Response:
 
     response.direct_passthrough = False
     response.headers["Etag"] = db_obj.checksum
+    response.headers["Accept-Ranges"] = "bytes"
     return response


Test patch:
diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py
--- a/securedrop/tests/test_journalist_api.py
+++ b/securedrop/tests/test_journalist_api.py
@@ -1,3 +1,5 @@
+import binascii
+import hashlib
 import json
 import random
 from datetime import datetime
@@ -232,7 +234,6 @@ def test_user_without_token_cannot_del_protected_endpoints(journalist_app, test_
 def test_attacker_cannot_use_token_after_admin_deletes(
     journalist_app, test_source, journalist_api_token
 ):
-
     with journalist_app.test_client() as app:
         uuid = test_source["source"].uuid
 
@@ -893,7 +894,6 @@ def test_reply_with_valid_square_json_400(
 
 
 def test_malformed_json_400(journalist_app, journalist_api_token, test_journo, test_source):
-
     with journalist_app.app_context():
         uuid = test_source["source"].uuid
         protected_routes = [
@@ -904,7 +904,6 @@ def test_malformed_json_400(journalist_app, journalist_api_token, test_journo, t
         ]
     with journalist_app.test_client() as app:
         for protected_route in protected_routes:
-
             response = app.post(
                 protected_route,
                 data="{this is invalid {json!",
@@ -916,7 +915,6 @@ def test_malformed_json_400(journalist_app, journalist_api_token, test_journo, t
 
 
 def test_empty_json_400(journalist_app, journalist_api_token, test_journo, test_source):
-
     with journalist_app.app_context():
         uuid = test_source["source"].uuid
         protected_routes = [
@@ -925,7 +923,6 @@ def test_empty_json_400(journalist_app, journalist_api_token, test_journo, test_
         ]
     with journalist_app.test_client() as app:
         for protected_route in protected_routes:
-
             response = app.post(
                 protected_route, data="", headers=get_api_headers(journalist_api_token)
             )
@@ -935,7 +932,6 @@ def test_empty_json_400(journalist_app, journalist_api_token, test_journo, test_
 
 
 def test_empty_json_20X(journalist_app, journalist_api_token, test_journo, test_source):
-
     with journalist_app.app_context():
         uuid = test_source["source"].uuid
         protected_routes = [
@@ -944,7 +940,6 @@ def test_empty_json_20X(journalist_app, journalist_api_token, test_journo, test_
         ]
     with journalist_app.test_client() as app:
         for protected_route in protected_routes:
-
             response = app.post(
                 protected_route, data="", headers=get_api_headers(journalist_api_token)
             )
@@ -1246,3 +1241,70 @@ def test_seen_bad_requests(journalist_app, journalist_api_token):
         response = app.post(seen_url, data=json.dumps(data), headers=headers)
         assert response.status_code == 404
         assert response.json["message"] == "reply not found: not-a-reply"
+
+
+def test_download_submission_range(journalist_app, test_files, journalist_api_token):
+    with journalist_app.test_client() as app:
+        submission_uuid = test_files["submissions"][0].uuid
+        uuid = test_files["source"].uuid
+
+        # Download the full file
+        full_response = app.get(
+            url_for(
+                "api.download_submission",
+                source_uuid=uuid,
+                submission_uuid=submission_uuid,
+            ),
+            headers=get_api_headers(journalist_api_token),
+        )
+        assert full_response.status_code == 200
+        assert full_response.mimetype == "application/pgp-encrypted"
+
+        # Verify the etag header is actually the sha256 hash
+        hasher = hashlib.sha256()
+        hasher.update(full_response.data)
+        digest = binascii.hexlify(hasher.digest()).decode("utf-8")
+        digest_str = "sha256:" + digest
+        assert full_response.headers.get("ETag") == digest_str
+
+        # Verify the Content-Length header matches the length of the content
+        assert full_response.headers.get("Content-Length") == str(len(full_response.data))
+
+        # Verify that range requests are accepted
+        assert full_response.headers.get("Accept-Ranges") == "bytes"
+
+        # Download the first 10 bytes of data
+        headers = get_api_headers(journalist_api_token)
+        headers["Range"] = "bytes=0-9"
+        partial_response = app.get(
+            url_for(
+                "api.download_submission",
+                source_uuid=uuid,
+                submission_uuid=submission_uuid,
+            ),
+            headers=headers,
+        )
+        assert partial_response.status_code == 206  # HTTP/1.1 206 Partial Content
+        assert partial_response.mimetype == "application/pgp-encrypted"
+        assert partial_response.headers.get("Content-Length") == "10"
+        assert len(partial_response.data) == 10
+        assert full_response.data[0:10] == partial_response.data
+
+        # Download the second half of the data
+        range_start = int(len(full_response.data) / 2)
+        range_end = len(full_response.data)
+        headers = get_api_headers(journalist_api_token)
+        headers["Range"] = f"bytes={range_start}-{range_end}"
+        partial_response = app.get(
+            url_for(
+                "api.download_submission",
+                source_uuid=uuid,
+                submission_uuid=submission_uuid,
+            ),
+            headers=headers,
+        )
+        assert partial_response.status_code == 206  # HTTP/1.1 206 Partial Content
+        assert partial_response.mimetype == "application/pgp-encrypted"
+        assert partial_response.headers.get("Content-Length") == str(range_end - range_start)
+        assert len(partial_response.data) == range_end - range_start
+        assert full_response.data[range_start:] == partial_response.data