From c3c4475e246533c8b65e3adc82eb401f971127a8 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Sun, 10 Jul 2022 14:29:28 +0200 Subject: [PATCH] Support for sending webmentions as outgoing activities --- ...9c4fc0_webmention_support_for_outgoing_.py | 28 +++++++++++ app/models.py | 4 ++ app/outgoing_activities.py | 50 +++++++++++++------ tests/test_process_outgoing_activities.py | 36 +++++++++++++ 4 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/afc37d9c4fc0_webmention_support_for_outgoing_.py diff --git a/alembic/versions/afc37d9c4fc0_webmention_support_for_outgoing_.py b/alembic/versions/afc37d9c4fc0_webmention_support_for_outgoing_.py new file mode 100644 index 0000000..d0d8bdc --- /dev/null +++ b/alembic/versions/afc37d9c4fc0_webmention_support_for_outgoing_.py @@ -0,0 +1,28 @@ +"""Webmention support for outgoing activties + +Revision ID: afc37d9c4fc0 +Revises: 65387f69edfb +Create Date: 2022-07-10 14:20:46.311098 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'afc37d9c4fc0' +down_revision = '65387f69edfb' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('outgoing_activity', sa.Column('webmention_target', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('outgoing_activity', 'webmention_target') + # ### end Alembic commands ### diff --git a/app/models.py b/app/models.py index 625a624..897a2df 100644 --- a/app/models.py +++ b/app/models.py @@ -331,6 +331,9 @@ class OutgoingActivity(Base): inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True) inbox_object = relationship(InboxObject, uselist=False) + # The source will be the outbox object URL + webmention_target = Column(String, nullable=True) + tries = Column(Integer, nullable=False, default=0) next_try = Column(DateTime(timezone=True), nullable=True, default=now) @@ -422,6 +425,7 @@ class IndieAuthAccessToken(Base): id = Column(Integer, primary_key=True, index=True) created_at = Column(DateTime(timezone=True), nullable=False, default=now) + # Will be null for personal access tokens indieauth_authorization_request_id = Column( Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True ) diff --git a/app/outgoing_activities.py b/app/outgoing_activities.py index b1b265c..4a84345 100644 --- a/app/outgoing_activities.py +++ b/app/outgoing_activities.py @@ -126,16 +126,20 @@ async def new_outgoing_activity( recipient: str, outbox_object_id: int | None, inbox_object_id: int | None = None, + webmention_target: str | None = None, ) -> models.OutgoingActivity: if outbox_object_id is None and inbox_object_id is None: raise ValueError("Must reference at least one inbox/outbox activity") - elif outbox_object_id and inbox_object_id: + if webmention_target and outbox_object_id is None: + raise ValueError("Webmentions must reference an outbox activity") + if outbox_object_id and inbox_object_id: raise ValueError("Cannot reference both inbox/outbox activities") outgoing_activity = models.OutgoingActivity( recipient=recipient, outbox_object_id=outbox_object_id, inbox_object_id=inbox_object_id, + webmention_target=webmention_target, ) db_session.add(outgoing_activity) @@ -205,21 +209,39 @@ def process_next_outgoing_activity(db: Session) -> bool: next_activity.tries = next_activity.tries + 1 next_activity.last_try = now() - payload = ap.wrap_object_if_needed(next_activity.anybox_object.ap_object) + logger.info(f"recipient={next_activity.recipient}") - # Use LD sig if the activity may need to be forwarded by recipients - if next_activity.anybox_object.is_from_outbox and payload["type"] in [ - "Create", - "Update", - "Delete", - ]: - # But only if the object is public (to help with deniability/privacy) - if next_activity.outbox_object.visibility == ap.VisibilityEnum.PUBLIC: - ldsig.generate_signature(payload, k) - - logger.info(f"{payload=}") try: - resp = ap.post(next_activity.recipient, payload) + if next_activity.webmention_target: + webmention_payload = { + "source": next_activity.outbox_object.url, + "target": next_activity.webmention_target, + } + logger.info(f"{webmention_payload=}") + resp = httpx.post( + next_activity.recipient, + data=webmention_payload, + headers={ + "User-Agent": config.USER_AGENT, + }, + ) + resp.raise_for_status() + else: + payload = ap.wrap_object_if_needed(next_activity.anybox_object.ap_object) + + # Use LD sig if the activity may need to be forwarded by recipients + if next_activity.anybox_object.is_from_outbox and payload["type"] in [ + "Create", + "Update", + "Delete", + ]: + # But only if the object is public (to help with deniability/privacy) + if next_activity.outbox_object.visibility == ap.VisibilityEnum.PUBLIC: + ldsig.generate_signature(payload, k) + + logger.info(f"{payload=}") + + resp = ap.post(next_activity.recipient, payload) except httpx.HTTPStatusError as http_error: logger.exception("Failed") next_activity.last_status_code = http_error.response.status_code diff --git a/tests/test_process_outgoing_activities.py b/tests/test_process_outgoing_activities.py index b2b3eaa..ee1b0be 100644 --- a/tests/test_process_outgoing_activities.py +++ b/tests/test_process_outgoing_activities.py @@ -85,6 +85,37 @@ def test_process_next_outgoing_activity__server_200( recipient=recipient_inbox_url, outbox_object_id=outbox_object.id, inbox_object_id=None, + webmention_target=None, + ) + + # When processing the next outgoing activity + # Then it is processed + assert process_next_outgoing_activity(db) is True + + assert respx_mock.calls.call_count == 1 + + outgoing_activity = db.query(models.OutgoingActivity).one() + assert outgoing_activity.is_sent is True + assert outgoing_activity.last_status_code == 204 + assert outgoing_activity.error is None + assert outgoing_activity.is_errored is False + + +def test_process_next_outgoing_activity__webmention( + db: Session, + respx_mock: respx.MockRouter, +) -> None: + # And an outgoing activity + outbox_object = _setup_outbox_object() + + recipient_url = "https://example.com/webmention" + respx_mock.post(recipient_url).mock(return_value=httpx.Response(204)) + + outgoing_activity = factories.OutgoingActivityFactory( + recipient=recipient_url, + outbox_object_id=outbox_object.id, + inbox_object_id=None, + webmention_target="http://example.com", ) # When processing the next outgoing activity @@ -114,6 +145,8 @@ def test_process_next_outgoing_activity__error_500( outgoing_activity = factories.OutgoingActivityFactory( recipient=recipient_inbox_url, outbox_object_id=outbox_object.id, + inbox_object_id=None, + webmention_target=None, ) # When processing the next outgoing activity @@ -144,6 +177,8 @@ def test_process_next_outgoing_activity__errored( outgoing_activity = factories.OutgoingActivityFactory( recipient=recipient_inbox_url, outbox_object_id=outbox_object.id, + inbox_object_id=None, + webmention_target=None, tries=_MAX_RETRIES - 1, ) @@ -176,6 +211,7 @@ def test_process_next_outgoing_activity__connect_error( recipient=recipient_inbox_url, outbox_object_id=outbox_object.id, inbox_object_id=None, + webmention_target=None, ) # When processing the next outgoing activity