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.
- phederation_client-0.1.1/CONTRIBUTING +4 -0
- phederation_client-0.1.1/LICENSE +21 -0
- phederation_client-0.1.1/PKG-INFO +73 -0
- phederation_client-0.1.1/README.rst +28 -0
- phederation_client-0.1.1/phederation/client/__init__.py +4 -0
- phederation_client-0.1.1/phederation/client/client.py +437 -0
- phederation_client-0.1.1/phederation/client/py.typed +0 -0
- phederation_client-0.1.1/phederation/client/resolver.py +391 -0
- phederation_client-0.1.1/phederation/models/__init__.py +400 -0
- phederation_client-0.1.1/phederation/models/activities.py +608 -0
- phederation_client-0.1.1/phederation/models/actors.py +329 -0
- phederation_client-0.1.1/phederation/models/apps.py +79 -0
- phederation_client-0.1.1/phederation/models/base.py +547 -0
- phederation_client-0.1.1/phederation/models/collections.py +252 -0
- phederation_client-0.1.1/phederation/models/exceptions.py +246 -0
- phederation_client-0.1.1/phederation/models/keys.py +94 -0
- phederation_client-0.1.1/phederation/models/links.py +83 -0
- phederation_client-0.1.1/phederation/models/maintenance.py +65 -0
- phederation_client-0.1.1/phederation/models/media.py +21 -0
- phederation_client-0.1.1/phederation/models/objects.py +393 -0
- phederation_client-0.1.1/phederation/models/proofs.py +161 -0
- phederation_client-0.1.1/phederation/models/py.typed +0 -0
- phederation_client-0.1.1/phederation/models/settings.py +378 -0
- phederation_client-0.1.1/phederation/utils/__init__.py +21 -0
- phederation_client-0.1.1/phederation/utils/base.py +16 -0
- phederation_client-0.1.1/phederation/utils/logging.py +36 -0
- phederation_client-0.1.1/phederation/utils/py.typed +0 -0
- phederation_client-0.1.1/phederation/utils/serialization.py +19 -0
- phederation_client-0.1.1/phederation/utils/validators.py +17 -0
- phederation_client-0.1.1/phederation/utils/version.py +29 -0
- phederation_client-0.1.1/phederation_client.egg-info/PKG-INFO +73 -0
- phederation_client-0.1.1/phederation_client.egg-info/SOURCES.txt +35 -0
- phederation_client-0.1.1/phederation_client.egg-info/dependency_links.txt +1 -0
- phederation_client-0.1.1/phederation_client.egg-info/requires.txt +20 -0
- phederation_client-0.1.1/phederation_client.egg-info/top_level.txt +1 -0
- phederation_client-0.1.1/pyproject.toml +69 -0
- phederation_client-0.1.1/setup.cfg +4 -0
|
@@ -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,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
|