Skip to content

Commit 5d7d13a

Browse files
committed
Add MultipartEncoder to support request streaming
The Multipart encoder helps requests to upload large files without the need to read the entire file in memory
1 parent d8e08da commit 5d7d13a

File tree

6 files changed

+88
-13
lines changed

6 files changed

+88
-13
lines changed

ctfcli/cli/media.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ def add(self, path):
1515

1616
api = API()
1717

18-
new_file = ("file", open(path, mode="rb"))
1918
filename = os.path.basename(path)
19+
new_file = (filename, open(path, mode="rb"))
2020
location = f"media/{filename}"
2121
file_payload = {
2222
"type": "page",
2323
"location": location,
2424
}
2525

2626
# Specifically use data= here to send multipart/form-data
27-
r = api.post("/api/v1/files", files=[new_file], data=file_payload)
27+
r = api.post("/api/v1/files", files={"file": new_file}, data=file_payload)
2828
r.raise_for_status()
2929
resp = r.json()
3030
server_location = resp["data"][0]["location"]

ctfcli/core/api.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from typing import Mapping, Union, Optional, List, Any, Tuple
12
from urllib.parse import urljoin
23

34
from requests import Session
5+
from requests_toolbelt.multipart.encoder import MultipartEncoder
46

57
from ctfcli.core.config import Config
68

@@ -38,14 +40,50 @@ def __init__(self):
3840
if "cookies" in config:
3941
self.cookies.update(dict(config["cookies"]))
4042

41-
def request(self, method, url, *args, **kwargs):
43+
def request(self, method, url, data=None, files=None, *args, **kwargs):
4244
# Strip out the preceding / so that urljoin creates the right url
4345
# considering the appended / on the prefix_url
4446
url = urljoin(self.prefix_url, url.lstrip("/"))
4547

46-
# if data= is present, do not modify the content-type
47-
if kwargs.get("data", None) is not None:
48-
return super(API, self).request(method, url, *args, **kwargs)
48+
# If data or files are any kind of key/value iterable
49+
# then encode the body as form-data
50+
if isinstance(data, (list, tuple, Mapping)) or isinstance(files, (list, tuple, Mapping)):
51+
# In order to use the MultipartEncoder, we need to convert data and files to the following structure :
52+
# A list of tuple containing the key and the values : List[Tuple[str, str]]
53+
# For files, the structure can be List[Tuple[str, Tuple[str, str, Optional[str]]]]
54+
# Example: [ ('file', ('doc.pdf', open('doc.pdf'), 'text/plain') ) ]
55+
56+
fields = list() # type: List[Tuple[str, Any]]
57+
if isinstance(data, dict):
58+
# int are not allowed as value in MultipartEncoder
59+
fields = list(map(lambda v: (v[0], str(v[1]) if isinstance(v[1], int) else v[1]), data.items()))
60+
61+
if files is not None:
62+
if isinstance(files, dict):
63+
files = list(files.items())
64+
fields.extend(files) # type: ignore
65+
66+
multipart = MultipartEncoder(fields)
67+
68+
return super(API, self).request(
69+
method,
70+
url,
71+
data=multipart,
72+
headers={"Content-Type": multipart.content_type},
73+
*args,
74+
**kwargs,
75+
)
76+
77+
# If data or files are not key/value pairs, then send the raw values
78+
if data is not None or files is not None:
79+
return super(API, self).request(
80+
method,
81+
url,
82+
data=data,
83+
files=files,
84+
*args,
85+
**kwargs,
86+
)
4987

5088
# otherwise set the content-type to application/json for all API requests
5189
# modify the headers here instead of using self.headers because we don't want to

ctfcli/core/challenge.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,11 +351,11 @@ def _delete_file(self, remote_location: str):
351351
r.raise_for_status()
352352

353353
def _create_file(self, local_path: Path):
354-
new_file = ("file", open(local_path, mode="rb"))
354+
new_file = (local_path.name, open(local_path, mode="rb"))
355355
file_payload = {"challenge_id": self.challenge_id, "type": "challenge"}
356356

357357
# Specifically use data= here to send multipart/form-data
358-
r = self.api.post("/api/v1/files", files=[new_file], data=file_payload)
358+
r = self.api.post("/api/v1/files", files={"file": new_file}, data=file_payload)
359359
r.raise_for_status()
360360

361361
# Close the file handle
@@ -364,7 +364,8 @@ def _create_file(self, local_path: Path):
364364
def _create_all_files(self):
365365
new_files = []
366366
for challenge_file in self["files"]:
367-
new_files.append(("file", open(self.challenge_directory / challenge_file, mode="rb")))
367+
file_path = self.challenge_directory / challenge_file
368+
new_files.append(("file", (file_path.name, file_path.open("rb"))))
368369

369370
files_payload = {"challenge_id": self.challenge_id, "type": "challenge"}
370371

@@ -374,7 +375,7 @@ def _create_all_files(self):
374375

375376
# Close the file handles
376377
for file_payload in new_files:
377-
file_payload[1].close()
378+
file_payload[1][1].close()
378379

379380
def _delete_existing_hints(self):
380381
remote_hints = self.api.get("/api/v1/hints").json()["data"]

poetry.lock

Lines changed: 37 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ appdirs = "^1.4.4"
2424
colorama = "^0.4.6"
2525
fire = "^0.5.0"
2626
typing-extensions = "^4.7.1"
27+
requests-toolbelt = "^1.0.0"
2728

2829
[tool.poetry.group.dev.dependencies]
2930
black = "^23.7.0"

tests/core/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,4 @@ def test_api_object_assigns_cookies(self, *args, **kwargs):
170170
def test_request_does_not_override_form_data_content_type(self, mock_request: MagicMock, *args, **kwargs):
171171
api = API()
172172
api.request("GET", "/test", data="some-file")
173-
mock_request.assert_called_once_with("GET", "https://example.com/test", data="some-file")
173+
mock_request.assert_called_once_with("GET", "https://example.com/test", data="some-file", files=None)

0 commit comments

Comments
 (0)