Skip to content

Commit d5403dc

Browse files
jkglasbrennerJustKuzya
authored andcommitted
feat: add file upload support and utility functions to client library
This update adds file uploading support to the Dioptra client library through a new DioptraFile class, supporting utility functions, and extending the client's post method to accept `data` and `files` arguments. The changes enable users to upload single files, multiple files, or all files within a directory while enforcing strict path validation in the associated filenames to prevent server-side directory traversal attacks. The new functionality includes: - A DioptraFile class that validates filenames to ensure they are normalized, POSIX-compliant relative paths without traversal components (e.g., ".."). - File selection utility functions: - select_files_in_directory: Recursively selects files from a directory with optional regex pattern filtering - select_one_or_more_files: Selects individual files with optional renaming support to handle filename collisions - Extended POST request in the client to handle multipart form data that contains regular form fields and file uploads. All file uploads, whether they're single file or multiple files, are streamed to the server. The DioptraSession implementation for the requests-based client and the Flask test client have both been updated to support the new functionality.
1 parent 3e6eaaf commit d5403dc

6 files changed

Lines changed: 682 additions & 6 deletions

File tree

src/dioptra/client/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@
1414
#
1515
# ACCESS THE FULL CC BY 4.0 LICENSE HERE:
1616
# https://creativecommons.org/licenses/by/4.0/legalcode
17+
from .base import DioptraFile
1718
from .client import (
1819
DioptraClient,
1920
connect_json_dioptra_client,
2021
connect_response_dioptra_client,
2122
)
23+
from .utils import select_files_in_directory, select_one_or_more_files
2224

2325
__all__ = [
2426
"connect_response_dioptra_client",
2527
"connect_json_dioptra_client",
28+
"select_files_in_directory",
29+
"select_one_or_more_files",
2630
"DioptraClient",
31+
"DioptraFile",
2732
]

src/dioptra/client/base.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,19 @@
1414
#
1515
# ACCESS THE FULL CC BY 4.0 LICENSE HERE:
1616
# https://creativecommons.org/licenses/by/4.0/legalcode
17+
import posixpath
18+
import re
1719
from abc import ABC, abstractmethod
18-
from pathlib import Path
20+
from dataclasses import dataclass
21+
from io import BufferedReader
22+
from pathlib import Path, PurePosixPath, PureWindowsPath
1923
from posixpath import join as urljoin
2024
from typing import Any, ClassVar, Generic, Protocol, TypeVar
2125

2226
T = TypeVar("T")
2327

28+
DOTS_REGEX = re.compile(r"^\.\.\.+$")
29+
2430

2531
class DioptraClientError(Exception):
2632
"""Base class for client errors"""
@@ -91,6 +97,52 @@ def json(self) -> dict[str, Any]:
9197
... # fmt: skip
9298

9399

100+
@dataclass
101+
class DioptraFile(object):
102+
"""A file to be uploaded to the Dioptra API.
103+
104+
Attributes:
105+
filename: The name of the file.
106+
stream: The file stream.
107+
content_type: The content type of the file.
108+
"""
109+
110+
filename: str
111+
stream: BufferedReader
112+
content_type: str | None
113+
114+
def __post_init__(self) -> None:
115+
if PureWindowsPath(self.filename).as_posix() != str(PurePosixPath(self.filename)): # noqa: B950; fmt: skip
116+
raise ValueError(
117+
"Invalid filename (reason: filename is a Windows path): "
118+
f"{self.filename}"
119+
)
120+
121+
if posixpath.normpath(self.filename) != self.filename:
122+
raise ValueError(
123+
"Invalid filename (reason: filename is not normalized): "
124+
f"{self.filename}"
125+
)
126+
127+
if not PurePosixPath(self.filename).is_relative_to("."):
128+
raise ValueError(
129+
"Invalid filename (reason: filename is not relative to ./): "
130+
f"{self.filename}"
131+
)
132+
133+
if PurePosixPath("..") in PurePosixPath(posixpath.normpath(self.filename)).parents: # noqa: B950; fmt: skip
134+
raise ValueError(
135+
"Invalid filename (reason: filename is not a sub-directory of ./): "
136+
f"{self.filename}"
137+
)
138+
139+
if any([DOTS_REGEX.match(str(x)) for x in PurePosixPath(posixpath.normpath(self.filename)).parts]): # noqa: B950; fmt: skip
140+
raise ValueError(
141+
"Invalid filename (reason: filename contains a sub-directory name that "
142+
f"is all dots): {self.filename}"
143+
)
144+
145+
94146
class DioptraSession(ABC, Generic[T]):
95147
"""The interface for communicating with the Dioptra API."""
96148

@@ -117,6 +169,8 @@ def make_request(
117169
url: str,
118170
params: dict[str, Any] | None = None,
119171
json_: dict[str, Any] | None = None,
172+
data: dict[str, Any] | None = None,
173+
files: dict[str, DioptraFile | list[DioptraFile]] | None = None,
120174
) -> DioptraResponseProtocol:
121175
"""Make a request to the API.
122176
@@ -129,6 +183,10 @@ def make_request(
129183
params: The query parameters to include in the request. Optional, defaults
130184
to None.
131185
json_: The JSON data to include in the request. Optional, defaults to None.
186+
data: A dictionary to send in the body of the request as part of a
187+
multipart form. Optional, defaults to None.
188+
files: Dictionary of "name": DioptraFile or lists of DioptraFile pairs to be
189+
uploaded. Optional, defaults to None.
132190
133191
Returns:
134192
The response from the API.
@@ -179,6 +237,8 @@ def post(
179237
*parts,
180238
params: dict[str, Any] | None = None,
181239
json_: dict[str, Any] | None = None,
240+
data: dict[str, Any] | None = None,
241+
files: dict[str, DioptraFile | list[DioptraFile]] | None = None,
182242
) -> T:
183243
"""Make a POST request to the API.
184244
@@ -188,6 +248,10 @@ def post(
188248
params: The query parameters to include in the request. Optional, defaults
189249
to None.
190250
json_: The JSON data to include in the request. Optional, defaults to None.
251+
data: A dictionary to send in the body of the request as part of a
252+
multipart form. Optional, defaults to None.
253+
files: Dictionary of "name": DioptraFile or lists of DioptraFile pairs to be
254+
uploaded. Optional, defaults to None.
191255
192256
Returns:
193257
The response from the API.
@@ -311,6 +375,8 @@ def _post(
311375
*parts,
312376
params: dict[str, Any] | None = None,
313377
json_: dict[str, Any] | None = None,
378+
data: dict[str, Any] | None = None,
379+
files: dict[str, DioptraFile | list[DioptraFile]] | None = None,
314380
) -> DioptraResponseProtocol:
315381
"""Make a POST request to the API.
316382
@@ -323,12 +389,21 @@ def _post(
323389
params: The query parameters to include in the request. Optional, defaults
324390
to None.
325391
json_: The JSON data to include in the request. Optional, defaults to None.
392+
data: A dictionary to send in the body of the request as part of a
393+
multipart form. Optional, defaults to None.
394+
files: Dictionary of "name": DioptraFile or lists of DioptraFile pairs to be
395+
uploaded. Optional, defaults to None.
326396
327397
Returns:
328398
A response object that implements the DioptraResponseProtocol interface.
329399
"""
330400
return self.make_request(
331-
"post", self.build_url(endpoint, *parts), params=params, json_=json_
401+
"post",
402+
self.build_url(endpoint, *parts),
403+
params=params,
404+
json_=json_,
405+
data=data,
406+
files=files,
332407
)
333408

334409
def _delete(

0 commit comments

Comments
 (0)