pyfederation 1.0.0__py3-none-any.whl
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.
- pyfederation/__init__.py +65 -0
- pyfederation/_utils.py +105 -0
- pyfederation/async_client.py +1417 -0
- pyfederation/client.py +1406 -0
- pyfederation/enums.py +88 -0
- pyfederation/exceptions.py +23 -0
- pyfederation/py.typed +0 -0
- pyfederation/records.py +276 -0
- pyfederation-1.0.0.dist-info/METADATA +426 -0
- pyfederation-1.0.0.dist-info/RECORD +12 -0
- pyfederation-1.0.0.dist-info/WHEEL +4 -0
- pyfederation-1.0.0.dist-info/licenses/LICENSE +15 -0
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
"""Asynchronous client for Federation servers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, BinaryIO, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._utils import (
|
|
14
|
+
attachment_file_name,
|
|
15
|
+
compact,
|
|
16
|
+
entity_path,
|
|
17
|
+
evidence_payload,
|
|
18
|
+
page_params,
|
|
19
|
+
parse_list,
|
|
20
|
+
path_segment,
|
|
21
|
+
require,
|
|
22
|
+
search_params,
|
|
23
|
+
)
|
|
24
|
+
from .enums import ClassificationFlag, EntityRelationshipType, IncidentType, RecordType
|
|
25
|
+
from .exceptions import FederationClientException
|
|
26
|
+
from .records import (
|
|
27
|
+
AuditLog,
|
|
28
|
+
BlacklistRecord,
|
|
29
|
+
ContentInput,
|
|
30
|
+
EntityQueryResult,
|
|
31
|
+
EntityRecord,
|
|
32
|
+
EvidenceRecord,
|
|
33
|
+
FileAttachmentRecord,
|
|
34
|
+
OperatorCreated,
|
|
35
|
+
OperatorRecord,
|
|
36
|
+
ReportRecord,
|
|
37
|
+
ReportSubmission,
|
|
38
|
+
ScannedContent,
|
|
39
|
+
SearchResult,
|
|
40
|
+
ServerInformation,
|
|
41
|
+
UploadResult,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
DEFAULT_MAX_FILE_SIZE = 52_428_800
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsyncFederationClient:
|
|
50
|
+
"""Asynchronous API client for a Federation server.
|
|
51
|
+
|
|
52
|
+
Every public method corresponds to an endpoint of the server and handles serialization,
|
|
53
|
+
authentication and error mapping, raising FederationClientException when a request fails.
|
|
54
|
+
|
|
55
|
+
List and search methods are paginated with a 1-based page and a limit of items per page, which
|
|
56
|
+
the server caps at its configured maximum. Most of them also accept a category filter, a field
|
|
57
|
+
to sort by and a sort order (ASC or DESC); the accepted values of each are listed in the server
|
|
58
|
+
specification.
|
|
59
|
+
|
|
60
|
+
Entities are identified by their UUID, SHA-256 hash or address, such as a domain, an email
|
|
61
|
+
address or an IP address.
|
|
62
|
+
|
|
63
|
+
Invalid arguments, such as an empty UUID or a page below 1, raise ValueError before any request
|
|
64
|
+
is sent.
|
|
65
|
+
|
|
66
|
+
To release the underlying connection pool, close the client with aclose() or use it as a
|
|
67
|
+
context manager.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
endpoint: str,
|
|
73
|
+
access_token: str | None = None,
|
|
74
|
+
http_client: httpx.AsyncClient | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Args:
|
|
78
|
+
endpoint: The base URL of the Federation server.
|
|
79
|
+
access_token: The access token used for authentication, or None for anonymous access.
|
|
80
|
+
http_client: The HTTP client used for all requests, closed together with this client.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If the endpoint is not a valid URL or the access token is invalid.
|
|
84
|
+
"""
|
|
85
|
+
if not httpx.URL(endpoint).host:
|
|
86
|
+
raise ValueError(f"Endpoint must be a valid URL: {endpoint}")
|
|
87
|
+
|
|
88
|
+
self._endpoint = endpoint.rstrip("/")
|
|
89
|
+
self._http_client = http_client or httpx.AsyncClient(timeout=httpx.Timeout(30, write=60))
|
|
90
|
+
self.access_token = access_token
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def endpoint(self) -> str:
|
|
94
|
+
"""The base URL of the Federation server."""
|
|
95
|
+
return self._endpoint
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def access_token(self) -> str | None:
|
|
99
|
+
"""The access token used for authentication, or None for anonymous access."""
|
|
100
|
+
return self._access_token
|
|
101
|
+
|
|
102
|
+
@access_token.setter
|
|
103
|
+
def access_token(self, access_token: str | None) -> None:
|
|
104
|
+
if access_token is not None and (not access_token or any(map(str.isspace, access_token))):
|
|
105
|
+
raise ValueError("Access token cannot be empty or contain whitespace")
|
|
106
|
+
|
|
107
|
+
self._access_token = access_token
|
|
108
|
+
|
|
109
|
+
async def aclose(self) -> None:
|
|
110
|
+
"""Closes the underlying HTTP client."""
|
|
111
|
+
await self._http_client.aclose()
|
|
112
|
+
|
|
113
|
+
async def __aenter__(self) -> Self:
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
async def __aexit__(
|
|
117
|
+
self,
|
|
118
|
+
exc_type: type[BaseException] | None,
|
|
119
|
+
exc_value: BaseException | None,
|
|
120
|
+
traceback: TracebackType | None,
|
|
121
|
+
) -> None:
|
|
122
|
+
await self.aclose()
|
|
123
|
+
|
|
124
|
+
async def _send(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
error_message: str,
|
|
129
|
+
*,
|
|
130
|
+
params: dict[str, Any] | None = None,
|
|
131
|
+
json: Any = None,
|
|
132
|
+
data: dict[str, Any] | None = None,
|
|
133
|
+
files: dict[str, Any] | None = None,
|
|
134
|
+
stream: bool = False,
|
|
135
|
+
) -> httpx.Response:
|
|
136
|
+
headers = {"Accept": "application/json"}
|
|
137
|
+
if self._access_token is not None:
|
|
138
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
139
|
+
|
|
140
|
+
request = self._http_client.build_request(
|
|
141
|
+
method,
|
|
142
|
+
f"{self._endpoint}/{path}",
|
|
143
|
+
params=params,
|
|
144
|
+
json=json,
|
|
145
|
+
data=data,
|
|
146
|
+
files=files,
|
|
147
|
+
headers=headers,
|
|
148
|
+
)
|
|
149
|
+
log.debug("%s request to %s", method, request.url)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
response = await self._http_client.send(request, stream=stream)
|
|
153
|
+
if response.is_error:
|
|
154
|
+
await response.aread()
|
|
155
|
+
raise FederationClientException.from_response(response, error_message)
|
|
156
|
+
except httpx.HTTPError as e:
|
|
157
|
+
raise FederationClientException(f"{error_message}: {e}") from e
|
|
158
|
+
|
|
159
|
+
return response
|
|
160
|
+
|
|
161
|
+
async def _request(
|
|
162
|
+
self,
|
|
163
|
+
method: str,
|
|
164
|
+
path: str,
|
|
165
|
+
error_message: str,
|
|
166
|
+
*,
|
|
167
|
+
params: dict[str, Any] | None = None,
|
|
168
|
+
json: Any = None,
|
|
169
|
+
data: dict[str, Any] | None = None,
|
|
170
|
+
files: dict[str, Any] | None = None,
|
|
171
|
+
) -> Any:
|
|
172
|
+
response = await self._send(
|
|
173
|
+
method, path, error_message, params=params, json=json, data=data, files=files
|
|
174
|
+
)
|
|
175
|
+
if not response.content:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
return response.json()
|
|
180
|
+
except ValueError as e:
|
|
181
|
+
raise FederationClientException(
|
|
182
|
+
f"{error_message}: Expected JSON response but received Content-Type: "
|
|
183
|
+
f"{response.headers.get('Content-Type')}",
|
|
184
|
+
response.status_code,
|
|
185
|
+
) from e
|
|
186
|
+
|
|
187
|
+
async def get_server_information(self) -> ServerInformation:
|
|
188
|
+
"""Returns the server information including version, feature flags and record counts."""
|
|
189
|
+
data = await self._request("GET", "info", "Failed to get server information")
|
|
190
|
+
return ServerInformation.model_validate(data)
|
|
191
|
+
|
|
192
|
+
async def get_specification(self) -> dict[str, Any]:
|
|
193
|
+
"""Returns the OpenAPI specification of the server."""
|
|
194
|
+
spec: dict[str, Any] = await self._request(
|
|
195
|
+
"GET", "specification", "Failed to get specification"
|
|
196
|
+
)
|
|
197
|
+
return spec
|
|
198
|
+
|
|
199
|
+
async def search(
|
|
200
|
+
self,
|
|
201
|
+
query: str,
|
|
202
|
+
types: Sequence[RecordType] | None = None,
|
|
203
|
+
page: int = 1,
|
|
204
|
+
limit: int = 10,
|
|
205
|
+
) -> list[SearchResult]:
|
|
206
|
+
"""Searches across all record types.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
query: The search query, at least 2 characters long.
|
|
210
|
+
types: The record types to restrict the search to, or None for all types.
|
|
211
|
+
page: The page number.
|
|
212
|
+
limit: The number of results per record type.
|
|
213
|
+
"""
|
|
214
|
+
params = search_params(query, page, limit, type=",".join(types) if types else None)
|
|
215
|
+
data = await self._request(
|
|
216
|
+
"GET", "search", f"Failed to search, query: {query}", params=params
|
|
217
|
+
)
|
|
218
|
+
return parse_list(SearchResult, data)
|
|
219
|
+
|
|
220
|
+
async def scan_content(
|
|
221
|
+
self,
|
|
222
|
+
evidence: str | ContentInput | Sequence[ContentInput],
|
|
223
|
+
author: str | None = None,
|
|
224
|
+
top_k: int | None = None,
|
|
225
|
+
threshold: float | None = None,
|
|
226
|
+
) -> ScannedContent:
|
|
227
|
+
"""Scans content for entities and blacklist records, and classifies it.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
evidence: The text, content input or content inputs to scan.
|
|
231
|
+
author: The UUID, SHA-256 hash or address of the author entity.
|
|
232
|
+
top_k: The number of top classifications to return.
|
|
233
|
+
threshold: The confidence threshold for classification.
|
|
234
|
+
"""
|
|
235
|
+
payload = evidence_payload(evidence)
|
|
236
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
237
|
+
if not any(item.get("text_content") for item in items):
|
|
238
|
+
raise ValueError("At least one evidence record must contain text content")
|
|
239
|
+
|
|
240
|
+
data = await self._request(
|
|
241
|
+
"POST",
|
|
242
|
+
"scan",
|
|
243
|
+
"Failed to scan content",
|
|
244
|
+
json=compact(evidence=payload, author=author, top_k=top_k, threshold=threshold),
|
|
245
|
+
)
|
|
246
|
+
return ScannedContent.model_validate(data)
|
|
247
|
+
|
|
248
|
+
async def list_audit_logs(
|
|
249
|
+
self,
|
|
250
|
+
page: int = 1,
|
|
251
|
+
limit: int = 100,
|
|
252
|
+
category: str | None = None,
|
|
253
|
+
by: str | None = None,
|
|
254
|
+
order: str | None = None,
|
|
255
|
+
) -> list[AuditLog]:
|
|
256
|
+
"""Lists audit log entries."""
|
|
257
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
258
|
+
data = await self._request(
|
|
259
|
+
"GET", "", f"Failed to list audit logs, page: {page}, limit: {limit}", params=params
|
|
260
|
+
)
|
|
261
|
+
return parse_list(AuditLog, data)
|
|
262
|
+
|
|
263
|
+
async def get_audit_log_record(self, audit_log_uuid: str) -> AuditLog:
|
|
264
|
+
"""Returns an audit log entry by its UUID."""
|
|
265
|
+
require(audit_log_uuid, "Audit log UUID")
|
|
266
|
+
data = await self._request(
|
|
267
|
+
"GET",
|
|
268
|
+
f"audit/{path_segment(audit_log_uuid)}",
|
|
269
|
+
f"Failed to get audit log record for UUID {audit_log_uuid}",
|
|
270
|
+
)
|
|
271
|
+
return AuditLog.model_validate(data)
|
|
272
|
+
|
|
273
|
+
async def search_audit_logs(
|
|
274
|
+
self,
|
|
275
|
+
query: str,
|
|
276
|
+
page: int = 1,
|
|
277
|
+
limit: int = 100,
|
|
278
|
+
category: str | None = None,
|
|
279
|
+
by: str | None = None,
|
|
280
|
+
order: str | None = None,
|
|
281
|
+
) -> list[AuditLog]:
|
|
282
|
+
"""Searches audit log entries."""
|
|
283
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
284
|
+
data = await self._request(
|
|
285
|
+
"GET", "audit/search", f"Failed to search audit logs, query: {query}", params=params
|
|
286
|
+
)
|
|
287
|
+
return parse_list(AuditLog, data)
|
|
288
|
+
|
|
289
|
+
async def create_operator(self, operator_name: str) -> OperatorCreated:
|
|
290
|
+
"""Creates an operator, returning its UUID and access token."""
|
|
291
|
+
require(operator_name, "Operator name")
|
|
292
|
+
data = await self._request(
|
|
293
|
+
"POST",
|
|
294
|
+
"operators",
|
|
295
|
+
f"Failed to create operator with name {operator_name}",
|
|
296
|
+
json={"name": operator_name},
|
|
297
|
+
)
|
|
298
|
+
return OperatorCreated.model_validate(data)
|
|
299
|
+
|
|
300
|
+
async def delete_operator(self, operator_uuid: str) -> None:
|
|
301
|
+
"""Deletes an operator."""
|
|
302
|
+
require(operator_uuid, "Operator UUID")
|
|
303
|
+
await self._request(
|
|
304
|
+
"DELETE",
|
|
305
|
+
f"operators/{path_segment(operator_uuid)}",
|
|
306
|
+
f"Failed to delete operator with UUID {operator_uuid}",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def disable_operator(self, operator_uuid: str) -> None:
|
|
310
|
+
"""Disables an operator, preventing it from authenticating."""
|
|
311
|
+
require(operator_uuid, "Operator UUID")
|
|
312
|
+
await self._request(
|
|
313
|
+
"PATCH",
|
|
314
|
+
f"operators/{path_segment(operator_uuid)}/disable",
|
|
315
|
+
f"Failed to disable operator with UUID {operator_uuid}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
async def enable_operator(self, operator_uuid: str) -> None:
|
|
319
|
+
"""Enables a disabled operator."""
|
|
320
|
+
require(operator_uuid, "Operator UUID")
|
|
321
|
+
await self._request(
|
|
322
|
+
"PATCH",
|
|
323
|
+
f"operators/{path_segment(operator_uuid)}/enable",
|
|
324
|
+
f"Failed to enable operator with UUID {operator_uuid}",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def get_operator(self, operator_uuid: str) -> OperatorRecord:
|
|
328
|
+
"""Returns an operator by its UUID."""
|
|
329
|
+
require(operator_uuid, "Operator UUID")
|
|
330
|
+
data = await self._request(
|
|
331
|
+
"GET", f"operators/{path_segment(operator_uuid)}", "Failed to get operator"
|
|
332
|
+
)
|
|
333
|
+
return OperatorRecord.model_validate(data)
|
|
334
|
+
|
|
335
|
+
async def get_self(self) -> OperatorRecord:
|
|
336
|
+
"""Returns the operator the client is authenticated as."""
|
|
337
|
+
data = await self._request("GET", "operators/self", "Failed to get self operator")
|
|
338
|
+
return OperatorRecord.model_validate(data)
|
|
339
|
+
|
|
340
|
+
async def list_operators(
|
|
341
|
+
self,
|
|
342
|
+
page: int = 1,
|
|
343
|
+
limit: int = 100,
|
|
344
|
+
category: str | None = None,
|
|
345
|
+
by: str | None = None,
|
|
346
|
+
order: str | None = None,
|
|
347
|
+
) -> list[OperatorRecord]:
|
|
348
|
+
"""Lists operators."""
|
|
349
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
350
|
+
data = await self._request(
|
|
351
|
+
"GET",
|
|
352
|
+
"operators",
|
|
353
|
+
f"Failed to list operators, page: {page}, limit: {limit}",
|
|
354
|
+
params=params,
|
|
355
|
+
)
|
|
356
|
+
return parse_list(OperatorRecord, data)
|
|
357
|
+
|
|
358
|
+
async def list_operator_audit_logs(
|
|
359
|
+
self,
|
|
360
|
+
operator_uuid: str,
|
|
361
|
+
page: int = 1,
|
|
362
|
+
limit: int = 100,
|
|
363
|
+
category: str | None = None,
|
|
364
|
+
by: str | None = None,
|
|
365
|
+
order: str | None = None,
|
|
366
|
+
) -> list[AuditLog]:
|
|
367
|
+
"""Lists the audit log entries of an operator."""
|
|
368
|
+
require(operator_uuid, "Operator UUID")
|
|
369
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
370
|
+
data = await self._request(
|
|
371
|
+
"GET",
|
|
372
|
+
f"operators/{path_segment(operator_uuid)}/audit",
|
|
373
|
+
f"Failed to list audit logs for operator with UUID {operator_uuid}",
|
|
374
|
+
params=params,
|
|
375
|
+
)
|
|
376
|
+
return parse_list(AuditLog, data)
|
|
377
|
+
|
|
378
|
+
async def list_operator_evidence(
|
|
379
|
+
self,
|
|
380
|
+
operator_uuid: str,
|
|
381
|
+
page: int = 1,
|
|
382
|
+
limit: int = 100,
|
|
383
|
+
include_confidential: bool = False,
|
|
384
|
+
by: str | None = None,
|
|
385
|
+
order: str | None = None,
|
|
386
|
+
) -> list[EvidenceRecord]:
|
|
387
|
+
"""Lists the evidence records submitted by an operator."""
|
|
388
|
+
require(operator_uuid, "Operator UUID")
|
|
389
|
+
params = page_params(
|
|
390
|
+
page, limit, include_confidential=include_confidential, by=by, order=order
|
|
391
|
+
)
|
|
392
|
+
data = await self._request(
|
|
393
|
+
"GET",
|
|
394
|
+
f"operators/{path_segment(operator_uuid)}/evidence",
|
|
395
|
+
f"Failed to list evidence records for operator with UUID {operator_uuid}",
|
|
396
|
+
params=params,
|
|
397
|
+
)
|
|
398
|
+
return parse_list(EvidenceRecord, data)
|
|
399
|
+
|
|
400
|
+
async def list_operator_blacklist(
|
|
401
|
+
self,
|
|
402
|
+
operator_uuid: str,
|
|
403
|
+
page: int = 1,
|
|
404
|
+
limit: int = 100,
|
|
405
|
+
include_lifted: bool = False,
|
|
406
|
+
by: str | None = None,
|
|
407
|
+
order: str | None = None,
|
|
408
|
+
) -> list[BlacklistRecord]:
|
|
409
|
+
"""Lists the blacklist records created by an operator."""
|
|
410
|
+
require(operator_uuid, "Operator UUID")
|
|
411
|
+
params = page_params(page, limit, include_lifted=include_lifted, by=by, order=order)
|
|
412
|
+
data = await self._request(
|
|
413
|
+
"GET",
|
|
414
|
+
f"operators/{path_segment(operator_uuid)}/blacklist",
|
|
415
|
+
f"Failed to list operator blacklist records with UUID {operator_uuid}",
|
|
416
|
+
params=params,
|
|
417
|
+
)
|
|
418
|
+
return parse_list(BlacklistRecord, data)
|
|
419
|
+
|
|
420
|
+
async def set_operator_permissions(
|
|
421
|
+
self, operator_uuid: str, has_operator_permissions: bool
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Grants or revokes the permission to manage other operators."""
|
|
424
|
+
require(operator_uuid, "Operator UUID")
|
|
425
|
+
action = "enable" if has_operator_permissions else "disable"
|
|
426
|
+
await self._request(
|
|
427
|
+
"PATCH",
|
|
428
|
+
f"operators/{path_segment(operator_uuid)}/operator-permissions",
|
|
429
|
+
f"Failed to {action} the operator's operator permissions",
|
|
430
|
+
json={"enabled": has_operator_permissions},
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def set_client_permissions(
|
|
434
|
+
self, operator_uuid: str, has_client_permissions: bool
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Grants or revokes the permission to submit data as a client."""
|
|
437
|
+
require(operator_uuid, "Operator UUID")
|
|
438
|
+
action = "enable" if has_client_permissions else "disable"
|
|
439
|
+
await self._request(
|
|
440
|
+
"PATCH",
|
|
441
|
+
f"operators/{path_segment(operator_uuid)}/client-permissions",
|
|
442
|
+
f"Failed to {action} the operator's client permissions",
|
|
443
|
+
json={"enabled": has_client_permissions},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def set_management_permissions(
|
|
447
|
+
self, operator_uuid: str, has_management_permissions: bool
|
|
448
|
+
) -> None:
|
|
449
|
+
"""Grants or revokes the permission to manage the database."""
|
|
450
|
+
require(operator_uuid, "Operator UUID")
|
|
451
|
+
action = "enable" if has_management_permissions else "disable"
|
|
452
|
+
await self._request(
|
|
453
|
+
"PATCH",
|
|
454
|
+
f"operators/{path_segment(operator_uuid)}/management-permissions",
|
|
455
|
+
f"Failed to {action} the operator's management permissions",
|
|
456
|
+
json={"enabled": has_management_permissions},
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
async def set_auto_assign(self, operator_uuid: str, auto_assign: bool) -> None:
|
|
460
|
+
"""Sets whether new reports are automatically assigned to an operator."""
|
|
461
|
+
require(operator_uuid, "Operator UUID")
|
|
462
|
+
action = "enable" if auto_assign else "disable"
|
|
463
|
+
await self._request(
|
|
464
|
+
"PATCH",
|
|
465
|
+
f"operators/{path_segment(operator_uuid)}/auto-assign",
|
|
466
|
+
f"Failed to {action} the operator's auto assign",
|
|
467
|
+
json={"enabled": auto_assign},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def generate_access_token(self, update: bool = True) -> str:
|
|
471
|
+
"""Generates a new access token for the authenticated operator, revoking the current one.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
update: Whether the client should use the new access token from now on.
|
|
475
|
+
"""
|
|
476
|
+
access_token: str = await self._request(
|
|
477
|
+
"POST", "operators/refresh", "Failed to generate access token"
|
|
478
|
+
)
|
|
479
|
+
if update:
|
|
480
|
+
self.access_token = access_token
|
|
481
|
+
|
|
482
|
+
return access_token
|
|
483
|
+
|
|
484
|
+
async def generate_operator_access_token(self, operator_uuid: str) -> str:
|
|
485
|
+
"""Generates a new access token for an operator, revoking its current one."""
|
|
486
|
+
require(operator_uuid, "Operator UUID")
|
|
487
|
+
access_token: str = await self._request(
|
|
488
|
+
"POST",
|
|
489
|
+
f"operators/{path_segment(operator_uuid)}/refresh",
|
|
490
|
+
f"Failed to generate access token for operator with UUID {operator_uuid}",
|
|
491
|
+
)
|
|
492
|
+
return access_token
|
|
493
|
+
|
|
494
|
+
async def search_operators(
|
|
495
|
+
self,
|
|
496
|
+
query: str,
|
|
497
|
+
page: int = 1,
|
|
498
|
+
limit: int = 100,
|
|
499
|
+
category: str | None = None,
|
|
500
|
+
by: str | None = None,
|
|
501
|
+
order: str | None = None,
|
|
502
|
+
) -> list[OperatorRecord]:
|
|
503
|
+
"""Searches operators."""
|
|
504
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
505
|
+
data = await self._request(
|
|
506
|
+
"GET", "operators/search", f"Failed to search operators, query: {query}", params=params
|
|
507
|
+
)
|
|
508
|
+
return parse_list(OperatorRecord, data)
|
|
509
|
+
|
|
510
|
+
async def update_operator_name(self, operator_uuid: str, new_name: str) -> None:
|
|
511
|
+
"""Renames an operator."""
|
|
512
|
+
require(operator_uuid, "Operator UUID")
|
|
513
|
+
require(new_name, "Operator name")
|
|
514
|
+
await self._request(
|
|
515
|
+
"PATCH",
|
|
516
|
+
f"operators/{path_segment(operator_uuid)}/update-name",
|
|
517
|
+
f"Failed to update name for operator with UUID {operator_uuid}",
|
|
518
|
+
json={"name": new_name},
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
async def list_operator_reports(
|
|
522
|
+
self,
|
|
523
|
+
operator_uuid: str,
|
|
524
|
+
page: int = 1,
|
|
525
|
+
limit: int = 100,
|
|
526
|
+
category: str | None = None,
|
|
527
|
+
by: str | None = None,
|
|
528
|
+
order: str | None = None,
|
|
529
|
+
) -> list[ReportRecord]:
|
|
530
|
+
"""Lists the reports submitted by an operator."""
|
|
531
|
+
require(operator_uuid, "Operator UUID")
|
|
532
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
533
|
+
data = await self._request(
|
|
534
|
+
"GET",
|
|
535
|
+
f"operators/{path_segment(operator_uuid)}/reports",
|
|
536
|
+
f"Failed to list reports for operator {operator_uuid}",
|
|
537
|
+
params=params,
|
|
538
|
+
)
|
|
539
|
+
return parse_list(ReportRecord, data)
|
|
540
|
+
|
|
541
|
+
async def list_assigned_operator_reports(
|
|
542
|
+
self,
|
|
543
|
+
operator_uuid: str,
|
|
544
|
+
page: int = 1,
|
|
545
|
+
limit: int = 100,
|
|
546
|
+
category: str | None = None,
|
|
547
|
+
by: str | None = None,
|
|
548
|
+
order: str | None = None,
|
|
549
|
+
) -> list[ReportRecord]:
|
|
550
|
+
"""Lists the reports assigned to an operator."""
|
|
551
|
+
require(operator_uuid, "Operator UUID")
|
|
552
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
553
|
+
data = await self._request(
|
|
554
|
+
"GET",
|
|
555
|
+
f"operators/{path_segment(operator_uuid)}/reports/assigned",
|
|
556
|
+
f"Failed to list assigned reports for operator {operator_uuid}",
|
|
557
|
+
params=params,
|
|
558
|
+
)
|
|
559
|
+
return parse_list(ReportRecord, data)
|
|
560
|
+
|
|
561
|
+
async def delete_entity(self, entity_identifier: str) -> None:
|
|
562
|
+
"""Deletes an entity."""
|
|
563
|
+
require(entity_identifier, "Entity identifier")
|
|
564
|
+
await self._request(
|
|
565
|
+
"DELETE",
|
|
566
|
+
entity_path(entity_identifier),
|
|
567
|
+
f"Failed to delete the entity {entity_identifier}",
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
async def get_entity_record(self, entity_identifier: str) -> EntityRecord:
|
|
571
|
+
"""Returns an entity."""
|
|
572
|
+
require(entity_identifier, "Entity identifier")
|
|
573
|
+
data = await self._request(
|
|
574
|
+
"GET",
|
|
575
|
+
entity_path(entity_identifier),
|
|
576
|
+
f"Failed to get the entity record for {entity_identifier}",
|
|
577
|
+
)
|
|
578
|
+
return EntityRecord.model_validate(data)
|
|
579
|
+
|
|
580
|
+
async def query_entity(self, entity_identifier: str) -> EntityQueryResult:
|
|
581
|
+
"""Returns an entity with its relationship group, active blacklists and suggested action."""
|
|
582
|
+
require(entity_identifier, "Entity identifier")
|
|
583
|
+
data = await self._request(
|
|
584
|
+
"GET",
|
|
585
|
+
f"{entity_path(entity_identifier)}/query",
|
|
586
|
+
f"Failed to query entity {entity_identifier}",
|
|
587
|
+
)
|
|
588
|
+
return EntityQueryResult.model_validate(data)
|
|
589
|
+
|
|
590
|
+
async def list_entities(
|
|
591
|
+
self,
|
|
592
|
+
page: int = 1,
|
|
593
|
+
limit: int = 100,
|
|
594
|
+
category: str | None = None,
|
|
595
|
+
by: str | None = None,
|
|
596
|
+
order: str | None = None,
|
|
597
|
+
) -> list[EntityRecord]:
|
|
598
|
+
"""Lists entities."""
|
|
599
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
600
|
+
data = await self._request(
|
|
601
|
+
"GET",
|
|
602
|
+
"entities",
|
|
603
|
+
f"Failed to list entities, page: {page}, limit: {limit}",
|
|
604
|
+
params=params,
|
|
605
|
+
)
|
|
606
|
+
return parse_list(EntityRecord, data)
|
|
607
|
+
|
|
608
|
+
async def get_top_threats(self, limit: int = 10) -> list[EntityRecord]:
|
|
609
|
+
"""Returns the entities with the lowest reputation."""
|
|
610
|
+
if limit < 1:
|
|
611
|
+
raise ValueError("Limit must be greater than 0")
|
|
612
|
+
|
|
613
|
+
data = await self._request(
|
|
614
|
+
"GET",
|
|
615
|
+
"entities/top-threats",
|
|
616
|
+
f"Failed to retrieve top threats, limit: {limit}",
|
|
617
|
+
params={"limit": limit},
|
|
618
|
+
)
|
|
619
|
+
return parse_list(EntityRecord, data)
|
|
620
|
+
|
|
621
|
+
async def list_entity_audit_logs(
|
|
622
|
+
self,
|
|
623
|
+
entity_identifier: str,
|
|
624
|
+
page: int = 1,
|
|
625
|
+
limit: int = 100,
|
|
626
|
+
category: str | None = None,
|
|
627
|
+
by: str | None = None,
|
|
628
|
+
order: str | None = None,
|
|
629
|
+
) -> list[AuditLog]:
|
|
630
|
+
"""Lists the audit log entries of an entity."""
|
|
631
|
+
require(entity_identifier, "Entity identifier")
|
|
632
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
633
|
+
data = await self._request(
|
|
634
|
+
"GET",
|
|
635
|
+
f"{entity_path(entity_identifier)}/audit",
|
|
636
|
+
f"Failed to list audit logs for entity {entity_identifier}",
|
|
637
|
+
params=params,
|
|
638
|
+
)
|
|
639
|
+
return parse_list(AuditLog, data)
|
|
640
|
+
|
|
641
|
+
async def list_entity_blacklist_records(
|
|
642
|
+
self,
|
|
643
|
+
entity_identifier: str,
|
|
644
|
+
page: int = 1,
|
|
645
|
+
limit: int = 100,
|
|
646
|
+
include_lifted: bool = False,
|
|
647
|
+
by: str | None = None,
|
|
648
|
+
order: str | None = None,
|
|
649
|
+
) -> list[BlacklistRecord]:
|
|
650
|
+
"""Lists the blacklist records of an entity."""
|
|
651
|
+
require(entity_identifier, "Entity identifier")
|
|
652
|
+
params = page_params(page, limit, include_lifted=include_lifted, by=by, order=order)
|
|
653
|
+
data = await self._request(
|
|
654
|
+
"GET",
|
|
655
|
+
f"{entity_path(entity_identifier)}/blacklist",
|
|
656
|
+
f"Failed to list blacklist records for entity {entity_identifier}",
|
|
657
|
+
params=params,
|
|
658
|
+
)
|
|
659
|
+
return parse_list(BlacklistRecord, data)
|
|
660
|
+
|
|
661
|
+
async def list_entity_evidence_records(
|
|
662
|
+
self,
|
|
663
|
+
entity_identifier: str,
|
|
664
|
+
page: int = 1,
|
|
665
|
+
limit: int = 100,
|
|
666
|
+
include_confidential: bool = False,
|
|
667
|
+
by: str | None = None,
|
|
668
|
+
order: str | None = None,
|
|
669
|
+
) -> list[EvidenceRecord]:
|
|
670
|
+
"""Lists the evidence records of an entity."""
|
|
671
|
+
require(entity_identifier, "Entity identifier")
|
|
672
|
+
params = page_params(
|
|
673
|
+
page, limit, include_confidential=include_confidential, by=by, order=order
|
|
674
|
+
)
|
|
675
|
+
data = await self._request(
|
|
676
|
+
"GET",
|
|
677
|
+
f"{entity_path(entity_identifier)}/evidence",
|
|
678
|
+
f"Failed to list evidence records for entity {entity_identifier}",
|
|
679
|
+
params=params,
|
|
680
|
+
)
|
|
681
|
+
return parse_list(EvidenceRecord, data)
|
|
682
|
+
|
|
683
|
+
async def list_entity_reports(
|
|
684
|
+
self,
|
|
685
|
+
entity_identifier: str,
|
|
686
|
+
page: int = 1,
|
|
687
|
+
limit: int = 100,
|
|
688
|
+
category: str | None = None,
|
|
689
|
+
by: str | None = None,
|
|
690
|
+
order: str | None = None,
|
|
691
|
+
) -> list[ReportRecord]:
|
|
692
|
+
"""Lists the reports made against an entity."""
|
|
693
|
+
require(entity_identifier, "Entity identifier")
|
|
694
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
695
|
+
data = await self._request(
|
|
696
|
+
"GET",
|
|
697
|
+
f"{entity_path(entity_identifier)}/reports",
|
|
698
|
+
f"Failed to list reports for entity {entity_identifier}",
|
|
699
|
+
params=params,
|
|
700
|
+
)
|
|
701
|
+
return parse_list(ReportRecord, data)
|
|
702
|
+
|
|
703
|
+
async def push_entity(
|
|
704
|
+
self, host: str, id: str | None = None, metadata: dict[str, Any] | None = None
|
|
705
|
+
) -> str:
|
|
706
|
+
"""Registers an entity, returning its UUID. Pushing a known entity returns its UUID.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
host: The domain or IP address of the entity.
|
|
710
|
+
id: The local part of the entity address, such as the username of an email address.
|
|
711
|
+
metadata: Arbitrary metadata to associate with the entity.
|
|
712
|
+
"""
|
|
713
|
+
require(host, "Host")
|
|
714
|
+
if id is not None:
|
|
715
|
+
require(id, "Entity ID")
|
|
716
|
+
|
|
717
|
+
entity_uuid: str = await self._request(
|
|
718
|
+
"POST",
|
|
719
|
+
"entities",
|
|
720
|
+
f"Failed to push entity with domain {host}",
|
|
721
|
+
json=compact(host=host, id=id, metadata=metadata),
|
|
722
|
+
)
|
|
723
|
+
return entity_uuid
|
|
724
|
+
|
|
725
|
+
async def update_entity(self, entity_identifier: str, metadata: dict[str, Any]) -> None:
|
|
726
|
+
"""Replaces the metadata of an entity."""
|
|
727
|
+
require(entity_identifier, "Entity identifier")
|
|
728
|
+
await self._request(
|
|
729
|
+
"PATCH",
|
|
730
|
+
entity_path(entity_identifier),
|
|
731
|
+
f"Failed to update entity {entity_identifier}",
|
|
732
|
+
json={"metadata": metadata},
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
async def set_entity_whitelist(self, entity_identifier: str, whitelisted: bool) -> None:
|
|
736
|
+
"""Sets whether an entity is whitelisted."""
|
|
737
|
+
require(entity_identifier, "Entity identifier")
|
|
738
|
+
await self._request(
|
|
739
|
+
"PATCH",
|
|
740
|
+
f"{entity_path(entity_identifier)}/whitelist",
|
|
741
|
+
f"Failed to set whitelist state for entity {entity_identifier}",
|
|
742
|
+
json={"whitelisted": whitelisted},
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
async def clear_entity_reputation(self, entity_identifier: str) -> None:
|
|
746
|
+
"""Resets the reputation of an entity."""
|
|
747
|
+
require(entity_identifier, "Entity identifier")
|
|
748
|
+
await self._request(
|
|
749
|
+
"PATCH",
|
|
750
|
+
f"{entity_path(entity_identifier)}/clear-reputation",
|
|
751
|
+
f"Failed to clear reputation for entity {entity_identifier}",
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
async def set_entity_relationship(
|
|
755
|
+
self,
|
|
756
|
+
entity_identifier: str,
|
|
757
|
+
related_entity_identifier: str,
|
|
758
|
+
relationship_type: EntityRelationshipType,
|
|
759
|
+
) -> None:
|
|
760
|
+
"""Relates an entity to another entity, given by its UUID, SHA-256 hash or address."""
|
|
761
|
+
require(entity_identifier, "Entity identifier")
|
|
762
|
+
require(related_entity_identifier, "Related entity identifier")
|
|
763
|
+
await self._request(
|
|
764
|
+
"PATCH",
|
|
765
|
+
f"{entity_path(entity_identifier)}/relationship",
|
|
766
|
+
f"Failed to set relationship for entity {entity_identifier}",
|
|
767
|
+
json={
|
|
768
|
+
"target_identifier": related_entity_identifier,
|
|
769
|
+
"relationship_type": relationship_type,
|
|
770
|
+
},
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
async def clear_entity_relationship(self, entity_identifier: str) -> None:
|
|
774
|
+
"""Removes the relationship of an entity."""
|
|
775
|
+
require(entity_identifier, "Entity identifier")
|
|
776
|
+
await self._request(
|
|
777
|
+
"DELETE",
|
|
778
|
+
f"{entity_path(entity_identifier)}/relationship",
|
|
779
|
+
f"Failed to clear relationship for entity {entity_identifier}",
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
async def search_entities(
|
|
783
|
+
self,
|
|
784
|
+
query: str,
|
|
785
|
+
page: int = 1,
|
|
786
|
+
limit: int = 100,
|
|
787
|
+
category: str | None = None,
|
|
788
|
+
by: str | None = None,
|
|
789
|
+
order: str | None = None,
|
|
790
|
+
) -> list[EntityRecord]:
|
|
791
|
+
"""Searches entities."""
|
|
792
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
793
|
+
data = await self._request(
|
|
794
|
+
"GET", "entities/search", f"Failed to search entities, query: {query}", params=params
|
|
795
|
+
)
|
|
796
|
+
return parse_list(EntityRecord, data)
|
|
797
|
+
|
|
798
|
+
async def delete_evidence(self, evidence_uuid: str) -> None:
|
|
799
|
+
"""Deletes an evidence record."""
|
|
800
|
+
require(evidence_uuid, "Evidence UUID")
|
|
801
|
+
await self._request(
|
|
802
|
+
"DELETE",
|
|
803
|
+
f"evidence/{path_segment(evidence_uuid)}",
|
|
804
|
+
f"Failed to delete evidence with UUID {evidence_uuid}",
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
async def get_evidence_record(self, evidence_uuid: str) -> EvidenceRecord:
|
|
808
|
+
"""Returns an evidence record by its UUID."""
|
|
809
|
+
require(evidence_uuid, "Evidence UUID")
|
|
810
|
+
data = await self._request(
|
|
811
|
+
"GET",
|
|
812
|
+
f"evidence/{path_segment(evidence_uuid)}",
|
|
813
|
+
f"Failed to get evidence record with UUID {evidence_uuid}",
|
|
814
|
+
)
|
|
815
|
+
return EvidenceRecord.model_validate(data)
|
|
816
|
+
|
|
817
|
+
async def get_evidence_attachments(self, evidence_uuid: str) -> list[FileAttachmentRecord]:
|
|
818
|
+
"""Returns the file attachments of an evidence record."""
|
|
819
|
+
require(evidence_uuid, "Evidence UUID")
|
|
820
|
+
data = await self._request(
|
|
821
|
+
"GET",
|
|
822
|
+
f"evidence/{path_segment(evidence_uuid)}/attachments",
|
|
823
|
+
f"Failed to get evidence attachments for evidence with UUID {evidence_uuid}",
|
|
824
|
+
)
|
|
825
|
+
return parse_list(FileAttachmentRecord, data)
|
|
826
|
+
|
|
827
|
+
async def list_evidence(
|
|
828
|
+
self,
|
|
829
|
+
page: int = 1,
|
|
830
|
+
limit: int = 100,
|
|
831
|
+
include_confidential: bool = False,
|
|
832
|
+
category: str | None = None,
|
|
833
|
+
by: str | None = None,
|
|
834
|
+
order: str | None = None,
|
|
835
|
+
) -> list[EvidenceRecord]:
|
|
836
|
+
"""Lists evidence records."""
|
|
837
|
+
params = page_params(
|
|
838
|
+
page,
|
|
839
|
+
limit,
|
|
840
|
+
include_confidential=include_confidential,
|
|
841
|
+
category=category,
|
|
842
|
+
by=by,
|
|
843
|
+
order=order,
|
|
844
|
+
)
|
|
845
|
+
data = await self._request(
|
|
846
|
+
"GET",
|
|
847
|
+
"evidence",
|
|
848
|
+
f"Failed to list evidence records, page: {page}, limit: {limit}",
|
|
849
|
+
params=params,
|
|
850
|
+
)
|
|
851
|
+
return parse_list(EvidenceRecord, data)
|
|
852
|
+
|
|
853
|
+
async def submit_evidence(
|
|
854
|
+
self,
|
|
855
|
+
entity_identifier: str,
|
|
856
|
+
text_content: str | None = None,
|
|
857
|
+
note: str | None = None,
|
|
858
|
+
tag: str | None = None,
|
|
859
|
+
confidential: bool = False,
|
|
860
|
+
metadata: dict[str, Any] | None = None,
|
|
861
|
+
classification: ClassificationFlag | None = None,
|
|
862
|
+
) -> str:
|
|
863
|
+
"""Submits evidence about an entity, returning the UUID of the evidence record.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
entity_identifier: The entity the evidence is about.
|
|
867
|
+
text_content: The observed text content.
|
|
868
|
+
note: A note by the operator.
|
|
869
|
+
tag: A tag to categorize the evidence with.
|
|
870
|
+
confidential: Whether the evidence is hidden from the public.
|
|
871
|
+
metadata: Arbitrary metadata to associate with the evidence.
|
|
872
|
+
classification: An immutable classification of the evidence, which requires management
|
|
873
|
+
permissions and trains the server's classifier with the text content.
|
|
874
|
+
"""
|
|
875
|
+
require(entity_identifier, "Entity identifier")
|
|
876
|
+
payload = compact(
|
|
877
|
+
entity_identifier=entity_identifier,
|
|
878
|
+
text_content=text_content,
|
|
879
|
+
note=note,
|
|
880
|
+
tag=tag,
|
|
881
|
+
confidential=confidential,
|
|
882
|
+
metadata=metadata,
|
|
883
|
+
classification=classification,
|
|
884
|
+
)
|
|
885
|
+
evidence_uuid: str = await self._request(
|
|
886
|
+
"POST",
|
|
887
|
+
"evidence",
|
|
888
|
+
f"Failed to submit evidence for entity {entity_identifier}",
|
|
889
|
+
json=payload,
|
|
890
|
+
)
|
|
891
|
+
return evidence_uuid
|
|
892
|
+
|
|
893
|
+
async def update_evidence_confidentiality(self, evidence_uuid: str, confidential: bool) -> None:
|
|
894
|
+
"""Sets whether an evidence record is hidden from the public."""
|
|
895
|
+
require(evidence_uuid, "Evidence UUID")
|
|
896
|
+
action = "set" if confidential else "unset"
|
|
897
|
+
await self._request(
|
|
898
|
+
"PATCH",
|
|
899
|
+
f"evidence/{path_segment(evidence_uuid)}/update-confidentiality",
|
|
900
|
+
f"Failed to {action} confidentiality for evidence with UUID {evidence_uuid}",
|
|
901
|
+
json={"confidential": confidential},
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
async def update_evidence_tag(self, evidence_uuid: str, tag: str) -> None:
|
|
905
|
+
"""Sets the tag of an evidence record."""
|
|
906
|
+
require(evidence_uuid, "Evidence UUID")
|
|
907
|
+
require(tag, "Tag")
|
|
908
|
+
await self._request(
|
|
909
|
+
"PATCH",
|
|
910
|
+
f"evidence/{path_segment(evidence_uuid)}/update-tag",
|
|
911
|
+
f"Failed to update tag for evidence with UUID {evidence_uuid}",
|
|
912
|
+
json={"tag": tag},
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
async def classify_evidence(
|
|
916
|
+
self, evidence_uuid: str, classification: ClassificationFlag
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Classifies an evidence record, training the server's classifier with its content."""
|
|
919
|
+
require(evidence_uuid, "Evidence UUID")
|
|
920
|
+
await self._request(
|
|
921
|
+
"PATCH",
|
|
922
|
+
f"evidence/{path_segment(evidence_uuid)}/classify",
|
|
923
|
+
f"Failed to classify evidence with UUID {evidence_uuid}",
|
|
924
|
+
json={"classification_flag": classification},
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
async def search_evidence(
|
|
928
|
+
self,
|
|
929
|
+
query: str,
|
|
930
|
+
page: int = 1,
|
|
931
|
+
limit: int = 100,
|
|
932
|
+
category: str | None = None,
|
|
933
|
+
by: str | None = None,
|
|
934
|
+
order: str | None = None,
|
|
935
|
+
) -> list[EvidenceRecord]:
|
|
936
|
+
"""Searches evidence records."""
|
|
937
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
938
|
+
data = await self._request(
|
|
939
|
+
"GET", "evidence/search", f"Failed to search evidence, query: {query}", params=params
|
|
940
|
+
)
|
|
941
|
+
return parse_list(EvidenceRecord, data)
|
|
942
|
+
|
|
943
|
+
async def add_evidence_to_report(self, evidence_uuid: str, report_uuid: str) -> None:
|
|
944
|
+
"""Links an evidence record to a report."""
|
|
945
|
+
require(evidence_uuid, "Evidence UUID")
|
|
946
|
+
require(report_uuid, "Report UUID")
|
|
947
|
+
await self._request(
|
|
948
|
+
"PATCH",
|
|
949
|
+
f"evidence/{path_segment(evidence_uuid)}/link-report",
|
|
950
|
+
f"Failed to link evidence {evidence_uuid} to report {report_uuid}",
|
|
951
|
+
json={"report_uuid": report_uuid},
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
async def delete_report(self, report_uuid: str) -> None:
|
|
955
|
+
"""Deletes a report."""
|
|
956
|
+
require(report_uuid, "Report UUID")
|
|
957
|
+
await self._request(
|
|
958
|
+
"DELETE",
|
|
959
|
+
f"reports/{path_segment(report_uuid)}",
|
|
960
|
+
f"Failed to delete report {report_uuid}",
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
async def submit_report(
|
|
964
|
+
self,
|
|
965
|
+
reporting_entity: str,
|
|
966
|
+
evidence: str | ContentInput | Sequence[ContentInput],
|
|
967
|
+
incident_type: IncidentType,
|
|
968
|
+
report_message: str | None = None,
|
|
969
|
+
) -> ReportSubmission:
|
|
970
|
+
"""Reports an entity, creating an evidence record for each piece of evidence.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
reporting_entity: The entity being reported.
|
|
974
|
+
evidence: The text, content input or content inputs documenting the incident.
|
|
975
|
+
incident_type: The type of the incident.
|
|
976
|
+
report_message: A description of the incident.
|
|
977
|
+
"""
|
|
978
|
+
require(reporting_entity, "Reporting entity identifier")
|
|
979
|
+
if isinstance(evidence, str):
|
|
980
|
+
require(evidence, "Content")
|
|
981
|
+
|
|
982
|
+
payload = compact(
|
|
983
|
+
reporting_entity=reporting_entity,
|
|
984
|
+
evidence=evidence_payload(evidence),
|
|
985
|
+
incident_type=incident_type,
|
|
986
|
+
report_message=report_message,
|
|
987
|
+
)
|
|
988
|
+
data = await self._request("POST", "reports", "Failed to submit report", json=payload)
|
|
989
|
+
return ReportSubmission.model_validate(data)
|
|
990
|
+
|
|
991
|
+
async def list_reports(
|
|
992
|
+
self,
|
|
993
|
+
page: int = 1,
|
|
994
|
+
limit: int = 100,
|
|
995
|
+
category: str | None = None,
|
|
996
|
+
by: str | None = None,
|
|
997
|
+
order: str | None = None,
|
|
998
|
+
) -> list[ReportRecord]:
|
|
999
|
+
"""Lists reports."""
|
|
1000
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
1001
|
+
data = await self._request(
|
|
1002
|
+
"GET",
|
|
1003
|
+
"reports",
|
|
1004
|
+
f"Failed to list reports, page: {page}, limit: {limit}",
|
|
1005
|
+
params=params,
|
|
1006
|
+
)
|
|
1007
|
+
return parse_list(ReportRecord, data)
|
|
1008
|
+
|
|
1009
|
+
async def list_opened_reports(
|
|
1010
|
+
self,
|
|
1011
|
+
page: int = 1,
|
|
1012
|
+
limit: int = 100,
|
|
1013
|
+
by: str | None = None,
|
|
1014
|
+
order: str | None = None,
|
|
1015
|
+
) -> list[ReportRecord]:
|
|
1016
|
+
"""Lists the opened reports assigned to the authenticated operator."""
|
|
1017
|
+
params = page_params(page, limit, by=by, order=order)
|
|
1018
|
+
data = await self._request(
|
|
1019
|
+
"GET",
|
|
1020
|
+
"reports/opened",
|
|
1021
|
+
f"Failed to list opened reports, page: {page}, limit: {limit}",
|
|
1022
|
+
params=params,
|
|
1023
|
+
)
|
|
1024
|
+
return parse_list(ReportRecord, data)
|
|
1025
|
+
|
|
1026
|
+
async def get_report(self, report_uuid: str) -> ReportRecord:
|
|
1027
|
+
"""Returns a report by its UUID."""
|
|
1028
|
+
require(report_uuid, "Report UUID")
|
|
1029
|
+
data = await self._request(
|
|
1030
|
+
"GET", f"reports/{path_segment(report_uuid)}", f"Failed to get report {report_uuid}"
|
|
1031
|
+
)
|
|
1032
|
+
return ReportRecord.model_validate(data)
|
|
1033
|
+
|
|
1034
|
+
async def list_report_evidence_records(
|
|
1035
|
+
self,
|
|
1036
|
+
report_uuid: str,
|
|
1037
|
+
page: int = 1,
|
|
1038
|
+
limit: int = 100,
|
|
1039
|
+
include_confidential: bool = False,
|
|
1040
|
+
category: str | None = None,
|
|
1041
|
+
by: str | None = None,
|
|
1042
|
+
order: str | None = None,
|
|
1043
|
+
) -> list[EvidenceRecord]:
|
|
1044
|
+
"""Lists the evidence records of a report."""
|
|
1045
|
+
require(report_uuid, "Report UUID")
|
|
1046
|
+
params = page_params(
|
|
1047
|
+
page,
|
|
1048
|
+
limit,
|
|
1049
|
+
include_confidential=include_confidential,
|
|
1050
|
+
category=category,
|
|
1051
|
+
by=by,
|
|
1052
|
+
order=order,
|
|
1053
|
+
)
|
|
1054
|
+
data = await self._request(
|
|
1055
|
+
"GET",
|
|
1056
|
+
f"reports/{path_segment(report_uuid)}/evidence",
|
|
1057
|
+
f"Failed to list evidence records for report {report_uuid}",
|
|
1058
|
+
params=params,
|
|
1059
|
+
)
|
|
1060
|
+
return parse_list(EvidenceRecord, data)
|
|
1061
|
+
|
|
1062
|
+
async def close_report(
|
|
1063
|
+
self,
|
|
1064
|
+
report_uuid: str,
|
|
1065
|
+
classification: ClassificationFlag | None = None,
|
|
1066
|
+
blacklist_incident_type: IncidentType | None = None,
|
|
1067
|
+
blacklist_expires: int | None = None,
|
|
1068
|
+
) -> None:
|
|
1069
|
+
"""Closes a report assigned to the authenticated operator.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
report_uuid: The report to close.
|
|
1073
|
+
classification: A classification to assign to the evidence records of the report.
|
|
1074
|
+
blacklist_incident_type: The incident type to blacklist the reported entity with, or
|
|
1075
|
+
None to close the report without blacklisting it.
|
|
1076
|
+
blacklist_expires: When the blacklist expires, or None for a permanent blacklist.
|
|
1077
|
+
"""
|
|
1078
|
+
require(report_uuid, "Report UUID")
|
|
1079
|
+
payload = compact(
|
|
1080
|
+
classification_flag=classification,
|
|
1081
|
+
blacklist_incident_type=blacklist_incident_type,
|
|
1082
|
+
blacklist_expires=blacklist_expires,
|
|
1083
|
+
)
|
|
1084
|
+
await self._request(
|
|
1085
|
+
"PATCH",
|
|
1086
|
+
f"reports/{path_segment(report_uuid)}/close",
|
|
1087
|
+
f"Failed to close report {report_uuid}",
|
|
1088
|
+
json=payload,
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
async def assign_operator_to_report(
|
|
1092
|
+
self, report_uuid: str, operator_uuid: str | None = None
|
|
1093
|
+
) -> None:
|
|
1094
|
+
"""Assigns a report to an operator, or to the authenticated operator if none is given."""
|
|
1095
|
+
require(report_uuid, "Report UUID")
|
|
1096
|
+
if operator_uuid is not None:
|
|
1097
|
+
require(operator_uuid, "Operator UUID")
|
|
1098
|
+
|
|
1099
|
+
await self._request(
|
|
1100
|
+
"PATCH",
|
|
1101
|
+
f"reports/{path_segment(report_uuid)}/assign",
|
|
1102
|
+
f"Failed to assign an operator to report {report_uuid}",
|
|
1103
|
+
json=compact(operator=operator_uuid),
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
async def search_reports(
|
|
1107
|
+
self,
|
|
1108
|
+
query: str,
|
|
1109
|
+
page: int = 1,
|
|
1110
|
+
limit: int = 100,
|
|
1111
|
+
category: str | None = None,
|
|
1112
|
+
by: str | None = None,
|
|
1113
|
+
order: str | None = None,
|
|
1114
|
+
) -> list[ReportRecord]:
|
|
1115
|
+
"""Searches reports."""
|
|
1116
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
1117
|
+
data = await self._request(
|
|
1118
|
+
"GET", "reports/search", f"Failed to search reports, query: {query}", params=params
|
|
1119
|
+
)
|
|
1120
|
+
return parse_list(ReportRecord, data)
|
|
1121
|
+
|
|
1122
|
+
async def blacklist_entity(
|
|
1123
|
+
self,
|
|
1124
|
+
entity_identifier: str,
|
|
1125
|
+
incident_type: IncidentType,
|
|
1126
|
+
report_uuid: str | None = None,
|
|
1127
|
+
expires: int | None = None,
|
|
1128
|
+
) -> str:
|
|
1129
|
+
"""Blacklists an entity, returning the UUID of the blacklist record.
|
|
1130
|
+
|
|
1131
|
+
Args:
|
|
1132
|
+
entity_identifier: The entity to blacklist.
|
|
1133
|
+
incident_type: The type of the incident the entity is blacklisted for.
|
|
1134
|
+
report_uuid: The report supporting the decision.
|
|
1135
|
+
expires: When the blacklist expires, or None for a permanent blacklist.
|
|
1136
|
+
"""
|
|
1137
|
+
require(entity_identifier, "Entity identifier")
|
|
1138
|
+
if expires is not None and expires < 0:
|
|
1139
|
+
raise ValueError("The expires parameter must be a positive integer or None")
|
|
1140
|
+
|
|
1141
|
+
payload = compact(
|
|
1142
|
+
entity_identifier=entity_identifier,
|
|
1143
|
+
type=incident_type,
|
|
1144
|
+
report_uuid=report_uuid,
|
|
1145
|
+
expires=expires,
|
|
1146
|
+
)
|
|
1147
|
+
blacklist_uuid: str = await self._request(
|
|
1148
|
+
"POST", "blacklist", f"Failed to blacklist entity {entity_identifier}", json=payload
|
|
1149
|
+
)
|
|
1150
|
+
return blacklist_uuid
|
|
1151
|
+
|
|
1152
|
+
async def delete_blacklist_record(self, blacklist_record_uuid: str) -> None:
|
|
1153
|
+
"""Deletes a blacklist record."""
|
|
1154
|
+
require(blacklist_record_uuid, "Blacklist record UUID")
|
|
1155
|
+
await self._request(
|
|
1156
|
+
"DELETE",
|
|
1157
|
+
f"blacklist/{path_segment(blacklist_record_uuid)}",
|
|
1158
|
+
f"Failed to delete blacklist record with UUID {blacklist_record_uuid}",
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
async def get_blacklist_record(self, blacklist_record_uuid: str) -> BlacklistRecord:
|
|
1162
|
+
"""Returns a blacklist record by its UUID."""
|
|
1163
|
+
require(blacklist_record_uuid, "Blacklist record UUID")
|
|
1164
|
+
data = await self._request(
|
|
1165
|
+
"GET",
|
|
1166
|
+
f"blacklist/{path_segment(blacklist_record_uuid)}",
|
|
1167
|
+
f"Failed to get blacklist record with UUID {blacklist_record_uuid}",
|
|
1168
|
+
)
|
|
1169
|
+
return BlacklistRecord.model_validate(data)
|
|
1170
|
+
|
|
1171
|
+
async def lift_blacklist_record(self, blacklist_record_uuid: str) -> None:
|
|
1172
|
+
"""Lifts a blacklist record, ending it before it expires."""
|
|
1173
|
+
require(blacklist_record_uuid, "Blacklist record UUID")
|
|
1174
|
+
await self._request(
|
|
1175
|
+
"PATCH",
|
|
1176
|
+
f"blacklist/{path_segment(blacklist_record_uuid)}/lift",
|
|
1177
|
+
f"Failed to lift blacklist record with UUID {blacklist_record_uuid}",
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
async def extend_blacklist_record(self, blacklist_record_uuid: str, seconds: int) -> None:
|
|
1181
|
+
"""Extends the expiry of a temporary blacklist record by a number of seconds."""
|
|
1182
|
+
require(blacklist_record_uuid, "Blacklist record UUID")
|
|
1183
|
+
if seconds <= 0:
|
|
1184
|
+
raise ValueError("Extension seconds must be positive")
|
|
1185
|
+
|
|
1186
|
+
await self._request(
|
|
1187
|
+
"PATCH",
|
|
1188
|
+
f"blacklist/{path_segment(blacklist_record_uuid)}/extend",
|
|
1189
|
+
f"Failed to extend blacklist record with UUID {blacklist_record_uuid}",
|
|
1190
|
+
json={"seconds": seconds},
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
async def list_blacklist_records(
|
|
1194
|
+
self,
|
|
1195
|
+
page: int = 1,
|
|
1196
|
+
limit: int = 100,
|
|
1197
|
+
include_lifted: bool = False,
|
|
1198
|
+
category: str | None = None,
|
|
1199
|
+
by: str | None = None,
|
|
1200
|
+
order: str | None = None,
|
|
1201
|
+
) -> list[BlacklistRecord]:
|
|
1202
|
+
"""Lists blacklist records."""
|
|
1203
|
+
params = page_params(
|
|
1204
|
+
page,
|
|
1205
|
+
limit,
|
|
1206
|
+
include_lifted=include_lifted,
|
|
1207
|
+
category=category,
|
|
1208
|
+
by=by,
|
|
1209
|
+
order=order,
|
|
1210
|
+
)
|
|
1211
|
+
data = await self._request(
|
|
1212
|
+
"GET",
|
|
1213
|
+
"blacklist",
|
|
1214
|
+
f"Failed to list blacklist records, page: {page}, limit: {limit}",
|
|
1215
|
+
params=params,
|
|
1216
|
+
)
|
|
1217
|
+
return parse_list(BlacklistRecord, data)
|
|
1218
|
+
|
|
1219
|
+
async def search_blacklist(
|
|
1220
|
+
self,
|
|
1221
|
+
query: str,
|
|
1222
|
+
page: int = 1,
|
|
1223
|
+
limit: int = 100,
|
|
1224
|
+
category: str | None = None,
|
|
1225
|
+
by: str | None = None,
|
|
1226
|
+
order: str | None = None,
|
|
1227
|
+
) -> list[BlacklistRecord]:
|
|
1228
|
+
"""Searches blacklist records."""
|
|
1229
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
1230
|
+
data = await self._request(
|
|
1231
|
+
"GET",
|
|
1232
|
+
"blacklist/search",
|
|
1233
|
+
f"Failed to search blacklist records, query: {query}",
|
|
1234
|
+
params=params,
|
|
1235
|
+
)
|
|
1236
|
+
return parse_list(BlacklistRecord, data)
|
|
1237
|
+
|
|
1238
|
+
async def get_attachment_info(self, attachment_uuid: str) -> FileAttachmentRecord:
|
|
1239
|
+
"""Returns the information of a file attachment."""
|
|
1240
|
+
require(attachment_uuid, "Attachment UUID")
|
|
1241
|
+
data = await self._request(
|
|
1242
|
+
"GET",
|
|
1243
|
+
f"attachments/{path_segment(attachment_uuid)}/info",
|
|
1244
|
+
f"Failed to get attachment information with UUID {attachment_uuid}",
|
|
1245
|
+
)
|
|
1246
|
+
return FileAttachmentRecord.model_validate(data)
|
|
1247
|
+
|
|
1248
|
+
async def list_attachments(
|
|
1249
|
+
self,
|
|
1250
|
+
page: int = 1,
|
|
1251
|
+
limit: int = 100,
|
|
1252
|
+
category: str | None = None,
|
|
1253
|
+
by: str | None = None,
|
|
1254
|
+
order: str | None = None,
|
|
1255
|
+
) -> list[FileAttachmentRecord]:
|
|
1256
|
+
"""Lists file attachments."""
|
|
1257
|
+
params = page_params(page, limit, category=category, by=by, order=order)
|
|
1258
|
+
data = await self._request(
|
|
1259
|
+
"GET",
|
|
1260
|
+
"attachments",
|
|
1261
|
+
f"Failed to list attachments, page: {page}, limit: {limit}",
|
|
1262
|
+
params=params,
|
|
1263
|
+
)
|
|
1264
|
+
return parse_list(FileAttachmentRecord, data)
|
|
1265
|
+
|
|
1266
|
+
async def delete_attachment(self, attachment_uuid: str) -> None:
|
|
1267
|
+
"""Deletes a file attachment."""
|
|
1268
|
+
require(attachment_uuid, "Attachment UUID")
|
|
1269
|
+
await self._request(
|
|
1270
|
+
"DELETE",
|
|
1271
|
+
f"attachments/{path_segment(attachment_uuid)}",
|
|
1272
|
+
f"Failed to delete attachment with UUID {attachment_uuid}",
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
async def download_attachment(
|
|
1276
|
+
self, attachment_uuid: str, directory_path: str | PathLike[str]
|
|
1277
|
+
) -> Path:
|
|
1278
|
+
"""Downloads a file attachment into a directory, returning the path of the file.
|
|
1279
|
+
|
|
1280
|
+
The file is named after the attachment, replacing any existing file of the same name.
|
|
1281
|
+
"""
|
|
1282
|
+
require(attachment_uuid, "Attachment UUID")
|
|
1283
|
+
require(os.fspath(directory_path), "Directory path")
|
|
1284
|
+
directory = Path(directory_path)
|
|
1285
|
+
if not directory.is_dir():
|
|
1286
|
+
raise ValueError(f"Directory does not exist: {directory_path}")
|
|
1287
|
+
if not os.access(directory, os.W_OK):
|
|
1288
|
+
raise ValueError(f"Directory is not writable: {directory_path}")
|
|
1289
|
+
|
|
1290
|
+
error_message = f"Failed to download attachment with UUID {attachment_uuid}"
|
|
1291
|
+
response = await self._send(
|
|
1292
|
+
"GET", f"attachments/{path_segment(attachment_uuid)}", error_message, stream=True
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
try:
|
|
1296
|
+
file_path = directory / attachment_file_name(response, attachment_uuid)
|
|
1297
|
+
with file_path.open("wb") as file:
|
|
1298
|
+
async for chunk in response.aiter_bytes():
|
|
1299
|
+
file.write(chunk)
|
|
1300
|
+
except httpx.HTTPError as e:
|
|
1301
|
+
raise FederationClientException(f"{error_message}: {e}") from e
|
|
1302
|
+
finally:
|
|
1303
|
+
await response.aclose()
|
|
1304
|
+
|
|
1305
|
+
return file_path
|
|
1306
|
+
|
|
1307
|
+
async def upload_file_attachment(
|
|
1308
|
+
self,
|
|
1309
|
+
evidence_uuid: str,
|
|
1310
|
+
local_file_path: str | PathLike[str],
|
|
1311
|
+
file_name: str | None = None,
|
|
1312
|
+
) -> UploadResult:
|
|
1313
|
+
"""Uploads a file as an attachment of an evidence record.
|
|
1314
|
+
|
|
1315
|
+
Args:
|
|
1316
|
+
evidence_uuid: The evidence record to attach the file to.
|
|
1317
|
+
local_file_path: The path of the file to upload.
|
|
1318
|
+
file_name: The name to upload the file as, defaulting to its local name.
|
|
1319
|
+
"""
|
|
1320
|
+
require(evidence_uuid, "Evidence UUID")
|
|
1321
|
+
path = Path(local_file_path)
|
|
1322
|
+
if not path.is_file():
|
|
1323
|
+
raise ValueError(f"File does not exist: {local_file_path}")
|
|
1324
|
+
if not os.access(path, os.R_OK):
|
|
1325
|
+
raise ValueError(f"File is not readable: {local_file_path}")
|
|
1326
|
+
if path.stat().st_size == 0:
|
|
1327
|
+
raise ValueError(f"Invalid file or empty file: {local_file_path}")
|
|
1328
|
+
|
|
1329
|
+
with path.open("rb") as file:
|
|
1330
|
+
return await self._upload_attachment(evidence_uuid, file_name or path.name, file)
|
|
1331
|
+
|
|
1332
|
+
async def upload_note_attachment(
|
|
1333
|
+
self, evidence_uuid: str, file_name: str, content: str
|
|
1334
|
+
) -> UploadResult:
|
|
1335
|
+
"""Uploads text as a .txt attachment of an evidence record."""
|
|
1336
|
+
require(evidence_uuid, "Evidence UUID")
|
|
1337
|
+
require(file_name, "File name")
|
|
1338
|
+
require(content, "Content")
|
|
1339
|
+
if not file_name.endswith(".txt"):
|
|
1340
|
+
file_name += ".txt"
|
|
1341
|
+
|
|
1342
|
+
return await self._upload_attachment(evidence_uuid, file_name, content.encode())
|
|
1343
|
+
|
|
1344
|
+
async def upload_file_attachment_from_url(
|
|
1345
|
+
self,
|
|
1346
|
+
evidence_uuid: str,
|
|
1347
|
+
file_url: str,
|
|
1348
|
+
max_file_size: int = DEFAULT_MAX_FILE_SIZE,
|
|
1349
|
+
) -> UploadResult:
|
|
1350
|
+
"""Downloads a file and uploads it as an attachment of an evidence record.
|
|
1351
|
+
|
|
1352
|
+
The file is requested from this machine, following redirects, so only pass a URL you
|
|
1353
|
+
trust.
|
|
1354
|
+
|
|
1355
|
+
Args:
|
|
1356
|
+
evidence_uuid: The evidence record to attach the file to.
|
|
1357
|
+
file_url: The URL to download the file from, which is requested without credentials.
|
|
1358
|
+
max_file_size: The maximum size of the file in bytes.
|
|
1359
|
+
"""
|
|
1360
|
+
require(evidence_uuid, "Evidence UUID")
|
|
1361
|
+
if not file_url:
|
|
1362
|
+
raise ValueError(f"Invalid URL provided: {file_url}")
|
|
1363
|
+
if max_file_size <= 0:
|
|
1364
|
+
raise ValueError("Maximum file size must be greater than 0")
|
|
1365
|
+
|
|
1366
|
+
error_message = f"Failed to download file from URL: {file_url}"
|
|
1367
|
+
content = bytearray()
|
|
1368
|
+
|
|
1369
|
+
try:
|
|
1370
|
+
async with self._http_client.stream("GET", file_url, follow_redirects=True) as response:
|
|
1371
|
+
if response.is_error:
|
|
1372
|
+
raise FederationClientException(
|
|
1373
|
+
f"{error_message}, {response.reason_phrase}", response.status_code
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
async for chunk in response.aiter_bytes():
|
|
1377
|
+
content += chunk
|
|
1378
|
+
if len(content) > max_file_size:
|
|
1379
|
+
raise FederationClientException(
|
|
1380
|
+
f"File exceeds maximum file size limit of {max_file_size} bytes", 413
|
|
1381
|
+
)
|
|
1382
|
+
except httpx.HTTPError as e:
|
|
1383
|
+
raise FederationClientException(f"{error_message}: {e}") from e
|
|
1384
|
+
|
|
1385
|
+
file_name = PurePosixPath(httpx.URL(file_url).path).name or "downloaded_file"
|
|
1386
|
+
return await self._upload_attachment(evidence_uuid, file_name, bytes(content))
|
|
1387
|
+
|
|
1388
|
+
async def _upload_attachment(
|
|
1389
|
+
self, evidence_uuid: str, file_name: str, content: bytes | BinaryIO
|
|
1390
|
+
) -> UploadResult:
|
|
1391
|
+
data = await self._request(
|
|
1392
|
+
"POST",
|
|
1393
|
+
"attachments",
|
|
1394
|
+
f"Failed to upload file attachment for evidence with UUID {evidence_uuid}",
|
|
1395
|
+
data={"evidence_uuid": evidence_uuid},
|
|
1396
|
+
files={"file": (file_name, content)},
|
|
1397
|
+
)
|
|
1398
|
+
return UploadResult.model_validate(data)
|
|
1399
|
+
|
|
1400
|
+
async def search_attachments(
|
|
1401
|
+
self,
|
|
1402
|
+
query: str,
|
|
1403
|
+
page: int = 1,
|
|
1404
|
+
limit: int = 100,
|
|
1405
|
+
category: str | None = None,
|
|
1406
|
+
by: str | None = None,
|
|
1407
|
+
order: str | None = None,
|
|
1408
|
+
) -> list[FileAttachmentRecord]:
|
|
1409
|
+
"""Searches file attachments."""
|
|
1410
|
+
params = search_params(query, page, limit, category=category, by=by, order=order)
|
|
1411
|
+
data = await self._request(
|
|
1412
|
+
"GET",
|
|
1413
|
+
"attachments/search",
|
|
1414
|
+
f"Failed to search attachments, query: {query}",
|
|
1415
|
+
params=params,
|
|
1416
|
+
)
|
|
1417
|
+
return parse_list(FileAttachmentRecord, data)
|