filebridge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filebridge-0.1.0/PKG-INFO +48 -0
- filebridge-0.1.0/README.md +35 -0
- filebridge-0.1.0/pyproject.toml +42 -0
- filebridge-0.1.0/src/filebridge/__init__.py +35 -0
- filebridge-0.1.0/src/filebridge/async_client.py +290 -0
- filebridge-0.1.0/src/filebridge/core.py +166 -0
- filebridge-0.1.0/src/filebridge/exceptions.py +31 -0
- filebridge-0.1.0/src/filebridge/io.py +213 -0
- filebridge-0.1.0/src/filebridge/models.py +16 -0
- filebridge-0.1.0/src/filebridge/py.typed +0 -0
- filebridge-0.1.0/src/filebridge/stream.py +147 -0
- filebridge-0.1.0/src/filebridge/sync_client.py +290 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: filebridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library for interacting with the Filebridge daemon (filebridged).
|
|
5
|
+
Author: Stefan Schönberger
|
|
6
|
+
Author-email: Stefan Schönberger <stefan@sniner.dev>
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5
|
|
9
|
+
Requires-Dist: cryptography>=46.0.0
|
|
10
|
+
Requires-Dist: zstandard>=0.25.0
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Filebridge Python Client
|
|
15
|
+
|
|
16
|
+
A Python client library for interacting with the [Filebridge daemon](https://github.com/sniner/filebridge) (`filebridged`). Built on `httpx` and `pydantic`, it provides synchronous and asynchronous access to remote files via the filebridge REST API, including chunked streaming and transparent ChaCha20Poly1305 encryption when a token is configured.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python >= 3.13
|
|
21
|
+
- `httpx` >= 0.28.1
|
|
22
|
+
- `pydantic` >= 2.12.5
|
|
23
|
+
- `cryptography` >= 44.0.0
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **Asynchronous & Synchronous Client**: Full native support for async and sync Python environments.
|
|
28
|
+
- **Streaming Files**: Memory-efficient chunked streaming via `stream_read` and `write_stream`.
|
|
29
|
+
- **Automatic Encryption**: Secure ChaCha20Poly1305 AEAD streaming encryption natively handled whenever a token is used.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from filebridge import FileBridgeClient
|
|
35
|
+
|
|
36
|
+
client = FileBridgeClient("http://localhost:8000")
|
|
37
|
+
loc = client.location("demo", token="my-secret-token")
|
|
38
|
+
|
|
39
|
+
# Read metadata for a file
|
|
40
|
+
info = loc.info("/path/to/file.txt")
|
|
41
|
+
print(f"File size: {info.size} bytes")
|
|
42
|
+
|
|
43
|
+
# Read a file
|
|
44
|
+
data = loc.read("/path/to/file.txt")
|
|
45
|
+
|
|
46
|
+
# Write a file
|
|
47
|
+
loc.write("/path/to/file.txt", b"Hello, World!")
|
|
48
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Filebridge Python Client
|
|
2
|
+
|
|
3
|
+
A Python client library for interacting with the [Filebridge daemon](https://github.com/sniner/filebridge) (`filebridged`). Built on `httpx` and `pydantic`, it provides synchronous and asynchronous access to remote files via the filebridge REST API, including chunked streaming and transparent ChaCha20Poly1305 encryption when a token is configured.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python >= 3.13
|
|
8
|
+
- `httpx` >= 0.28.1
|
|
9
|
+
- `pydantic` >= 2.12.5
|
|
10
|
+
- `cryptography` >= 44.0.0
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Asynchronous & Synchronous Client**: Full native support for async and sync Python environments.
|
|
15
|
+
- **Streaming Files**: Memory-efficient chunked streaming via `stream_read` and `write_stream`.
|
|
16
|
+
- **Automatic Encryption**: Secure ChaCha20Poly1305 AEAD streaming encryption natively handled whenever a token is used.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from filebridge import FileBridgeClient
|
|
22
|
+
|
|
23
|
+
client = FileBridgeClient("http://localhost:8000")
|
|
24
|
+
loc = client.location("demo", token="my-secret-token")
|
|
25
|
+
|
|
26
|
+
# Read metadata for a file
|
|
27
|
+
info = loc.info("/path/to/file.txt")
|
|
28
|
+
print(f"File size: {info.size} bytes")
|
|
29
|
+
|
|
30
|
+
# Read a file
|
|
31
|
+
data = loc.read("/path/to/file.txt")
|
|
32
|
+
|
|
33
|
+
# Write a file
|
|
34
|
+
loc.write("/path/to/file.txt", b"Hello, World!")
|
|
35
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "filebridge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python library for interacting with the Filebridge daemon (filebridged)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Stefan Schönberger", email = "stefan@sniner.dev" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
"pydantic>=2.12.5",
|
|
13
|
+
"cryptography>=46.0.0",
|
|
14
|
+
"zstandard>=0.25.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest (>=9.0.2,<10.0.0)",
|
|
20
|
+
"basedpyright (>=1.38.2,<2.0.0)",
|
|
21
|
+
"ruff>=0.15.4",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.pyright]
|
|
25
|
+
typeCheckingMode = "standard"
|
|
26
|
+
useLibraryCodeForTypes = true
|
|
27
|
+
venvPath = "."
|
|
28
|
+
venv = ".venv"
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 96
|
|
32
|
+
ignore = ["E402"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
per-file-ignores = { "__init__.py" = ["F401"] }
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
pythonpath = ["tests"]
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
42
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .async_client import (
|
|
2
|
+
AsyncFileBridgeClient,
|
|
3
|
+
AsyncLocation,
|
|
4
|
+
)
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
FileBridgeError,
|
|
8
|
+
FileBridgePermissionError,
|
|
9
|
+
IsDirectoryError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from .io import (
|
|
13
|
+
AsyncFileBridgeReadStream,
|
|
14
|
+
FileBridgeReadStream,
|
|
15
|
+
)
|
|
16
|
+
from .models import (
|
|
17
|
+
Metadata,
|
|
18
|
+
)
|
|
19
|
+
from .sync_client import (
|
|
20
|
+
FileBridgeClient,
|
|
21
|
+
Location,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FileBridgeClient",
|
|
26
|
+
"AsyncFileBridgeClient",
|
|
27
|
+
"Location",
|
|
28
|
+
"AsyncLocation",
|
|
29
|
+
"FileBridgeError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"NotFoundError",
|
|
32
|
+
"IsDirectoryError",
|
|
33
|
+
"FileBridgePermissionError",
|
|
34
|
+
"Metadata",
|
|
35
|
+
]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import AsyncContextManager, List, Optional, overload
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .core import (
|
|
9
|
+
build_encrypted_write_body,
|
|
10
|
+
decode_read_response,
|
|
11
|
+
get_api_path,
|
|
12
|
+
handle_response_errors,
|
|
13
|
+
parse_json_response,
|
|
14
|
+
prepare_request_kwargs,
|
|
15
|
+
)
|
|
16
|
+
from .exceptions import AuthenticationError, FileBridgeError, IsDirectoryError, NotFoundError
|
|
17
|
+
from .io import AsyncFileBridgeReadStream
|
|
18
|
+
from .models import ListResponse, Metadata
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncLocation:
|
|
22
|
+
def __init__(self, client: AsyncFileBridgeClient, dir_id: str, token: Optional[str] = None):
|
|
23
|
+
self.dir_id = dir_id
|
|
24
|
+
self.token = token
|
|
25
|
+
self._client = client
|
|
26
|
+
|
|
27
|
+
async def _send_request(
|
|
28
|
+
self, method: str, path: str, req_nonce: Optional[str] = None, **kwargs
|
|
29
|
+
) -> httpx.Response:
|
|
30
|
+
url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
|
|
31
|
+
kwargs, generated_nonce = prepare_request_kwargs(
|
|
32
|
+
method=method,
|
|
33
|
+
url=url,
|
|
34
|
+
token=self.token,
|
|
35
|
+
kwargs=kwargs,
|
|
36
|
+
)
|
|
37
|
+
# Use the passed req_nonce if provided (e.g., from write/write_stream where kwargs is prepared early)
|
|
38
|
+
expected_nonce = req_nonce if req_nonce is not None else generated_nonce
|
|
39
|
+
|
|
40
|
+
resp = await self._client.client.request(method, url, **kwargs)
|
|
41
|
+
|
|
42
|
+
if not resp.is_success:
|
|
43
|
+
handle_response_errors(resp.status_code, resp.text)
|
|
44
|
+
raise FileBridgeError(f"HTTP Error {resp.status_code}: {resp.text}")
|
|
45
|
+
|
|
46
|
+
if self.token:
|
|
47
|
+
resp_nonce = resp.headers.get("X-Nonce")
|
|
48
|
+
if resp_nonce != expected_nonce:
|
|
49
|
+
raise AuthenticationError("Nonce mismatch")
|
|
50
|
+
return resp
|
|
51
|
+
|
|
52
|
+
async def read(
|
|
53
|
+
self, path: str, offset: Optional[int] = None, length: Optional[int] = None
|
|
54
|
+
) -> bytes:
|
|
55
|
+
api_path = get_api_path(self.dir_id, path)
|
|
56
|
+
params = {}
|
|
57
|
+
if offset is not None:
|
|
58
|
+
params["offset"] = offset
|
|
59
|
+
if length is not None:
|
|
60
|
+
params["length"] = length
|
|
61
|
+
|
|
62
|
+
headers = {"Accept": "application/octet-stream"}
|
|
63
|
+
if self.token:
|
|
64
|
+
headers["Accept"] = "application/vnd.filebridge.stream"
|
|
65
|
+
|
|
66
|
+
resp = await self._send_request("GET", api_path, params=params, headers=headers)
|
|
67
|
+
|
|
68
|
+
return decode_read_response(
|
|
69
|
+
self.token,
|
|
70
|
+
resp.headers.get("Content-Type", ""),
|
|
71
|
+
resp.content,
|
|
72
|
+
resp.request.headers.get("X-Signature"),
|
|
73
|
+
path,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def write(self, path: str, data: bytes, offset: Optional[int] = None):
|
|
77
|
+
api_path = get_api_path(self.dir_id, path)
|
|
78
|
+
params = {}
|
|
79
|
+
if offset is not None:
|
|
80
|
+
params["offset"] = offset
|
|
81
|
+
|
|
82
|
+
headers = {"Content-Type": "application/octet-stream"}
|
|
83
|
+
if self.token:
|
|
84
|
+
headers["Content-Type"] = "application/vnd.filebridge.stream"
|
|
85
|
+
|
|
86
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
87
|
+
kwargs, _ = prepare_request_kwargs(
|
|
88
|
+
"PUT", url, self.token, {"params": params, "headers": headers}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if self.token:
|
|
92
|
+
sig = kwargs.get("headers", {}).get("X-Signature", "")
|
|
93
|
+
kwargs["content"] = build_encrypted_write_body(self.token, sig, data)
|
|
94
|
+
await self._send_request(
|
|
95
|
+
"PUT", path, req_nonce=kwargs.get("headers", {}).get("X-Nonce"), **kwargs
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
kwargs["content"] = data
|
|
99
|
+
await self._send_request("PUT", path, **kwargs)
|
|
100
|
+
|
|
101
|
+
@overload
|
|
102
|
+
def stream_read(
|
|
103
|
+
self,
|
|
104
|
+
path: str,
|
|
105
|
+
offset: Optional[int] = ...,
|
|
106
|
+
length: Optional[int] = ...,
|
|
107
|
+
encoding: None = None,
|
|
108
|
+
) -> AsyncContextManager[AsyncFileBridgeReadStream]: ...
|
|
109
|
+
|
|
110
|
+
@overload
|
|
111
|
+
def stream_read(
|
|
112
|
+
self,
|
|
113
|
+
path: str,
|
|
114
|
+
offset: Optional[int] = ...,
|
|
115
|
+
length: Optional[int] = ...,
|
|
116
|
+
*,
|
|
117
|
+
encoding: str,
|
|
118
|
+
) -> AsyncContextManager[AsyncFileBridgeReadStream]: ...
|
|
119
|
+
|
|
120
|
+
@asynccontextmanager
|
|
121
|
+
async def stream_read(
|
|
122
|
+
self,
|
|
123
|
+
path: str,
|
|
124
|
+
offset: Optional[int] = None,
|
|
125
|
+
length: Optional[int] = None,
|
|
126
|
+
encoding: Optional[str] = None,
|
|
127
|
+
):
|
|
128
|
+
api_path = get_api_path(self.dir_id, path)
|
|
129
|
+
params = {}
|
|
130
|
+
if offset is not None:
|
|
131
|
+
params["offset"] = offset
|
|
132
|
+
if length is not None:
|
|
133
|
+
params["length"] = length
|
|
134
|
+
|
|
135
|
+
headers = {"Accept": "application/octet-stream"}
|
|
136
|
+
if self.token:
|
|
137
|
+
headers["Accept"] = "application/vnd.filebridge.stream"
|
|
138
|
+
|
|
139
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
140
|
+
kwargs, req_nonce = prepare_request_kwargs(
|
|
141
|
+
method="GET",
|
|
142
|
+
url=url,
|
|
143
|
+
token=self.token,
|
|
144
|
+
kwargs={"params": params, "headers": headers},
|
|
145
|
+
)
|
|
146
|
+
headers = kwargs.setdefault("headers", {})
|
|
147
|
+
|
|
148
|
+
async with self._client.client.stream("GET", url, **kwargs) as resp:
|
|
149
|
+
if not resp.is_success:
|
|
150
|
+
await resp.aread()
|
|
151
|
+
handle_response_errors(resp.status_code, resp.text)
|
|
152
|
+
raise FileBridgeError(f"HTTP Error {resp.status_code}: {resp.text}")
|
|
153
|
+
|
|
154
|
+
if self.token:
|
|
155
|
+
resp_nonce = resp.headers.get("X-Nonce")
|
|
156
|
+
if resp_nonce != req_nonce:
|
|
157
|
+
raise AuthenticationError("Nonce mismatch")
|
|
158
|
+
|
|
159
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
160
|
+
if "application/json" in content_type:
|
|
161
|
+
body = await resp.aread()
|
|
162
|
+
sig = resp.request.headers.get("X-Signature", "") if self.token else None
|
|
163
|
+
data = parse_json_response(self.token, sig, body)
|
|
164
|
+
if "items" in data:
|
|
165
|
+
raise IsDirectoryError(f"{path} is a directory")
|
|
166
|
+
|
|
167
|
+
raw_stream = AsyncFileBridgeReadStream(resp, self.token)
|
|
168
|
+
if encoding:
|
|
169
|
+
raise NotImplementedError(
|
|
170
|
+
"TextIOWrapper is not supported for async streams directly."
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
async with raw_stream as stream:
|
|
174
|
+
yield stream
|
|
175
|
+
|
|
176
|
+
async def write_stream(self, path: str, stream, offset: Optional[int] = None):
|
|
177
|
+
api_path = get_api_path(self.dir_id, path)
|
|
178
|
+
params = {}
|
|
179
|
+
if offset is not None:
|
|
180
|
+
params["offset"] = offset
|
|
181
|
+
|
|
182
|
+
async def chunk_generator():
|
|
183
|
+
import inspect
|
|
184
|
+
|
|
185
|
+
if hasattr(stream, "read"):
|
|
186
|
+
# If the method returns a coroutine, we await it
|
|
187
|
+
while True:
|
|
188
|
+
result = stream.read(64 * 1024)
|
|
189
|
+
if inspect.isawaitable(result):
|
|
190
|
+
chunk = await result
|
|
191
|
+
else:
|
|
192
|
+
chunk = result
|
|
193
|
+
if not chunk:
|
|
194
|
+
break
|
|
195
|
+
if isinstance(chunk, str):
|
|
196
|
+
chunk = chunk.encode("utf-8")
|
|
197
|
+
yield chunk
|
|
198
|
+
elif hasattr(stream, "__aiter__"):
|
|
199
|
+
async for chunk in stream:
|
|
200
|
+
if isinstance(chunk, str):
|
|
201
|
+
chunk = chunk.encode("utf-8")
|
|
202
|
+
yield chunk
|
|
203
|
+
else:
|
|
204
|
+
for chunk in stream:
|
|
205
|
+
if isinstance(chunk, str):
|
|
206
|
+
chunk = chunk.encode("utf-8")
|
|
207
|
+
yield chunk
|
|
208
|
+
|
|
209
|
+
headers = {"Content-Type": "application/octet-stream"}
|
|
210
|
+
if self.token:
|
|
211
|
+
headers["Content-Type"] = "application/vnd.filebridge.stream"
|
|
212
|
+
|
|
213
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
214
|
+
kwargs, _ = prepare_request_kwargs(
|
|
215
|
+
"PUT", url, self.token, {"params": params, "headers": headers}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if self.token:
|
|
219
|
+
token = self.token
|
|
220
|
+
|
|
221
|
+
from .stream import (
|
|
222
|
+
StreamAead,
|
|
223
|
+
encode_data,
|
|
224
|
+
encode_stop,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def signed_chunk_generator():
|
|
228
|
+
sig = kwargs.get("headers", {}).get("X-Signature", "")
|
|
229
|
+
aead = StreamAead(token, sig)
|
|
230
|
+
async for chunk in chunk_generator():
|
|
231
|
+
encrypted_chunk = aead.encrypt(chunk)
|
|
232
|
+
yield encode_data(encrypted_chunk)
|
|
233
|
+
yield encode_stop(aead.finalize())
|
|
234
|
+
|
|
235
|
+
kwargs["content"] = signed_chunk_generator()
|
|
236
|
+
await self._send_request(
|
|
237
|
+
"PUT", path, req_nonce=kwargs.get("headers", {}).get("X-Nonce"), **kwargs
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
kwargs["content"] = chunk_generator()
|
|
241
|
+
await self._send_request("PUT", path, **kwargs)
|
|
242
|
+
|
|
243
|
+
async def list(self, path: Optional[str] = None) -> List[Metadata]:
|
|
244
|
+
api_path = get_api_path(self.dir_id, path)
|
|
245
|
+
resp = await self._send_request("GET", api_path)
|
|
246
|
+
|
|
247
|
+
sig = resp.request.headers.get("X-Signature", "") if self.token else None
|
|
248
|
+
data = parse_json_response(self.token, sig, resp.content)
|
|
249
|
+
if "items" not in data:
|
|
250
|
+
meta = Metadata(**data)
|
|
251
|
+
return [meta]
|
|
252
|
+
|
|
253
|
+
list_resp = ListResponse(**data)
|
|
254
|
+
return list_resp.items
|
|
255
|
+
|
|
256
|
+
async def info(self, path: str) -> Metadata:
|
|
257
|
+
api_path = get_api_path(self.dir_id, path)
|
|
258
|
+
resp = await self._send_request("GET", api_path)
|
|
259
|
+
sig = resp.request.headers.get("X-Signature", "") if self.token else None
|
|
260
|
+
data = parse_json_response(self.token, sig, resp.content)
|
|
261
|
+
return Metadata(**data)
|
|
262
|
+
|
|
263
|
+
async def exists(self, path: str) -> bool:
|
|
264
|
+
try:
|
|
265
|
+
await self.info(path)
|
|
266
|
+
return True
|
|
267
|
+
except NotFoundError:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
async def delete(self, path: str):
|
|
271
|
+
api_path = get_api_path(self.dir_id, path)
|
|
272
|
+
await self._send_request("DELETE", api_path)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class AsyncFileBridgeClient:
|
|
276
|
+
def __init__(self, base_url: str):
|
|
277
|
+
self.base_url = base_url.rstrip("/") + "/"
|
|
278
|
+
self.client = httpx.AsyncClient(base_url=self.base_url)
|
|
279
|
+
|
|
280
|
+
def location(self, dir_id: str, token: Optional[str] = None) -> AsyncLocation:
|
|
281
|
+
return AsyncLocation(self, dir_id, token)
|
|
282
|
+
|
|
283
|
+
async def close(self):
|
|
284
|
+
await self.client.aclose()
|
|
285
|
+
|
|
286
|
+
async def __aenter__(self):
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
290
|
+
await self.close()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
FileBridgeError,
|
|
14
|
+
FileBridgePermissionError,
|
|
15
|
+
IsDirectoryError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def calculate_signature(
|
|
21
|
+
token: str | None, method: str, url: str, timestamp: str, nonce: str
|
|
22
|
+
) -> str:
|
|
23
|
+
if not token:
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
parsed = urlparse(url)
|
|
27
|
+
path = parsed.path
|
|
28
|
+
if parsed.query:
|
|
29
|
+
path += "?" + parsed.query
|
|
30
|
+
|
|
31
|
+
mac = hmac.new(token.encode(), digestmod=hashlib.sha256)
|
|
32
|
+
mac.update(timestamp.encode())
|
|
33
|
+
mac.update(nonce.encode())
|
|
34
|
+
mac.update(method.upper().encode())
|
|
35
|
+
mac.update(path.encode())
|
|
36
|
+
|
|
37
|
+
return mac.hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_api_path(dir_id: str, path: str | None) -> str:
|
|
41
|
+
"""Build the API path, stripping leading slashes from the requested path."""
|
|
42
|
+
if path:
|
|
43
|
+
# Ensure path doesn't start with / to avoid urljoin issues
|
|
44
|
+
clean_path = path.lstrip("/")
|
|
45
|
+
if clean_path:
|
|
46
|
+
return f"api/v1/fs/{dir_id}/{clean_path}"
|
|
47
|
+
return f"api/v1/fs/{dir_id}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def prepare_request_kwargs(
|
|
51
|
+
method: str, url: str, token: str | None, kwargs: dict[str, Any]
|
|
52
|
+
) -> tuple[dict[str, Any], str]:
|
|
53
|
+
content = kwargs.get("content", b"")
|
|
54
|
+
if isinstance(content, str):
|
|
55
|
+
kwargs["content"] = content.encode()
|
|
56
|
+
elif kwargs.get("json") is not None:
|
|
57
|
+
kwargs["content"] = json.dumps(kwargs["json"], separators=(",", ":")).encode()
|
|
58
|
+
if "json" in kwargs:
|
|
59
|
+
del kwargs["json"]
|
|
60
|
+
|
|
61
|
+
nonce = ""
|
|
62
|
+
if token:
|
|
63
|
+
headers = kwargs.get("headers", {})
|
|
64
|
+
if "X-Signature" not in headers:
|
|
65
|
+
timestamp = str(int(time.time()))
|
|
66
|
+
nonce = secrets.token_hex(8)
|
|
67
|
+
signature = calculate_signature(token, method, url, timestamp, nonce)
|
|
68
|
+
headers.update(
|
|
69
|
+
{"X-Signature": signature, "X-Timestamp": timestamp, "X-Nonce": nonce}
|
|
70
|
+
)
|
|
71
|
+
kwargs["headers"] = headers
|
|
72
|
+
|
|
73
|
+
return kwargs, nonce
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handle_response_errors(status_code: int, text: str):
|
|
77
|
+
if status_code == 401:
|
|
78
|
+
raise AuthenticationError(f"Authentication failed: {text}")
|
|
79
|
+
if status_code == 403:
|
|
80
|
+
raise FileBridgePermissionError(f"Access Forbidden: {text}")
|
|
81
|
+
if status_code == 404:
|
|
82
|
+
raise NotFoundError(f"Not Found: {text}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_json_response(token: str | None, signature: str | None, body: bytes) -> dict:
|
|
86
|
+
"""Parse a JSON response body, decrypting it first when token+signature are present."""
|
|
87
|
+
parsed = json.loads(body)
|
|
88
|
+
if token and signature:
|
|
89
|
+
from .stream import StreamError, decrypt_json_response
|
|
90
|
+
|
|
91
|
+
message = parsed.get("message")
|
|
92
|
+
if message is None:
|
|
93
|
+
raise FileBridgeError("Missing 'message' field in encrypted response")
|
|
94
|
+
try:
|
|
95
|
+
json_bytes = decrypt_json_response(token, signature, message)
|
|
96
|
+
except StreamError as e:
|
|
97
|
+
raise FileBridgeError(f"JSON response decryption failed: {e}")
|
|
98
|
+
return json.loads(json_bytes)
|
|
99
|
+
return parsed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def decode_read_response(
|
|
103
|
+
token: str | None,
|
|
104
|
+
content_type: str,
|
|
105
|
+
content: bytes,
|
|
106
|
+
sig: str | None,
|
|
107
|
+
path: str,
|
|
108
|
+
) -> bytes:
|
|
109
|
+
"""Evaluate Content-Type and return decoded payload bytes.
|
|
110
|
+
|
|
111
|
+
Raises IsDirectoryError for directory JSON responses, FileBridgeError
|
|
112
|
+
for missing signature in stream mode.
|
|
113
|
+
"""
|
|
114
|
+
if "application/json" in content_type:
|
|
115
|
+
data = parse_json_response(token, sig if token else None, content)
|
|
116
|
+
if "items" in data:
|
|
117
|
+
raise IsDirectoryError(f"{path} is a directory")
|
|
118
|
+
|
|
119
|
+
if "application/vnd.filebridge.stream" in content_type:
|
|
120
|
+
if not sig or not token:
|
|
121
|
+
raise FileBridgeError("Missing signature for stream verification")
|
|
122
|
+
return decode_verified_stream_content(token, sig, content)
|
|
123
|
+
|
|
124
|
+
return content
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_encrypted_write_body(token: str, sig: str, data: bytes) -> bytes:
|
|
128
|
+
"""Pack `data` into signed stream frames (ChaCha20Poly1305)."""
|
|
129
|
+
from .stream import StreamAead, encode_data, encode_stop
|
|
130
|
+
|
|
131
|
+
CHUNK_SIZE = 64 * 1024
|
|
132
|
+
aead = StreamAead(token, sig)
|
|
133
|
+
buf = bytearray()
|
|
134
|
+
for i in range(0, len(data), CHUNK_SIZE):
|
|
135
|
+
buf.extend(encode_data(aead.encrypt(data[i : i + CHUNK_SIZE])))
|
|
136
|
+
buf.extend(encode_stop(aead.finalize()))
|
|
137
|
+
return bytes(buf)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def decode_verified_stream_content(token: str, signature: str, content: bytes) -> bytes:
|
|
141
|
+
from .stream import StreamAead, StreamDecoder, StreamError
|
|
142
|
+
|
|
143
|
+
decoder = StreamDecoder()
|
|
144
|
+
aead = StreamAead(token, signature)
|
|
145
|
+
decoder.push(content)
|
|
146
|
+
|
|
147
|
+
result_bytes = bytearray()
|
|
148
|
+
while True:
|
|
149
|
+
frame = decoder.next_frame()
|
|
150
|
+
if not frame:
|
|
151
|
+
break
|
|
152
|
+
tag, sig_str, payload = frame
|
|
153
|
+
if tag == "DATA":
|
|
154
|
+
try:
|
|
155
|
+
result_bytes.extend(aead.decrypt(payload))
|
|
156
|
+
except StreamError:
|
|
157
|
+
raise FileBridgeError("Chunk Authenticated Decryption failed")
|
|
158
|
+
elif tag == "STOP":
|
|
159
|
+
if not sig_str:
|
|
160
|
+
raise FileBridgeError("Stop frame missing signature")
|
|
161
|
+
try:
|
|
162
|
+
aead.verify_stop(sig_str)
|
|
163
|
+
except StreamError:
|
|
164
|
+
raise FileBridgeError("Stop signature mismatch")
|
|
165
|
+
break
|
|
166
|
+
return bytes(result_bytes)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FileBridgeError(Exception):
|
|
5
|
+
"""Base exception for FileBridge."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(FileBridgeError):
|
|
11
|
+
"""Raised when authentication fails (401/403)."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(FileBridgeError):
|
|
17
|
+
"""Raised when a resource is not found (404)."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IsDirectoryError(FileBridgeError):
|
|
23
|
+
"""Raised when a file operation is attempted on a directory."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileBridgePermissionError(FileBridgeError):
|
|
29
|
+
"""Raised when an operation is forbidden due to permissions (403)."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .exceptions import FileBridgeError
|
|
8
|
+
from .stream import StreamError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileBridgeReadStream(io.RawIOBase):
|
|
12
|
+
def __init__(self, response: httpx.Response, token: str | None = None):
|
|
13
|
+
self._response = response
|
|
14
|
+
self._token = token
|
|
15
|
+
self._iter = response.iter_bytes()
|
|
16
|
+
self._buffer = bytearray()
|
|
17
|
+
self._eof = False
|
|
18
|
+
|
|
19
|
+
self._is_verified = "application/vnd.filebridge.stream" in response.headers.get(
|
|
20
|
+
"Content-Type", ""
|
|
21
|
+
)
|
|
22
|
+
self._decoder = None
|
|
23
|
+
|
|
24
|
+
if self._is_verified:
|
|
25
|
+
from .stream import StreamDecoder, StreamAead
|
|
26
|
+
|
|
27
|
+
signature = response.request.headers.get("X-Signature")
|
|
28
|
+
if not signature or not self._token:
|
|
29
|
+
raise FileBridgeError("Missing signature for stream verification")
|
|
30
|
+
self._decoder = StreamDecoder()
|
|
31
|
+
self._aead = StreamAead(self._token, signature)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def name(self) -> str:
|
|
35
|
+
return self.__class__.__name__
|
|
36
|
+
|
|
37
|
+
def readable(self) -> bool:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def close(self):
|
|
41
|
+
self._response.close()
|
|
42
|
+
super().close()
|
|
43
|
+
|
|
44
|
+
def _fill_buffer(self) -> bool:
|
|
45
|
+
"""Reads from HTTP stream into buffer. Returns True if data was added, False if EOF."""
|
|
46
|
+
if self._eof:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
if not self._is_verified:
|
|
50
|
+
try:
|
|
51
|
+
chunk = next(self._iter)
|
|
52
|
+
if chunk:
|
|
53
|
+
self._buffer.extend(chunk)
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
except StopIteration:
|
|
57
|
+
self._eof = True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# Verified stream logic
|
|
61
|
+
decoder = self._decoder
|
|
62
|
+
aead = self._aead
|
|
63
|
+
assert decoder is not None and aead is not None
|
|
64
|
+
|
|
65
|
+
while not self._eof:
|
|
66
|
+
frame = decoder.next_frame()
|
|
67
|
+
if frame:
|
|
68
|
+
tag, sig_str, payload = frame
|
|
69
|
+
if tag == "DATA":
|
|
70
|
+
try:
|
|
71
|
+
self._buffer.extend(aead.decrypt(payload))
|
|
72
|
+
return True
|
|
73
|
+
except StreamError:
|
|
74
|
+
raise FileBridgeError("Chunk Authenticated Decryption Failed")
|
|
75
|
+
elif tag == "STOP":
|
|
76
|
+
if not sig_str:
|
|
77
|
+
raise FileBridgeError("Stop frame missing signature")
|
|
78
|
+
try:
|
|
79
|
+
aead.verify_stop(sig_str)
|
|
80
|
+
except StreamError:
|
|
81
|
+
raise FileBridgeError("Stop signature mismatch")
|
|
82
|
+
self._eof = True
|
|
83
|
+
return False
|
|
84
|
+
else:
|
|
85
|
+
try:
|
|
86
|
+
decoder.push(next(self._iter))
|
|
87
|
+
except StopIteration:
|
|
88
|
+
raise FileBridgeError("Unexpected EOF before STOP frame")
|
|
89
|
+
|
|
90
|
+
return bool(self._buffer)
|
|
91
|
+
|
|
92
|
+
def readinto(self, b) -> int:
|
|
93
|
+
if not self._buffer and not self._eof:
|
|
94
|
+
self._fill_buffer()
|
|
95
|
+
|
|
96
|
+
if not self._buffer:
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
length = min(len(b), len(self._buffer))
|
|
100
|
+
b[:length] = self._buffer[:length]
|
|
101
|
+
del self._buffer[:length]
|
|
102
|
+
return length
|
|
103
|
+
|
|
104
|
+
def read(self, size: int = -1) -> bytes:
|
|
105
|
+
if size == -1 or size is None:
|
|
106
|
+
while not self._eof:
|
|
107
|
+
self._fill_buffer()
|
|
108
|
+
res = bytes(self._buffer)
|
|
109
|
+
self._buffer.clear()
|
|
110
|
+
return res
|
|
111
|
+
|
|
112
|
+
while len(self._buffer) < size and not self._eof:
|
|
113
|
+
self._fill_buffer()
|
|
114
|
+
|
|
115
|
+
length = min(size, len(self._buffer))
|
|
116
|
+
res = bytes(self._buffer[:length])
|
|
117
|
+
del self._buffer[:length]
|
|
118
|
+
return res
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncFileBridgeReadStream:
|
|
122
|
+
def __init__(self, response: httpx.Response, token: str | None = None):
|
|
123
|
+
self._response = response
|
|
124
|
+
self._token = token
|
|
125
|
+
self._iter = response.aiter_bytes()
|
|
126
|
+
self._buffer = bytearray()
|
|
127
|
+
self._eof = False
|
|
128
|
+
|
|
129
|
+
self._is_verified = "application/vnd.filebridge.stream" in response.headers.get(
|
|
130
|
+
"Content-Type", ""
|
|
131
|
+
)
|
|
132
|
+
self._decoder = None
|
|
133
|
+
|
|
134
|
+
if self._is_verified:
|
|
135
|
+
from .stream import StreamDecoder, StreamAead
|
|
136
|
+
|
|
137
|
+
signature = response.request.headers.get("X-Signature")
|
|
138
|
+
if not signature or not self._token:
|
|
139
|
+
raise FileBridgeError("Missing signature for stream verification")
|
|
140
|
+
self._decoder = StreamDecoder()
|
|
141
|
+
self._aead = StreamAead(self._token, signature)
|
|
142
|
+
|
|
143
|
+
async def __aenter__(self):
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
147
|
+
await self.close()
|
|
148
|
+
|
|
149
|
+
async def close(self):
|
|
150
|
+
await self._response.aclose()
|
|
151
|
+
|
|
152
|
+
async def _fill_buffer(self) -> bool:
|
|
153
|
+
if self._eof:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
if not self._is_verified:
|
|
157
|
+
try:
|
|
158
|
+
chunk = await self._iter.__anext__()
|
|
159
|
+
if chunk:
|
|
160
|
+
self._buffer.extend(chunk)
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
except StopAsyncIteration:
|
|
164
|
+
self._eof = True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
# Verified stream logic
|
|
168
|
+
decoder = self._decoder
|
|
169
|
+
aead = self._aead
|
|
170
|
+
assert decoder is not None and aead is not None
|
|
171
|
+
|
|
172
|
+
while not self._eof:
|
|
173
|
+
frame = decoder.next_frame()
|
|
174
|
+
if frame:
|
|
175
|
+
tag, sig_str, payload = frame
|
|
176
|
+
if tag == "DATA":
|
|
177
|
+
try:
|
|
178
|
+
self._buffer.extend(aead.decrypt(payload))
|
|
179
|
+
return True
|
|
180
|
+
except StreamError:
|
|
181
|
+
raise FileBridgeError("Chunk Authenticated Decryption Failed")
|
|
182
|
+
elif tag == "STOP":
|
|
183
|
+
if not sig_str:
|
|
184
|
+
raise FileBridgeError("Stop frame missing signature")
|
|
185
|
+
try:
|
|
186
|
+
aead.verify_stop(sig_str)
|
|
187
|
+
except StreamError:
|
|
188
|
+
raise FileBridgeError("Stop signature mismatch")
|
|
189
|
+
self._eof = True
|
|
190
|
+
return False
|
|
191
|
+
else:
|
|
192
|
+
try:
|
|
193
|
+
decoder.push(await self._iter.__anext__())
|
|
194
|
+
except StopAsyncIteration:
|
|
195
|
+
raise FileBridgeError("Unexpected EOF before STOP frame")
|
|
196
|
+
|
|
197
|
+
return bool(self._buffer)
|
|
198
|
+
|
|
199
|
+
async def read(self, size: int = -1) -> bytes:
|
|
200
|
+
if size == -1 or size is None:
|
|
201
|
+
while not self._eof:
|
|
202
|
+
await self._fill_buffer()
|
|
203
|
+
res = bytes(self._buffer)
|
|
204
|
+
self._buffer.clear()
|
|
205
|
+
return res
|
|
206
|
+
|
|
207
|
+
while len(self._buffer) < size and not self._eof:
|
|
208
|
+
await self._fill_buffer()
|
|
209
|
+
|
|
210
|
+
length = min(size, len(self._buffer))
|
|
211
|
+
res = bytes(self._buffer[:length])
|
|
212
|
+
del self._buffer[:length]
|
|
213
|
+
return res
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Metadata(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
is_dir: bool
|
|
9
|
+
size: int | None = None
|
|
10
|
+
mdate: str | None = None
|
|
11
|
+
sha256: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ListResponse(BaseModel):
|
|
15
|
+
items: list[Metadata]
|
|
16
|
+
detail: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
import zstandard
|
|
8
|
+
from cryptography.exceptions import InvalidTag
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StreamError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StreamAead:
|
|
17
|
+
def __init__(self, token: str, iv_hex: str):
|
|
18
|
+
key_hash = hashlib.sha256(token.encode()).digest()
|
|
19
|
+
self.cipher = ChaCha20Poly1305(key_hash)
|
|
20
|
+
|
|
21
|
+
iv_hasher = hashlib.sha256()
|
|
22
|
+
iv_hasher.update(token.encode())
|
|
23
|
+
iv_hasher.update(iv_hex.encode())
|
|
24
|
+
iv_hash = iv_hasher.digest()
|
|
25
|
+
|
|
26
|
+
self.nonce_base = bytearray(12)
|
|
27
|
+
copy_len = min(len(iv_hash), 12)
|
|
28
|
+
self.nonce_base[:copy_len] = iv_hash[:copy_len]
|
|
29
|
+
self.counter = 0
|
|
30
|
+
|
|
31
|
+
def _current_nonce(self) -> bytes:
|
|
32
|
+
nonce_bytes = bytearray(self.nonce_base)
|
|
33
|
+
counter_bytes = struct.pack(">Q", self.counter)
|
|
34
|
+
for i in range(8):
|
|
35
|
+
nonce_bytes[4 + i] ^= counter_bytes[i]
|
|
36
|
+
return bytes(nonce_bytes)
|
|
37
|
+
|
|
38
|
+
def encrypt(self, data: bytes) -> bytes:
|
|
39
|
+
nonce = self._current_nonce()
|
|
40
|
+
ciphertext = self.cipher.encrypt(nonce, data, None)
|
|
41
|
+
self.counter += 1
|
|
42
|
+
return ciphertext
|
|
43
|
+
|
|
44
|
+
def decrypt(self, data: bytes) -> bytes:
|
|
45
|
+
nonce = self._current_nonce()
|
|
46
|
+
try:
|
|
47
|
+
plaintext = self.cipher.decrypt(nonce, data, None)
|
|
48
|
+
self.counter += 1
|
|
49
|
+
return plaintext
|
|
50
|
+
except InvalidTag as e:
|
|
51
|
+
raise StreamError(f"Decryption failed: {e}")
|
|
52
|
+
|
|
53
|
+
def finalize(self) -> str:
|
|
54
|
+
final_block = self.encrypt(b"")
|
|
55
|
+
return final_block.hex()
|
|
56
|
+
|
|
57
|
+
def verify_stop(self, hex_sig: str):
|
|
58
|
+
try:
|
|
59
|
+
final_block = bytes.fromhex(hex_sig)
|
|
60
|
+
self.decrypt(final_block)
|
|
61
|
+
except (ValueError, StreamError) as e:
|
|
62
|
+
raise StreamError(f"Invalid stop signature: {e}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode_data(payload: bytes) -> bytes:
|
|
66
|
+
frame = bytearray(b"DATA")
|
|
67
|
+
frame.extend(struct.pack(">I", len(payload)))
|
|
68
|
+
frame.extend(payload)
|
|
69
|
+
return bytes(frame)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def encode_stop(signature: str | None = None) -> bytes:
|
|
73
|
+
frame = bytearray(b"STOP")
|
|
74
|
+
if signature:
|
|
75
|
+
sig_bytes = signature.encode()
|
|
76
|
+
frame.extend(struct.pack(">I", len(sig_bytes)))
|
|
77
|
+
frame.extend(sig_bytes)
|
|
78
|
+
else:
|
|
79
|
+
frame.extend(struct.pack(">I", 0))
|
|
80
|
+
return bytes(frame)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _derive_block_nonce(token: str, iv_hex: str) -> tuple[ChaCha20Poly1305, bytes]:
|
|
84
|
+
"""Shared key+nonce derivation for single-block JSON encryption (counter=0)."""
|
|
85
|
+
key_hash = hashlib.sha256(token.encode()).digest()
|
|
86
|
+
cipher = ChaCha20Poly1305(key_hash)
|
|
87
|
+
iv_hasher = hashlib.sha256()
|
|
88
|
+
iv_hasher.update(token.encode())
|
|
89
|
+
iv_hasher.update(iv_hex.encode())
|
|
90
|
+
nonce = iv_hasher.digest()[:12]
|
|
91
|
+
return cipher, nonce
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def encrypt_json_response(token: str, iv_hex: str, json_bytes: bytes) -> str:
|
|
95
|
+
"""Compress, encrypt and base64-encode JSON bytes for a token-protected response."""
|
|
96
|
+
compressed = zstandard.ZstdCompressor(level=3).compress(json_bytes)
|
|
97
|
+
cipher, nonce = _derive_block_nonce(token, iv_hex)
|
|
98
|
+
ciphertext = cipher.encrypt(nonce, compressed, None)
|
|
99
|
+
return base64.b64encode(ciphertext).decode("ascii")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def decrypt_json_response(token: str, iv_hex: str, encoded: str) -> bytes:
|
|
103
|
+
"""Base64-decode, decrypt and decompress an encrypted JSON response."""
|
|
104
|
+
data = base64.b64decode(encoded)
|
|
105
|
+
cipher, nonce = _derive_block_nonce(token, iv_hex)
|
|
106
|
+
try:
|
|
107
|
+
compressed = cipher.decrypt(nonce, data, None)
|
|
108
|
+
except InvalidTag as e:
|
|
109
|
+
raise StreamError(f"JSON response decryption failed: {e}")
|
|
110
|
+
return zstandard.ZstdDecompressor().decompress(compressed)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StreamDecoder:
|
|
114
|
+
def __init__(self):
|
|
115
|
+
self.buffer = bytearray()
|
|
116
|
+
self.offset = 0
|
|
117
|
+
|
|
118
|
+
def push(self, chunk: bytes):
|
|
119
|
+
self.buffer.extend(chunk)
|
|
120
|
+
|
|
121
|
+
def next_frame(self) -> tuple[str, str | None, bytes] | None:
|
|
122
|
+
rem_len = len(self.buffer) - self.offset
|
|
123
|
+
if rem_len < 8:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
tag = bytes(self.buffer[self.offset : self.offset + 4]).decode("ascii")
|
|
127
|
+
length = struct.unpack(">I", self.buffer[self.offset + 4 : self.offset + 8])[0]
|
|
128
|
+
|
|
129
|
+
if rem_len < 8 + length:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
payload = bytes(self.buffer[self.offset + 8 : self.offset + 8 + length])
|
|
133
|
+
|
|
134
|
+
# Advance buffer offset
|
|
135
|
+
self.offset += 8 + length
|
|
136
|
+
|
|
137
|
+
# Periodically compact buffer to release memory if we've processed a lot
|
|
138
|
+
if self.offset > 1024 * 1024:
|
|
139
|
+
del self.buffer[:self.offset]
|
|
140
|
+
self.offset = 0
|
|
141
|
+
|
|
142
|
+
sig_str = None
|
|
143
|
+
if tag == "STOP":
|
|
144
|
+
if length > 0:
|
|
145
|
+
sig_str = payload.decode("ascii", errors="replace")
|
|
146
|
+
|
|
147
|
+
return tag, sig_str, payload
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import ContextManager, List, Optional, overload
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .core import (
|
|
10
|
+
build_encrypted_write_body,
|
|
11
|
+
decode_read_response,
|
|
12
|
+
get_api_path,
|
|
13
|
+
handle_response_errors,
|
|
14
|
+
parse_json_response,
|
|
15
|
+
prepare_request_kwargs,
|
|
16
|
+
)
|
|
17
|
+
from .exceptions import AuthenticationError, FileBridgeError, IsDirectoryError, NotFoundError
|
|
18
|
+
from .io import FileBridgeReadStream
|
|
19
|
+
from .models import ListResponse, Metadata
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Location:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
client: "FileBridgeClient",
|
|
26
|
+
dir_id: str,
|
|
27
|
+
token: Optional[str] = None,
|
|
28
|
+
):
|
|
29
|
+
self.dir_id = dir_id
|
|
30
|
+
self.token = token
|
|
31
|
+
self.http_client = client.client
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
def _send_request(
|
|
35
|
+
self, method: str, url: str, kwargs: dict, req_nonce: Optional[str] = None
|
|
36
|
+
) -> httpx.Response:
|
|
37
|
+
response = self.http_client.request(method, url, **kwargs)
|
|
38
|
+
if not response.is_success:
|
|
39
|
+
handle_response_errors(response.status_code, response.text)
|
|
40
|
+
raise FileBridgeError(f"HTTP Error {response.status_code}: {response.text}")
|
|
41
|
+
|
|
42
|
+
if self.token:
|
|
43
|
+
resp_nonce = response.headers.get("X-Nonce")
|
|
44
|
+
if resp_nonce != req_nonce:
|
|
45
|
+
raise AuthenticationError("Nonce mismatch")
|
|
46
|
+
return response
|
|
47
|
+
|
|
48
|
+
def read(
|
|
49
|
+
self,
|
|
50
|
+
path: str,
|
|
51
|
+
offset: Optional[int] = None,
|
|
52
|
+
length: Optional[int] = None,
|
|
53
|
+
) -> bytes:
|
|
54
|
+
api_path = get_api_path(self.dir_id, path)
|
|
55
|
+
params = {}
|
|
56
|
+
if offset is not None:
|
|
57
|
+
params["offset"] = offset
|
|
58
|
+
if length is not None:
|
|
59
|
+
params["length"] = length
|
|
60
|
+
|
|
61
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
62
|
+
kwargs, req_nonce = prepare_request_kwargs(
|
|
63
|
+
method="GET",
|
|
64
|
+
url=url,
|
|
65
|
+
token=self.token,
|
|
66
|
+
kwargs={"params": params} if params else {},
|
|
67
|
+
)
|
|
68
|
+
headers = kwargs.setdefault("headers", {})
|
|
69
|
+
headers["Accept"] = "application/octet-stream"
|
|
70
|
+
if self.token:
|
|
71
|
+
headers["Accept"] = "application/vnd.filebridge.stream"
|
|
72
|
+
|
|
73
|
+
response = self._send_request("GET", url, kwargs, req_nonce)
|
|
74
|
+
|
|
75
|
+
return decode_read_response(
|
|
76
|
+
self.token,
|
|
77
|
+
response.headers.get("Content-Type", ""),
|
|
78
|
+
response.content,
|
|
79
|
+
response.request.headers.get("X-Signature"),
|
|
80
|
+
path,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def write(self, path: str, data: bytes, offset: Optional[int] = None):
|
|
84
|
+
api_path = get_api_path(self.dir_id, path)
|
|
85
|
+
params = {}
|
|
86
|
+
if offset is not None:
|
|
87
|
+
params["offset"] = offset
|
|
88
|
+
|
|
89
|
+
headers = {"Content-Type": "application/octet-stream"}
|
|
90
|
+
if self.token:
|
|
91
|
+
headers["Content-Type"] = "application/vnd.filebridge.stream"
|
|
92
|
+
|
|
93
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
94
|
+
kwargs, req_nonce = prepare_request_kwargs(
|
|
95
|
+
"PUT", url, self.token, {"params": params, "headers": headers}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if self.token:
|
|
99
|
+
sig = kwargs.get("headers", {}).get("X-Signature", "")
|
|
100
|
+
kwargs["content"] = build_encrypted_write_body(self.token, sig, data)
|
|
101
|
+
self._send_request("PUT", url, kwargs, req_nonce)
|
|
102
|
+
else:
|
|
103
|
+
kwargs["content"] = data
|
|
104
|
+
self._send_request("PUT", url, kwargs, req_nonce)
|
|
105
|
+
|
|
106
|
+
@overload
|
|
107
|
+
def stream_read(
|
|
108
|
+
self,
|
|
109
|
+
path: str,
|
|
110
|
+
offset: Optional[int] = ...,
|
|
111
|
+
length: Optional[int] = ...,
|
|
112
|
+
encoding: None = None,
|
|
113
|
+
) -> ContextManager[FileBridgeReadStream]: ...
|
|
114
|
+
|
|
115
|
+
@overload
|
|
116
|
+
def stream_read(
|
|
117
|
+
self,
|
|
118
|
+
path: str,
|
|
119
|
+
offset: Optional[int] = ...,
|
|
120
|
+
length: Optional[int] = ...,
|
|
121
|
+
*,
|
|
122
|
+
encoding: str,
|
|
123
|
+
) -> ContextManager[io.TextIOWrapper]: ...
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def stream_read(
|
|
127
|
+
self,
|
|
128
|
+
path: str,
|
|
129
|
+
offset: Optional[int] = None,
|
|
130
|
+
length: Optional[int] = None,
|
|
131
|
+
encoding: Optional[str] = None,
|
|
132
|
+
):
|
|
133
|
+
api_path = get_api_path(self.dir_id, path)
|
|
134
|
+
params = {}
|
|
135
|
+
if offset is not None:
|
|
136
|
+
params["offset"] = offset
|
|
137
|
+
if length is not None:
|
|
138
|
+
params["length"] = length
|
|
139
|
+
|
|
140
|
+
headers = {"Accept": "application/octet-stream"}
|
|
141
|
+
if self.token:
|
|
142
|
+
headers["Accept"] = "application/vnd.filebridge.stream"
|
|
143
|
+
|
|
144
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
145
|
+
kwargs, req_nonce = prepare_request_kwargs(
|
|
146
|
+
method="GET",
|
|
147
|
+
url=url,
|
|
148
|
+
token=self.token,
|
|
149
|
+
kwargs={"params": params, "headers": headers},
|
|
150
|
+
)
|
|
151
|
+
headers = kwargs.setdefault("headers", {})
|
|
152
|
+
|
|
153
|
+
with self.http_client.stream("GET", url, **kwargs) as response:
|
|
154
|
+
if not response.is_success:
|
|
155
|
+
response.read() # Ensure content is read before raising for error handling
|
|
156
|
+
handle_response_errors(response.status_code, response.text)
|
|
157
|
+
raise FileBridgeError(f"HTTP Error {response.status_code}: {response.text}")
|
|
158
|
+
|
|
159
|
+
if self.token:
|
|
160
|
+
resp_nonce = response.headers.get("X-Nonce")
|
|
161
|
+
if resp_nonce != req_nonce:
|
|
162
|
+
raise AuthenticationError("Nonce mismatch")
|
|
163
|
+
|
|
164
|
+
content_type = response.headers.get("Content-Type", "")
|
|
165
|
+
if "application/json" in content_type:
|
|
166
|
+
body = response.read()
|
|
167
|
+
sig = response.request.headers.get("X-Signature", "") if self.token else None
|
|
168
|
+
data = parse_json_response(self.token, sig, body)
|
|
169
|
+
if "items" in data:
|
|
170
|
+
raise IsDirectoryError(f"{path} is a directory")
|
|
171
|
+
|
|
172
|
+
raw_stream = FileBridgeReadStream(response, self.token)
|
|
173
|
+
if encoding:
|
|
174
|
+
wrapper = io.TextIOWrapper(raw_stream, encoding=encoding)
|
|
175
|
+
try:
|
|
176
|
+
yield wrapper
|
|
177
|
+
finally:
|
|
178
|
+
wrapper.close()
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
yield raw_stream
|
|
182
|
+
finally:
|
|
183
|
+
raw_stream.close()
|
|
184
|
+
|
|
185
|
+
def write_stream(self, path: str, stream, offset: Optional[int] = None):
|
|
186
|
+
api_path = get_api_path(self.dir_id, path)
|
|
187
|
+
params = {}
|
|
188
|
+
if offset is not None:
|
|
189
|
+
params["offset"] = offset
|
|
190
|
+
|
|
191
|
+
headers = {"Content-Type": "application/octet-stream"}
|
|
192
|
+
if self.token:
|
|
193
|
+
headers["Content-Type"] = "application/vnd.filebridge.stream"
|
|
194
|
+
|
|
195
|
+
url = f"{self._client.base_url.rstrip('/')}/{api_path}"
|
|
196
|
+
kwargs, req_nonce = prepare_request_kwargs(
|
|
197
|
+
"PUT", url, self.token, {"params": params, "headers": headers}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def chunk_generator():
|
|
201
|
+
if hasattr(stream, "read"):
|
|
202
|
+
while True:
|
|
203
|
+
chunk = stream.read(64 * 1024)
|
|
204
|
+
if not chunk:
|
|
205
|
+
break
|
|
206
|
+
if isinstance(chunk, str):
|
|
207
|
+
chunk = chunk.encode("utf-8")
|
|
208
|
+
yield chunk
|
|
209
|
+
else:
|
|
210
|
+
for chunk in stream:
|
|
211
|
+
if isinstance(chunk, str):
|
|
212
|
+
chunk = chunk.encode("utf-8")
|
|
213
|
+
yield chunk
|
|
214
|
+
|
|
215
|
+
if self.token:
|
|
216
|
+
from .stream import (
|
|
217
|
+
StreamAead,
|
|
218
|
+
encode_data,
|
|
219
|
+
encode_stop,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
token = self.token
|
|
223
|
+
assert token is not None
|
|
224
|
+
|
|
225
|
+
def signed_chunk_generator():
|
|
226
|
+
sig = kwargs.get("headers", {}).get("X-Signature", "")
|
|
227
|
+
aead = StreamAead(token, sig)
|
|
228
|
+
for chunk in chunk_generator():
|
|
229
|
+
encrypted_chunk = aead.encrypt(chunk)
|
|
230
|
+
yield encode_data(encrypted_chunk)
|
|
231
|
+
|
|
232
|
+
yield encode_stop(aead.finalize())
|
|
233
|
+
|
|
234
|
+
kwargs["content"] = signed_chunk_generator()
|
|
235
|
+
self._send_request("PUT", url, kwargs, req_nonce)
|
|
236
|
+
else:
|
|
237
|
+
kwargs["content"] = chunk_generator()
|
|
238
|
+
self._send_request("PUT", url, kwargs, req_nonce)
|
|
239
|
+
|
|
240
|
+
def list(self, path: Optional[str] = None) -> List[Metadata]:
|
|
241
|
+
url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
|
|
242
|
+
kwargs, req_nonce = prepare_request_kwargs("GET", url, self.token, {})
|
|
243
|
+
response = self._send_request("GET", url, kwargs, req_nonce)
|
|
244
|
+
|
|
245
|
+
sig = response.request.headers.get("X-Signature", "") if self.token else None
|
|
246
|
+
data = parse_json_response(self.token, sig, response.content)
|
|
247
|
+
if "items" not in data:
|
|
248
|
+
meta = Metadata(**data)
|
|
249
|
+
return [meta]
|
|
250
|
+
|
|
251
|
+
list_resp = ListResponse(**data)
|
|
252
|
+
return list_resp.items
|
|
253
|
+
|
|
254
|
+
def info(self, path: str) -> Metadata:
|
|
255
|
+
url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
|
|
256
|
+
kwargs, req_nonce = prepare_request_kwargs("GET", url, self.token, {})
|
|
257
|
+
response = self._send_request("GET", url, kwargs, req_nonce)
|
|
258
|
+
sig = response.request.headers.get("X-Signature", "") if self.token else None
|
|
259
|
+
data = parse_json_response(self.token, sig, response.content)
|
|
260
|
+
return Metadata(**data)
|
|
261
|
+
|
|
262
|
+
def exists(self, path: str) -> bool:
|
|
263
|
+
try:
|
|
264
|
+
self.info(path)
|
|
265
|
+
return True
|
|
266
|
+
except NotFoundError:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
def delete(self, path: str):
|
|
270
|
+
url = f"{self._client.base_url.rstrip('/')}/{get_api_path(self.dir_id, path)}"
|
|
271
|
+
kwargs, req_nonce = prepare_request_kwargs("DELETE", url, self.token, {})
|
|
272
|
+
self._send_request("DELETE", url, kwargs, req_nonce)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class FileBridgeClient:
|
|
276
|
+
def __init__(self, base_url: str):
|
|
277
|
+
self.base_url = base_url.rstrip("/") + "/"
|
|
278
|
+
self.client = httpx.Client(base_url=self.base_url)
|
|
279
|
+
|
|
280
|
+
def location(self, dir_id: str, token: Optional[str] = None) -> Location:
|
|
281
|
+
return Location(self, dir_id, token)
|
|
282
|
+
|
|
283
|
+
def close(self):
|
|
284
|
+
self.client.close()
|
|
285
|
+
|
|
286
|
+
def __enter__(self):
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
290
|
+
self.close()
|