diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd9554..e8b48c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [unreleased] -### Major changes +### Backwards incompatible changes Diaspora protocol support added for `comment` and `like` relayable types. On inbound payloads the signature included in the payload will be verified against the sender public key. A failed verification will raise `SignatureVerificationError`. For outbound entities, the author private key will be used to add a signature to the payload. @@ -10,8 +10,7 @@ This introduces some backwards incompatible changes to the way entities are proc Additionally, Diaspora entity mappers `message_to_objects` and `element_to_objects` now take an optional `sender_key_fetcher` parameter. This must be a function that when called with the sender handle will return the sender public key. This allows using locally cached public keys instead of fetching them as needed. NOTE! If the function is not given, each processed payload will fetch the public key over the network. -### Other backwards incompatible changes -* A failed payload signature verification now raises a `SignatureVerificationError` instead of a less specific `AssertionError`. +A failed payload signature verification now raises a `SignatureVerificationError` instead of a less specific `AssertionError`. ### Added * Three new attributes added to entities. @@ -20,6 +19,9 @@ Additionally, Diaspora entity mappers `message_to_objects` and `element_to_objec * Add sender public key to the entity at `_sender_key`, but only if it was used for validating signatures. * Add support for the new Diaspora payload properties coming in the next protocol version. Old XML payloads are and will be still supported. * `DiasporaComment` and `DiasporaLike` will get the order of elements in the XML payload as a list in `xml_tags`. For implementers who want to recreate payloads for these relayables, this list should be saved for later use. +* High level `federation.outbound.handle_send` helper function now allows sending entities to a list of recipients without having to deal with payload creation or caring about the protocol (in preparation of being a multi-protocol library). + * The function takes three parameters, `entity` that will be sent, `from_user` that is sending (note, not necessarely authoring, this user will be used to sign the payload for Diaspora for example) and a list of recipients as tuples of recipient handle/domain and optionally protocol. In the future, if protocol is not given, it will be guessed from the recipient handle, and if necessary a network lookup will be made to see what protocols the receiving identity supports. + * Payloads will be delivered to each receiver only once. Currently only public messages are supported through this helper, so multiple recipients on a single domain will cause only one delivery. ### Changed * Refactor processing of Diaspora payload XML into entities. Diaspora protocol is dropping the `` wrapper for the payloads. Payloads with the wrapper will still be parsed as before. diff --git a/docs/usage.rst b/docs/usage.rst index d05ce51..14ed143 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -82,6 +82,7 @@ Outbound High level utility functions to pass outbound entities to. These should be favoured instead of protocol specific utility functions. .. autofunction:: federation.outbound.handle_create_payload +.. autofunction:: federation.outbound.handle_send Protocols diff --git a/federation/outbound.py b/federation/outbound.py index 7e8d7b2..3f95471 100644 --- a/federation/outbound.py +++ b/federation/outbound.py @@ -1,6 +1,7 @@ -# -*- coding: utf-8 -*- from federation.entities.diaspora.mappers import get_outbound_entity from federation.protocols.diaspora.protocol import Protocol +from federation.utils.diaspora import get_public_endpoint +from federation.utils.network import send_document def handle_create_payload(entity, from_user, to_user=None): @@ -22,3 +23,36 @@ def handle_create_payload(entity, from_user, to_user=None): outbound_entity = get_outbound_entity(entity, from_user.private_key) data = protocol.build_send(entity=outbound_entity, from_user=from_user, to_user=to_user) return data + + +def handle_send(entity, from_user, recipients=None): + """Send an entity to remote servers. + + `from_user` must have `private_key` and `handle` attributes. + + `recipients` should be a list of tuples, containing: + - recipient handle, domain or id + - protocol (optional, if known) + + Using this we will build a list of payloads per protocol, after resolving any that need to be guessed or + looked up over the network. After that, each recipient will get the generated protocol payload delivered. + + NOTE! This will not support Diaspora limited messages - `handle_create_payload` above should be directly + called instead and payload sent with `federation.utils.network.send_document`. + """ + payloads = {"diaspora": {"payload": None, "recipients": set()}} + # Generate payload per protocol and split recipients to protocols + for recipient, protocol in recipients: + # TODO currently we only support Diaspora protocol, so no need to guess, just generate the payload + if not payloads["diaspora"]["payload"]: + payloads["diaspora"]["payload"] = handle_create_payload(entity, from_user) + if "@" in recipient: + payloads["diaspora"]["recipients"].add(recipient.split("@")[1]) + else: + payloads["diaspora"]["recipients"].add(recipient) + # Do actual sending + for protocol, data in payloads.items(): + for recipient in data.get("recipients"): + # TODO protocol independant url generation by importing named helper under protocol + url = get_public_endpoint(recipient) + send_document(url, data.get("payload")) diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py new file mode 100644 index 0000000..4f30b58 --- /dev/null +++ b/federation/tests/conftest.py @@ -0,0 +1,26 @@ +from unittest.mock import Mock + +import pytest + +from federation.entities.diaspora.entities import DiasporaPost + + +@pytest.fixture(autouse=True) +def disable_network_calls(monkeypatch): + """Disable network calls.""" + monkeypatch.setattr("requests.post", Mock()) + + class MockResponse(str): + status_code = 200 + text = "" + + @staticmethod + def raise_for_status(): + pass + + monkeypatch.setattr("requests.get", Mock(return_value=MockResponse)) + + +@pytest.fixture +def diasporapost(): + return DiasporaPost() diff --git a/federation/tests/entities/diaspora/test_mappers.py b/federation/tests/entities/diaspora/test_mappers.py index 21c8173..1516ae4 100644 --- a/federation/tests/entities/diaspora/test_mappers.py +++ b/federation/tests/entities/diaspora/test_mappers.py @@ -77,7 +77,7 @@ class TestDiasporaEntityMappersReceive(): @patch("federation.entities.diaspora.mappers.DiasporaComment._validate_signatures") def test_message_to_objects_comment(self, mock_validate): - entities = message_to_objects(DIASPORA_POST_COMMENT) + entities = message_to_objects(DIASPORA_POST_COMMENT, sender_key_fetcher=Mock()) assert len(entities) == 1 comment = entities[0] assert isinstance(comment, DiasporaComment) @@ -95,7 +95,7 @@ class TestDiasporaEntityMappersReceive(): @patch("federation.entities.diaspora.mappers.DiasporaLike._validate_signatures") def test_message_to_objects_like(self, mock_validate): - entities = message_to_objects(DIASPORA_POST_LIKE) + entities = message_to_objects(DIASPORA_POST_LIKE, sender_key_fetcher=Mock()) assert len(entities) == 1 like = entities[0] assert isinstance(like, DiasporaLike) diff --git a/federation/tests/test_outbound.py b/federation/tests/test_outbound.py index f9a78ca..4c272b4 100644 --- a/federation/tests/test_outbound.py +++ b/federation/tests/test_outbound.py @@ -1,13 +1,12 @@ -# -*- coding: utf-8 -*- -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, call from Crypto.PublicKey import RSA from federation.entities.diaspora.entities import DiasporaPost -from federation.outbound import handle_create_payload +from federation.outbound import handle_create_payload, handle_send -class TestHandleCreatePayloadBuildsAPayload(object): +class TestHandleCreatePayloadBuildsAPayload(): @patch("federation.outbound.Protocol") def test_handle_create_payload_builds_an_xml(self, mock_protocol_class): mock_protocol = Mock() @@ -24,3 +23,24 @@ class TestHandleCreatePayloadBuildsAPayload(object): entity = DiasporaPost() handle_create_payload(entity, from_user) assert mock_get_outbound_entity.called + + +@patch("federation.outbound.handle_create_payload", return_value="payload") +@patch("federation.outbound.send_document") +class TestHandleSend(): + def test_calls_handle_create_payload(self, mock_send, mock_create, diasporapost): + recipients = [("foo@127.0.0.1", "diaspora"), ("localhost", None)] + mock_from_user = Mock() + handle_send(diasporapost, mock_from_user, recipients) + mock_create.assert_called_once_with(diasporapost, mock_from_user) + + def test_calls_send_document(self, mock_send, mock_create, diasporapost): + recipients = [("foo@127.0.0.1", "diaspora"), ("localhost", None)] + mock_from_user = Mock() + handle_send(diasporapost, mock_from_user, recipients) + call_args_list = [ + call("https://127.0.0.1/receive/public", "payload"), + call("https://localhost/receive/public", "payload"), + ] + assert call_args_list[0] in mock_send.call_args_list + assert call_args_list[1] in mock_send.call_args_list diff --git a/federation/utils/diaspora.py b/federation/utils/diaspora.py index 20b9ce7..2b4073c 100644 --- a/federation/utils/diaspora.py +++ b/federation/utils/diaspora.py @@ -135,3 +135,7 @@ def retrieve_and_parse_profile(handle): profile, ex) return None return profile + + +def get_public_endpoint(domain): + return "https://%s/receive/public" % domain