diff --git a/activities/models/post.py b/activities/models/post.py index 1c342cc..d5dca88 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -1,6 +1,7 @@ import re from collections.abc import Iterable from typing import Optional +from urllib.parse import urlparse import httpx import urlman @@ -660,6 +661,9 @@ class Post(StatorModel): Raises DoesNotExist if it's not found and create is False, or it's from a blocked domain. """ + # Ensure the domain of the object's actor and ID match to prevent injection + if urlparse(data["id"]).hostname != urlparse(data["attributedTo"]).hostname: + raise ValueError("Object's ID domain is different to its author") # Do we have one with the right ID? created = False try: @@ -828,9 +832,14 @@ class Post(StatorModel): Handles an incoming delete request """ with transaction.atomic(): + # Is this an embedded object or plain ID? + if isinstance(data["object"], str): + object_uri = data["object"] + else: + object_uri = data["object"]["id"] # Find our post by ID if we have one try: - post = cls.by_object_uri(data["object"]["id"]) + post = cls.by_object_uri(object_uri) except cls.DoesNotExist: # It's already been deleted return diff --git a/stator/runner.py b/stator/runner.py index 593f658..05ed4bc 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -179,13 +179,11 @@ class StatorRunner: async def run_single_cycle(self): """ - Testing entrypoint to advance things just one cycle + Testing entrypoint to advance things just one cycle, and allow errors + to propagate out. """ await asyncio.wait_for(self.fetch_and_process_tasks(), timeout=1) - for _ in range(100): - if not self.tasks: - break - self.remove_completed_tasks() - await asyncio.sleep(0.05) + for task in self.tasks: + await task run_single_cycle_sync = async_to_sync(run_single_cycle) diff --git a/tests/activities/models/test_post.py b/tests/activities/models/test_post.py index c50f8eb..e37e42d 100644 --- a/tests/activities/models/test_post.py +++ b/tests/activities/models/test_post.py @@ -2,6 +2,7 @@ import pytest from pytest_httpx import HTTPXMock from activities.models import Post, PostStates +from users.models import Identity, InboxMessage @pytest.mark.django_db @@ -237,3 +238,97 @@ def test_content_map(remote_identity): create=True, ) assert post3.content == "Hello World" + + +@pytest.mark.django_db +@pytest.mark.parametrize("delete_type", ["note", "tombstone", "ref"]) +def test_inbound_posts( + remote_identity: Identity, + stator, + delete_type: bool, +): + """ + Ensures that a remote post can arrive via inbox message, be edited, and be + deleted. + """ + # Create an inbound new post message + message = { + "id": "test", + "type": "Create", + "actor": remote_identity.actor_uri, + "object": { + "id": "https://remote.test/test-post", + "type": "Note", + "published": "2022-11-13T23:20:16Z", + "attributedTo": remote_identity.actor_uri, + "content": "post version one", + }, + } + InboxMessage.objects.create(message=message) + + # Run stator and ensure that made the post + stator.run_single_cycle_sync() + post = Post.objects.get(object_uri="https://remote.test/test-post") + assert post.content == "post version one" + assert post.published.day == 13 + assert post.url == "https://remote.test/test-post" + + # Create an inbound post edited message + message = { + "id": "test", + "type": "Update", + "actor": remote_identity.actor_uri, + "object": { + "id": "https://remote.test/test-post", + "type": "Note", + "published": "2022-11-13T23:20:16Z", + "updated": "2022-11-14T23:20:16Z", + "url": "https://remote.test/test-post/display", + "attributedTo": remote_identity.actor_uri, + "content": "post version two", + }, + } + InboxMessage.objects.create(message=message) + + # Run stator and ensure that edited the post + stator.run_single_cycle_sync() + post = Post.objects.get(object_uri="https://remote.test/test-post") + assert post.content == "post version two" + assert post.edited.day == 14 + assert post.url == "https://remote.test/test-post/display" + + # Create an inbound post deleted message + if delete_type == "ref": + message = { + "id": "test", + "type": "Delete", + "actor": remote_identity.actor_uri, + "object": "https://remote.test/test-post", + } + elif delete_type == "tombstone": + message = { + "id": "test", + "type": "Delete", + "actor": remote_identity.actor_uri, + "object": { + "id": "https://remote.test/test-post", + "type": "Tombstone", + }, + } + else: + message = { + "id": "test", + "type": "Delete", + "actor": remote_identity.actor_uri, + "object": { + "id": "https://remote.test/test-post", + "type": "Note", + "published": "2022-11-13T23:20:16Z", + "attributedTo": remote_identity.actor_uri, + }, + } + InboxMessage.objects.create(message=message) + + # Run stator and ensure that deleted the post + stator.run_single_cycle_sync() + assert not Post.objects.filter(object_uri="https://remote.test/test-post").exists() diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index beaadff..962c3d2 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -98,9 +98,20 @@ class InboxMessageStates(StateGraph): f"Cannot handle activity of type undo.{unknown}" ) case "delete": - # If there is no object type, it's probably a profile + # If there is no object type, we need to see if it's a profile or a post if not isinstance(instance.message["object"], dict): - await sync_to_async(Identity.handle_delete_ap)(instance.message) + if await Identity.objects.filter( + actor_uri=instance.message["object"] + ).aexists(): + await sync_to_async(Identity.handle_delete_ap)(instance.message) + elif await Post.objects.filter( + object_uri=instance.message["object"] + ).aexists(): + await sync_to_async(Post.handle_delete_ap)(instance.message) + else: + raise ValueError( + f"Cannot handle activity of type delete on URI {instance.message['object']}" + ) else: match instance.message_object_type: case "tombstone":