phederation-client 0.1.1__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.

Potentially problematic release.


This version of phederation-client might be problematic. Click here for more details.

Files changed (37) hide show
  1. phederation_client-0.1.1/CONTRIBUTING +4 -0
  2. phederation_client-0.1.1/LICENSE +21 -0
  3. phederation_client-0.1.1/PKG-INFO +73 -0
  4. phederation_client-0.1.1/README.rst +28 -0
  5. phederation_client-0.1.1/phederation/client/__init__.py +4 -0
  6. phederation_client-0.1.1/phederation/client/client.py +437 -0
  7. phederation_client-0.1.1/phederation/client/py.typed +0 -0
  8. phederation_client-0.1.1/phederation/client/resolver.py +391 -0
  9. phederation_client-0.1.1/phederation/models/__init__.py +400 -0
  10. phederation_client-0.1.1/phederation/models/activities.py +608 -0
  11. phederation_client-0.1.1/phederation/models/actors.py +329 -0
  12. phederation_client-0.1.1/phederation/models/apps.py +79 -0
  13. phederation_client-0.1.1/phederation/models/base.py +547 -0
  14. phederation_client-0.1.1/phederation/models/collections.py +252 -0
  15. phederation_client-0.1.1/phederation/models/exceptions.py +246 -0
  16. phederation_client-0.1.1/phederation/models/keys.py +94 -0
  17. phederation_client-0.1.1/phederation/models/links.py +83 -0
  18. phederation_client-0.1.1/phederation/models/maintenance.py +65 -0
  19. phederation_client-0.1.1/phederation/models/media.py +21 -0
  20. phederation_client-0.1.1/phederation/models/objects.py +393 -0
  21. phederation_client-0.1.1/phederation/models/proofs.py +161 -0
  22. phederation_client-0.1.1/phederation/models/py.typed +0 -0
  23. phederation_client-0.1.1/phederation/models/settings.py +378 -0
  24. phederation_client-0.1.1/phederation/utils/__init__.py +21 -0
  25. phederation_client-0.1.1/phederation/utils/base.py +16 -0
  26. phederation_client-0.1.1/phederation/utils/logging.py +36 -0
  27. phederation_client-0.1.1/phederation/utils/py.typed +0 -0
  28. phederation_client-0.1.1/phederation/utils/serialization.py +19 -0
  29. phederation_client-0.1.1/phederation/utils/validators.py +17 -0
  30. phederation_client-0.1.1/phederation/utils/version.py +29 -0
  31. phederation_client-0.1.1/phederation_client.egg-info/PKG-INFO +73 -0
  32. phederation_client-0.1.1/phederation_client.egg-info/SOURCES.txt +35 -0
  33. phederation_client-0.1.1/phederation_client.egg-info/dependency_links.txt +1 -0
  34. phederation_client-0.1.1/phederation_client.egg-info/requires.txt +20 -0
  35. phederation_client-0.1.1/phederation_client.egg-info/top_level.txt +1 -0
  36. phederation_client-0.1.1/pyproject.toml +69 -0
  37. phederation_client-0.1.1/setup.cfg +4 -0
