Support for sending webmentions as outgoing activities

main
Thomas Sileo 2022-07-10 14:29:28 +02:00
parent 9417f38cc7
commit c3c4475e24
4 changed files with 104 additions and 14 deletions

View File

@ -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 ###

View File

@ -331,6 +331,9 @@ class OutgoingActivity(Base):
inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True) inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True)
inbox_object = relationship(InboxObject, uselist=False) 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) tries = Column(Integer, nullable=False, default=0)
next_try = Column(DateTime(timezone=True), nullable=True, default=now) 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) id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=now) created_at = Column(DateTime(timezone=True), nullable=False, default=now)
# Will be null for personal access tokens
indieauth_authorization_request_id = Column( indieauth_authorization_request_id = Column(
Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True
) )

View File

@ -126,16 +126,20 @@ async def new_outgoing_activity(
recipient: str, recipient: str,
outbox_object_id: int | None, outbox_object_id: int | None,
inbox_object_id: int | None = None, inbox_object_id: int | None = None,
webmention_target: str | None = None,
) -> models.OutgoingActivity: ) -> models.OutgoingActivity:
if outbox_object_id is None and inbox_object_id is None: if outbox_object_id is None and inbox_object_id is None:
raise ValueError("Must reference at least one inbox/outbox activity") 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") raise ValueError("Cannot reference both inbox/outbox activities")
outgoing_activity = models.OutgoingActivity( outgoing_activity = models.OutgoingActivity(
recipient=recipient, recipient=recipient,
outbox_object_id=outbox_object_id, outbox_object_id=outbox_object_id,
inbox_object_id=inbox_object_id, inbox_object_id=inbox_object_id,
webmention_target=webmention_target,
) )
db_session.add(outgoing_activity) 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.tries = next_activity.tries + 1
next_activity.last_try = now() 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: 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: except httpx.HTTPStatusError as http_error:
logger.exception("Failed") logger.exception("Failed")
next_activity.last_status_code = http_error.response.status_code next_activity.last_status_code = http_error.response.status_code

View File

@ -85,6 +85,37 @@ def test_process_next_outgoing_activity__server_200(
recipient=recipient_inbox_url, recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id, outbox_object_id=outbox_object.id,
inbox_object_id=None, 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 # When processing the next outgoing activity
@ -114,6 +145,8 @@ def test_process_next_outgoing_activity__error_500(
outgoing_activity = factories.OutgoingActivityFactory( outgoing_activity = factories.OutgoingActivityFactory(
recipient=recipient_inbox_url, recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id, outbox_object_id=outbox_object.id,
inbox_object_id=None,
webmention_target=None,
) )
# When processing the next outgoing activity # When processing the next outgoing activity
@ -144,6 +177,8 @@ def test_process_next_outgoing_activity__errored(
outgoing_activity = factories.OutgoingActivityFactory( outgoing_activity = factories.OutgoingActivityFactory(
recipient=recipient_inbox_url, recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id, outbox_object_id=outbox_object.id,
inbox_object_id=None,
webmention_target=None,
tries=_MAX_RETRIES - 1, tries=_MAX_RETRIES - 1,
) )
@ -176,6 +211,7 @@ def test_process_next_outgoing_activity__connect_error(
recipient=recipient_inbox_url, recipient=recipient_inbox_url,
outbox_object_id=outbox_object.id, outbox_object_id=outbox_object.id,
inbox_object_id=None, inbox_object_id=None,
webmention_target=None,
) )
# When processing the next outgoing activity # When processing the next outgoing activity