@@ -0,0 +1,4 @@
1
+ # Ordered alphabetically by account name. Corresponding developers are marked with *
2
+
3
+ Username; Years of contribution; Contact person
4
+ https://codeberg.org/feldie; 2026-; *
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 https://codeberg.org/feldie (see also CONTRIBUTING file)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: phederation-client
3
+ Version: 0.1.1
4
+ Summary: Phederation implementation of an ActivityPub client. Can be used to connect to an ActivityPub server in the fediverse. This is the core library for the Phederation server software.
5
+ Author: Felix Dietrich
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://codeberg.org/feldie/phederation-client
8
+ Project-URL: Issues, https://codeberg.org/feldie/phederation-client/-/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Framework :: Pydantic :: 2
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Topic :: Communications
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Intended Audience :: Science/Research
19
+ Classifier: Natural Language :: English
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/x-rst
22
+ License-File: LICENSE
23
+ License-File: CONTRIBUTING
24
+ Requires-Dist: aiofiles>=24.1.0
25
+ Requires-Dist: build>=1.4.0
26
+ Requires-Dist: coverage>=7.10.7
27
+ Requires-Dist: fastapi[standard]==0.136.3
28
+ Requires-Dist: httpx>=0.27.2
29
+ Requires-Dist: pydantic>=2.11.7
30
+ Requires-Dist: pydantic-settings[yaml]>=2.10.1
31
+ Requires-Dist: pydeps>=3.0.1
32
+ Requires-Dist: pytest>=9.0.1
33
+ Requires-Dist: pytest-asyncio>=1.1.0
34
+ Requires-Dist: python-dotenv>=1.1.1
35
+ Requires-Dist: python-json-logger>=4.0.0
36
+ Requires-Dist: python-keycloak>=5.8.1
37
+ Requires-Dist: python-multipart>=0.0.20
38
+ Requires-Dist: pyyaml>=6.0.2
39
+ Requires-Dist: requests>=2.32.5
40
+ Requires-Dist: toml>=0.10.2
41
+ Requires-Dist: twine>=6.2.0
42
+ Requires-Dist: types-urllib3>=1.26.25.14
43
+ Requires-Dist: urllib3<2,>=1.21.1
44
+ Dynamic: license-file
45
+
46
+ A python implementation of an ActivityPub client.
47
+
48
+ This is the core library for the phederation software (https://codeberg.org/feldie/phederation), which implements an entire ActivityPub server architecture. The client library implements the models and utility methods for ActivityPub, and the ActivityPubClient class that can be used to interact with the server software.
49
+
50
+ Requirements
51
+ ============
52
+ - Linux environment (tested on Mint/Ubuntu)
53
+ - Python (3.12+)
54
+
55
+ Installation
56
+ ============
57
+
58
+ If you want to build ActivityPub clients, you can use the software as a library.
59
+
60
+ 1) Clone the repository
61
+
62
+ .. code-block:: sh
63
+
64
+ git clone https://codeberg.org/feldie/phederation-client.git phederation-client
65
+
66
+
67
+ 2) Navigate to the root folder of the "phederation-client" repository, then set up a python environment with uv, e.g.:
68
+
69
+ .. code-block:: sh
70
+
71
+ cd phederation-client
72
+ uv sync
73
+
@@ -0,0 +1,28 @@
1
+ A python implementation of an ActivityPub client.
2
+
3
+ This is the core library for the phederation software (https://codeberg.org/feldie/phederation), which implements an entire ActivityPub server architecture. The client library implements the models and utility methods for ActivityPub, and the ActivityPubClient class that can be used to interact with the server software.
4
+
5
+ Requirements
6
+ ============
7
+ - Linux environment (tested on Mint/Ubuntu)
8
+ - Python (3.12+)
9
+
10
+ Installation
11
+ ============
12
+
13
+ If you want to build ActivityPub clients, you can use the software as a library.
14
+
15
+ 1) Clone the repository
16
+
17
+ .. code-block:: sh
18
+
19
+ git clone https://codeberg.org/feldie/phederation-client.git phederation-client
20
+
21
+
22
+ 2) Navigate to the root folder of the "phederation-client" repository, then set up a python environment with uv, e.g.:
23
+
24
+ .. code-block:: sh
25
+
26
+ cd phederation-client
27
+ uv sync
28
+
@@ -0,0 +1,4 @@
1
+ from .client import ActivityPubClient
2
+ from .resolver import Resolver
3
+
4
+ __all__ = ["ActivityPubClient", "Resolver"]
@@ -0,0 +1,437 @@
1
+ """
2
+ client.py
3
+ Implements a client to interact with phederation instances.
4
+ This is a quality-of-life service to demonstrate and ease new client implementations.
5
+ It is also heavily used in testing all functionality of the main phederation (server) software.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ from http import HTTPStatus
11
+ from logging import Logger
12
+ from typing import Any, BinaryIO, cast
13
+ from urllib.parse import urlparse
14
+
15
+ import httpx
16
+ from fastapi import HTTPException, UploadFile
17
+ from keycloak import KeycloakAuthenticationError, KeycloakOpenID, KeycloakPostError
18
+ from pydantic import BaseModel, Field
19
+ from pydantic.types import SecretStr
20
+
21
+ from phederation.models import (
22
+ ActivityPubBaseWithId,
23
+ ActivityPubException,
24
+ APCreate,
25
+ APMigrate,
26
+ ApplicationRegistrationRequest,
27
+ CredentialApplication,
28
+ DeliveryError,
29
+ DeliveryResult,
30
+ NodeInfo,
31
+ ObjectId,
32
+ ResolverError,
33
+ ResolverResult,
34
+ UrlType,
35
+ UserError,
36
+ ValidCollection,
37
+ actor_id_from_username,
38
+ dereference,
39
+ object_from_type,
40
+ )
41
+ from phederation.models.activities import APActivity
42
+ from phederation.models.actors import APActor
43
+ from phederation.models.exceptions import DecodingError
44
+ from phederation.models.objects import APDocument, APObject
45
+ from phederation.models.proofs import to_sha256
46
+ from phederation.models.settings import KeycloakSettings, PhedSettings
47
+ from phederation.utils import urljoin
48
+ from phederation.utils.logging import configure_logger
49
+ from phederation.utils.serialization import to_json_string
50
+ from phederation.utils.version import PHEDERATION_HEADERS
51
+
52
+ from .resolver import Resolver
53
+
54
+
55
+ class ActivityPubClient(BaseModel):
56
+ """
57
+ ActivityPubClient that can be used in client2server applications to easily setup and communicate with a phederation server using the activitypub protocol.
58
+
59
+ Applications can use the deliver and resolver methods and properties of an ActivityPubClient instance to deliver activities and to resolve information about instances and actors in the federation. The 'host_url' must be set to the server url, and the 'actor_id' must be set to the actor that is operating the client. This actor must be registered on the server.
60
+
61
+ Authentication is done through KeycloakSettings (stored in an .env file).
62
+ """
63
+
64
+ settings: PhedSettings = Field(default=..., description="The settings for the client-side phederation setup.")
65
+ instance_actor_id: ObjectId | None = Field(
66
+ default=None,
67
+ description="The id of the instance actor for the host instance. Must be a resolvable URL (as defined by ActivityPub), or None if it should be discovered from the host settings.",
68
+ )
69
+ keycloak_settings: KeycloakSettings | None = Field(description="Settings to reach the keycloak instance for user authentication (OAuth2).")
70
+ username: str | None = Field(default=None, description="Username of the actor to use for login")
71
+ password: SecretStr | None = Field(default=None, description="Password of the actor to use for login")
72
+ access_token: SecretStr | None = Field(default=None, description="Current access_token of the actor to use for authentication")
73
+ _initialized: bool = False
74
+
75
+ def initialize(self):
76
+ if not self._initialized:
77
+ self._resolver: Resolver = Resolver(settings=self.settings)
78
+ self._requests: httpx.AsyncClient = httpx.AsyncClient()
79
+ self._logger: Logger = configure_logger(__name__, prefix=self.settings.federation.logging_prefix + "_client")
80
+ if self.keycloak_settings is None:
81
+ self._logger.warning("Client does not have keycloak settings set. Will not be able to authenticate")
82
+ self._keycloak_openid = None
83
+ else:
84
+ keycloak_hostname = (
85
+ self.keycloak_settings.keycloak_docker_hostname
86
+ if self.keycloak_settings.keycloak_docker_hostname
87
+ else self.keycloak_settings.keycloak_hostname
88
+ )
89
+ self._keycloak_openid: KeycloakOpenID | None = KeycloakOpenID(
90
+ server_url=keycloak_hostname,
91
+ realm_name=self.keycloak_settings.keycloak_realm,
92
+ client_id=self.keycloak_settings.keycloak_client_id,
93
+ client_secret_key=self.keycloak_settings.keycloak_client_secret.get_secret_value(),
94
+ verify=True,
95
+ )
96
+ self._initialized = True
97
+
98
+ async def resolve_multiple(self, urls: list[ObjectId], resolve_endpoint: ObjectId | None):
99
+ """Tries to resolve multiple objects given by their id.
100
+ This will use the /resolve endpoint of the instance.
101
+ The objects in the list can be of different types.
102
+ The object ids must be for the instance connected to this client (because this is using the instance resolve endpoint)
103
+
104
+ Args:
105
+ urls (list[ObjectId]): Ids of objects to resolve.
106
+ resolve_endpoint: The endpoint on the instance where multiple objects can be resolved. Default None (uses '/resolve').
107
+
108
+ Returns:
109
+ list[APObject | DataIntegrityProof | NodeInfo]: list of resolved objects. If an object id cannot be resolved, it is not included. The list is not necessarily in the same order as the given urls, but the objects have their '.id' property set, so can be identified again.
110
+ """
111
+ # TODO: allow to resolve from multiple instance endpoints
112
+ # get resolve endpoint from instance
113
+ if not self._initialized:
114
+ raise ResolverError("Must initialize the client first")
115
+ if resolve_endpoint:
116
+ try:
117
+ headers = await self._headers(with_token=True)
118
+ urls_encoded = to_json_string(urls)
119
+ response = await self._requests.post(resolve_endpoint, headers=headers, content=urls_encoded)
120
+ result_bytes = await response.aread()
121
+ result_json = cast(list[dict[str, Any]], json.loads(result_bytes.decode(encoding="utf-8")))
122
+ result_objects = [object_from_type(obj_dict) for obj_dict in result_json]
123
+ result_objects_with_id = [obj for obj in result_objects if isinstance(obj, ActivityPubBaseWithId)]
124
+ return result_objects_with_id
125
+ except Exception as e:
126
+ raise ResolverError(f"Could not resolve from /resolve endpoint; error: {e}")
127
+ raise ResolverError(f"The instance does not have a /resolve endpoint")
128
+
129
+ @property
130
+ def actor_id(self):
131
+ """The id of the actor who is using this client.
132
+ A resolvable URL (as defined by ActivityPub), or None if no specific actor is using it."""
133
+ if self.username:
134
+ return actor_id_from_username(self.settings.domain.hostname, username=self.username)
135
+ else:
136
+ raise ValueError("Actor_id not available in ActivityPubClient: field 'username' is not set")
137
+
138
+ def authenticate_user(self, username: str, password: SecretStr, redirect_uri: str | None = None) -> SecretStr:
139
+ """
140
+ Authenticate the user using Keycloak and return an access token.
141
+ """
142
+ try:
143
+ if not self._keycloak_openid:
144
+ raise KeycloakAuthenticationError("Keycloak connection not initialized")
145
+ if not redirect_uri:
146
+ redirect_uri = self.settings.domain.hostname
147
+ token: dict[str, Any] | None = self._keycloak_openid.token( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
148
+ username, password.get_secret_value(), redirect_uri=redirect_uri
149
+ )
150
+ if not token:
151
+ raise KeycloakAuthenticationError("Unauthorized, token not available")
152
+ access_token = token.get("access_token", None)
153
+ if not isinstance(access_token, str):
154
+ raise KeycloakAuthenticationError("Unauthorized, access_token not available")
155
+ return SecretStr(access_token)
156
+ except KeycloakAuthenticationError as e:
157
+ self._logger.warning(f"Keycloak: login unsuccessful; KeycloakAuthenticationError error: {e}.")
158
+ raise HTTPException(
159
+ status_code=HTTPStatus.UNAUTHORIZED,
160
+ detail="Unauthorized",
161
+ )
162
+ except KeycloakPostError as e:
163
+ self._logger.warning(f"Keycloak: login unsuccessful; KeycloakPostError error: {e}.")
164
+ raise HTTPException(
165
+ status_code=HTTPStatus.BAD_REQUEST,
166
+ detail="POST/Client error",
167
+ )
168
+
169
+ def get_token(self) -> SecretStr | None:
170
+ if self.access_token:
171
+ return self.access_token
172
+ if not self._keycloak_openid or not self.username or not self.password:
173
+ token = None
174
+ else:
175
+ token = self.authenticate_user(username=self.username, password=self.password)
176
+ self.access_token = token
177
+ return token
178
+
179
+ async def deliver_to_url(
180
+ self, activity: APActivity, url: ObjectId, retry_count: int = 0, data: UploadFile | None = None, token: SecretStr | None = None
181
+ ) -> DeliveryResult:
182
+ """Deliver activity to a given url (e.g. inbox or outbox of actor).
183
+
184
+ Args:
185
+ activity (APActivity): to deliver.
186
+ url (str): url where the activity is to be delivered to.
187
+ retry_count (int, optional): how often the delivery has been tried already. Defaults to 0.
188
+ data (UploadFile, optional): data of a file to upload. Only used in c2s communication (i.e. by client apps).
189
+ token (str, optional): if this is set, the delivery will not use header signing and instead pass the Authorization: Bearer token.
190
+
191
+ Returns:
192
+ DeliveryResult: Result of delivery after retries.
193
+ """
194
+ parsed_url = urlparse(str(url))
195
+ domain = parsed_url.netloc
196
+ result = DeliveryResult()
197
+
198
+ try:
199
+ # Prepare headers with host
200
+ headers = PHEDERATION_HEADERS.copy()
201
+ headers["Host"] = domain
202
+
203
+ # Get key id of the actor sending this
204
+ actor_id = dereference(activity, key="actor")
205
+ if actor_id is None:
206
+ raise DeliveryError(f"No actor in activity found: {activity}")
207
+
208
+ # Add a content hash of the content of the activity.object
209
+ await activity.add_hash()
210
+
211
+ # Use standardized JSON serialization
212
+ activity_dict = activity.serialize()
213
+ body_json = to_json_string(activity_dict)
214
+
215
+ if data:
216
+ files: dict[str, tuple[None | str, str | BinaryIO, str]] = {
217
+ "activity_form": (None, body_json, "application/json"),
218
+ "file": (data.filename, data.file, data.content_type or "text/plain"),
219
+ }
220
+ _ = headers.pop("Content-Type") # is going to be set by the AsyncClient to multipart
221
+ else:
222
+ files = {}
223
+
224
+ if token:
225
+ headers["Authorization"] = f"Bearer {token.get_secret_value()}"
226
+ headers["WWW-Authenticate"] = "Bearer"
227
+
228
+ # Make request with pre-serialized JSON or Files
229
+ async def post_files(url: str, client: httpx.AsyncClient):
230
+ return await client.post(url, files=files, headers=headers, timeout=self.settings.federation.delivery_timeout)
231
+
232
+ async def post_json(url: str, client: httpx.AsyncClient):
233
+ return await client.post(url, content=body_json, headers=headers, timeout=self.settings.federation.delivery_timeout)
234
+
235
+ post_action = post_files if data else post_json
236
+
237
+ try:
238
+ response = await post_action(url=url, client=self._requests)
239
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
240
+ self._logger.debug(f"Error when connecting to url {url}: {type(e).__name__} {e}")
241
+ result.error_message = "Error when connecting to url."
242
+ result.status_code = HTTPStatus.CONTINUE
243
+ result.failed.append(url)
244
+ return result
245
+
246
+ response_headers: dict[str, str] = {h.lower(): response.headers[h] for h in response.headers.keys()}
247
+
248
+ if response.status_code in (HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.ACCEPTED):
249
+ try:
250
+ response_content: dict[str, Any] = cast(dict[str, Any], json.loads(response.content.decode("utf8")))
251
+ except json.JSONDecodeError as e:
252
+ raise DecodingError(message=f"JSONDecodeError for response_content '{response.content}'.")
253
+
254
+ if "location" in response_headers:
255
+ response_content["Location"] = response_headers["location"]
256
+ result.success.append(response_content)
257
+ result.status_code = response.status_code
258
+ return result
259
+
260
+ if (
261
+ response.status_code in (HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.GATEWAY_TIMEOUT)
262
+ and retry_count < self.settings.federation.max_retries
263
+ ):
264
+ # Calculate retry delay
265
+ retry_after = int(response_headers.get("retry-after", self.settings.federation.retry_delay))
266
+ self._logger.warning(
267
+ f"Retry delivery of activity with id={activity.id} after waiting {retry_after} seconds. Retry_count: {retry_count + 1} / {self.settings.federation.max_retries}..."
268
+ )
269
+ await asyncio.sleep(retry_after)
270
+
271
+ # Retry delivery
272
+ return await self.deliver_to_url(activity=activity, url=url, retry_count=retry_count + 1)
273
+
274
+ self._logger.warning(
275
+ f"Delivery Error for activity type {activity.type}: url={url}, content={response.content}, headers={response_headers}"
276
+ )
277
+ result.failed.append(url)
278
+ result.status_code = response.status_code
279
+ result.error_message = str(response.content) if len(response.content) > 0 else "No response text received"
280
+
281
+ except json.JSONDecodeError as e:
282
+ self._logger.error(f"Delivery JSONDecodeError for url '{url}': {e}")
283
+ result.failed.append(url)
284
+ result.status_code = HTTPStatus.BAD_REQUEST
285
+ result.error_message = f"Delivery exception: cannot decode."
286
+ except ActivityPubException as e:
287
+ self._logger.error(f"Delivery ActivityPubException for url '{url}': {e.message}")
288
+ result.failed.append(url)
289
+ result.status_code = e.status_code
290
+ result.error_message = f"Delivery APException: {e.user_message or 'unknown'}"
291
+ except Exception as e:
292
+ self._logger.error(f"Delivery Exception for url '{url}': {type(e).__name__}, {str(e)}")
293
+ result.failed.append(url)
294
+ result.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
295
+ result.error_message = f"Delivery exception: {type(e).__name__}, {str(e)}"
296
+
297
+ return result
298
+
299
+ async def deliver_file(self, file: BinaryIO, file_hash: str | None, media_type: str = "application/json", file_description: str = "A json file"):
300
+ file_to_send = UploadFile(filename=file.name, file=file)
301
+
302
+ create_activity = APCreate(actor=self.actor_id, object=APDocument(content=file_description, hash=file_hash, media_type=media_type))
303
+
304
+ token = self.get_token()
305
+ actor = await self.resolver.resolve(APActor, self.actor_id)
306
+ media_endpoint = actor.endpoints.upload_media if actor.endpoints else None
307
+ if not media_endpoint:
308
+ raise UserError("Actor does not have a media endpoint to send files to")
309
+ response = await self.deliver_to_url(url=media_endpoint, activity=create_activity, data=file_to_send, token=token)
310
+ return response
311
+
312
+ async def deliver_activity(self, activity: APActivity):
313
+ """Deliver activity to an actor's outbox.
314
+ This is the main method for client2server communication. Instead of signing the header (which would require access to the private key),
315
+ the client must send a token for authentication.
316
+
317
+ Args:
318
+ activity (APActivity): Activity to deliver.
319
+ actor_id (ObjectId): id of actor that is performing the activity.
320
+ token (str | None, optional): Authentication token, if available and necessary. Defaults to None.
321
+
322
+ Returns:
323
+ DeliveryResult: Result of the delivery to the outbox. Can contain the id of the created activity, if successful; or a failed result if an exception was raised.
324
+ """
325
+ token = self.get_token()
326
+
327
+ url = None
328
+ try:
329
+ # always hash the content
330
+ await activity.add_hash()
331
+
332
+ actor = await self.resolver.resolve(APActor, self.actor_id)
333
+ url = actor.outbox
334
+ return await self.deliver_to_url(activity=activity, url=actor.outbox, token=token)
335
+ except ActivityPubException as e:
336
+ self._logger.error(f"Failed to deliver to actor {self.actor_id} outbox at url {url}: '{e.message}'. Failing silently.")
337
+ return DeliveryResult(failed=[self.actor_id], error_message=e.user_message)
338
+ except Exception as e:
339
+ self._logger.error(f"Failed to deliver to actor {self.actor_id} outbox at url {url}: '{str(e)}'. Failing silently.")
340
+ return DeliveryResult(failed=[self.actor_id], error_message=str(e))
341
+
342
+ async def deliver_maintenance(self, activity: APActivity):
343
+ token = self.get_token()
344
+ obj = activity.object
345
+ if self.settings.media.create_file_hash and isinstance(obj, APObject):
346
+ obj.hash = to_sha256(obj.content or "")
347
+
348
+ maintenance_route = urljoin(self.settings.domain.admin_url, UrlType.Maintenance.value)
349
+ result = await self.deliver_to_url(activity, url=maintenance_route, token=token)
350
+ return result
351
+
352
+ async def resolve_nodeinfo(self) -> NodeInfo | None:
353
+ base_url = self.settings.domain.hostname
354
+ response = await self._requests.get(urljoin(base_url, "/.well-known/instanceinfo"))
355
+ nodeinfo = NodeInfo.deserialize(cast(dict[str, str], response.json()))
356
+ return nodeinfo
357
+
358
+ async def register_client(self, request: ApplicationRegistrationRequest) -> CredentialApplication:
359
+ url = urljoin(self.settings.domain.hostname, UrlType.Applications.value)
360
+ headers = await self._headers(with_token=False)
361
+ body_serialized = to_json_string(request.model_dump())
362
+ self._logger.info(f"Application registration request for client '{request.client_name}' at instance url {url}")
363
+ result = await self._requests.post(url=url, content=body_serialized, headers=headers)
364
+ result = result.raise_for_status()
365
+ application_dict = cast(dict[str, str], result.json())
366
+ application = CredentialApplication.model_validate(application_dict)
367
+ return application
368
+
369
+ async def create_account(
370
+ self,
371
+ ):
372
+ if not self.actor_id or not self.username:
373
+ raise DeliveryError("Could not create account, actor_id or username is not set")
374
+ actor_id = actor_id_from_username(self.settings.domain.hostname, username=self.username)
375
+ inbox_url = urljoin(actor_id, ValidCollection.Inbox.value)
376
+ outbox_url = urljoin(actor_id, ValidCollection.Outbox.value)
377
+ activity = APCreate(actor=self.actor_id, object=APActor(preferred_username=self.username, inbox=inbox_url, outbox=outbox_url))
378
+ url = urljoin(self.settings.domain.hostname, ValidCollection.Users.value)
379
+ headers = await self._headers(with_token=True)
380
+ body_serialized = to_json_string(activity.serialize())
381
+ self._logger.info(f"Sending user acount registration for user {self.username} to instance url {url}")
382
+ result = await self._requests.put(url=url, content=body_serialized, headers=headers)
383
+ return result
384
+
385
+ async def _headers(self, with_token: bool = True):
386
+ headers = PHEDERATION_HEADERS
387
+ headers["Host"] = self.settings.domain.hostname
388
+ if with_token:
389
+ token = self.get_token()
390
+ if token:
391
+ headers["Authorization"] = f"Bearer {token.get_secret_value()}"
392
+ headers["WWW-Authenticate"] = "Bearer"
393
+ return headers
394
+
395
+ @property
396
+ def resolver(self):
397
+ self.initialize()
398
+ return self._resolver
399
+
400
+ async def download_user_data(self):
401
+ if not self.username:
402
+ raise ResolverError("Username is not set in the client, cannot download user data")
403
+ # send the "assemble user data" command
404
+ create_userdata = APMigrate(actor=self.actor_id)
405
+ result = await self.deliver_activity(activity=create_userdata)
406
+ activity_id = result.get_success_id_or_raise()
407
+
408
+ # retrieve the activity to get the file url
409
+ token = self.get_token()
410
+ activity = await self.resolver.resolve(APActivity, activity_id, access_token=token)
411
+ file_descriptor_document = await self.resolver.resolve(APObject, activity.object)
412
+ file_urls = file_descriptor_document.url
413
+
414
+ if not file_urls:
415
+ response = ResolverResult(
416
+ content=file_descriptor_document.id, status_code=None, error_message="Url is not contained in the user data document"
417
+ )
418
+ raise ResolverError("Could not resolve any user data file urls", response=response)
419
+
420
+ # get the actor media endpoint and download the data
421
+ files: list[bytes] = []
422
+ for file_url in file_urls:
423
+ resolved_file = b"".join([line async for line in await self.resolver.resolve_media(file_id=file_url, access_token=token)])
424
+ files.append(resolved_file)
425
+ return files
426
+
427
+ async def user_migration_initialize(self, client_for_new_instance: "ActivityPubClient"):
428
+ activity = APMigrate(actor=self.actor_id, actor_to=client_for_new_instance.actor_id, object=None)
429
+ result = await self.deliver_activity(activity)
430
+ return result
431
+
432
+ async def user_migration_status(self, migration_activity_id: ObjectId):
433
+ token = self.get_token()
434
+ activity = await self.resolver.resolve(APActivity, migration_activity_id, access_token=token)
435
+ if activity.object:
436
+ obj = await self.resolver.resolve(APObject, activity.object, access_token=token)
437
+ return cast(APDocument, obj)
File without changes