From 138f7263a13c7212537b9ecb2753d8d5e991250f Mon Sep 17 00:00:00 2001 From: Jason Robinson Date: Mon, 12 Aug 2019 00:19:07 +0300 Subject: [PATCH 1/2] Extract images as entity children from ActivityPub payloads --- federation/entities/activitypub/mappers.py | 28 +++++++++- federation/entities/base.py | 11 ++-- federation/entities/diaspora/mappers.py | 9 ++-- .../entities/activitypub/test_mappers.py | 30 +++++------ .../tests/entities/diaspora/test_mappers.py | 7 +-- federation/tests/factories/entities.py | 6 +-- .../tests/fixtures/payloads/activitypub.py | 53 +++++++++++++++++++ 7 files changed, 105 insertions(+), 39 deletions(-) diff --git a/federation/entities/activitypub/mappers.py b/federation/entities/activitypub/mappers.py index cc9d903..31daead 100644 --- a/federation/entities/activitypub/mappers.py +++ b/federation/entities/activitypub/mappers.py @@ -5,7 +5,8 @@ from federation.entities.activitypub.constants import NAMESPACE_PUBLIC from federation.entities.activitypub.entities import ( ActivitypubFollow, ActivitypubProfile, ActivitypubAccept, ActivitypubPost, ActivitypubComment, ActivitypubRetraction, ActivitypubShare) -from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share +from federation.entities.activitypub.objects import ImageObject +from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share, Image from federation.entities.mixins import BaseEntity from federation.types import UserType, ReceiverVariant @@ -35,6 +36,7 @@ UNDO_MAPPINGS = { "Announce": ActivitypubRetraction, } + def element_to_objects(payload: Dict) -> List: """ Transform an Element to a list of entities. @@ -65,6 +67,9 @@ def element_to_objects(payload: Dict) -> List: entity._source_object = payload # Extract receivers entity._receivers = extract_receivers(payload) + # Extract children + if payload.get("object") and isinstance(payload.get("object"), dict): + entity._children = extract_attachments(payload.get("object")) if hasattr(entity, "post_receive"): entity.post_receive() @@ -84,6 +89,25 @@ def element_to_objects(payload: Dict) -> List: return entities +def extract_attachments(payload: Dict) -> List[Image]: + """ + Extract images from attachments. + + There could be other attachments, but currently we only extract images. + """ + attachments = [] + for item in payload.get('attachment', []): + # noinspection PyProtectedMember + if item.get("type") == "Document" and item.get("mediaType") in ImageObject._allowed_types: + attachments.append( + Image( + url=item.get('url'), + name=item.get('name') or "", + ) + ) + return attachments + + def extract_receiver(payload: Dict, receiver: str) -> Optional[UserType]: """ Transform a single receiver ID to a UserType. @@ -239,7 +263,7 @@ def transform_attribute(key: str, value: Union[str, Dict, int], transformed: Dic elif key == "inReplyTo": transformed["target_id"] = value elif key == "name": - transformed["name"] = value + transformed["name"] = value or "" elif key == "object" and not is_object: if isinstance(value, dict): if cls == ActivitypubAccept: diff --git a/federation/entities/base.py b/federation/entities/base.py index 29c291c..d666b2a 100644 --- a/federation/entities/base.py +++ b/federation/entities/base.py @@ -17,21 +17,18 @@ class Accept(CreatedAtMixin, TargetIDMixin, BaseEntity): self._required.remove('id') -class Image(PublicMixin, OptionalRawContentMixin, CreatedAtMixin, BaseEntity): +class Image(OptionalRawContentMixin, CreatedAtMixin, BaseEntity): """Reflects a single image, possibly linked to another object.""" - remote_path = "" - remote_name = "" - linked_type = "" - linked_guid = "" + url = "" + name = "" height = 0 width = 0 - url = "" _default_activity = ActivityType.CREATE def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._required += ["remote_path", "remote_name"] + self._required += ["url"] class Comment(RawContentMixin, ParticipationMixin, CreatedAtMixin, RootTargetIDMixin, BaseEntity): diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py index 45da275..f1c4451 100644 --- a/federation/entities/diaspora/mappers.py +++ b/federation/entities/diaspora/mappers.py @@ -2,6 +2,7 @@ import logging from datetime import datetime from typing import Callable, List +# noinspection PyPackageRequirements from Crypto.PublicKey.RSA import RsaKey from lxml import etree @@ -131,6 +132,7 @@ def element_to_objects( entity._mentions = entity.extract_mentions() # Do child elements for child in element: + # noinspection PyProtectedMember entity._children.extend(element_to_objects(child, sender, user=user)) # Add to entities list entities.append(entity) @@ -219,12 +221,7 @@ def transform_attributes(attrs, cls): elif key in ["target_type"] and cls == DiasporaRetraction: transformed["entity_type"] = DiasporaRetraction.entity_type_from_remote(value) elif key == "remote_photo_path": - transformed["remote_path"] = value - elif key == "remote_photo_name": - transformed["remote_name"] = value - elif key == "status_message_guid": - transformed["linked_guid"] = value - transformed["linked_type"] = "Post" + transformed["url"] = f"{value}{attrs.get('remote_photo_name')}" elif key == "author_signature": transformed["signature"] = value elif key in BOOLEAN_KEYS: diff --git a/federation/tests/entities/activitypub/test_mappers.py b/federation/tests/entities/activitypub/test_mappers.py index f3a4350..d29b7c2 100644 --- a/federation/tests/entities/activitypub/test_mappers.py +++ b/federation/tests/entities/activitypub/test_mappers.py @@ -6,10 +6,11 @@ from federation.entities.activitypub.entities import ( ActivitypubFollow, ActivitypubAccept, ActivitypubProfile, ActivitypubPost, ActivitypubComment, ActivitypubRetraction, ActivitypubShare) from federation.entities.activitypub.mappers import message_to_objects, get_outbound_entity -from federation.entities.base import Accept, Follow, Profile, Post, Comment +from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image from federation.tests.fixtures.payloads import ( ACTIVITYPUB_FOLLOW, ACTIVITYPUB_PROFILE, ACTIVITYPUB_PROFILE_INVALID, ACTIVITYPUB_UNDO_FOLLOW, ACTIVITYPUB_POST, - ACTIVITYPUB_COMMENT, ACTIVITYPUB_RETRACTION, ACTIVITYPUB_SHARE, ACTIVITYPUB_RETRACTION_SHARE) + ACTIVITYPUB_COMMENT, ACTIVITYPUB_RETRACTION, ACTIVITYPUB_SHARE, ACTIVITYPUB_RETRACTION_SHARE, + ACTIVITYPUB_POST_IMAGES) from federation.types import UserType, ReceiverVariant @@ -71,25 +72,22 @@ class TestActivitypubEntityMappersReceive: assert post.public is True assert getattr(post, "target_id", None) is None - @pytest.mark.skip def test_message_to_objects_post_with_photos(self): - entities = message_to_objects(DIASPORA_POST_WITH_PHOTOS, "alice@alice.diaspora.example.org") + entities = message_to_objects(ACTIVITYPUB_POST_IMAGES, "https://mastodon.social/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, DiasporaPost) + assert isinstance(post, ActivitypubPost) + assert len(post._children) == 1 photo = post._children[0] - assert isinstance(photo, DiasporaImage) - assert photo.remote_path == "https://alice.diaspora.example.org/uploads/images/" - assert photo.remote_name == "1234.jpg" + assert isinstance(photo, Image) + assert photo.url == "https://files.mastodon.social/media_attachments/files/017/642/079/original/" \ + "f51b0aee0ee1f2e1.jpg" + assert photo.name == "" assert photo.raw_content == "" - assert photo.linked_type == "Post" - assert photo.linked_guid == "((guidguidguidguidguidguidguid))" - assert photo.height == 120 - assert photo.width == 120 - assert photo.guid == "((guidguidguidguidguidguidguif))" - assert photo.handle == "alice@alice.diaspora.example.org" - assert photo.public == False - assert photo.created_at == datetime(2011, 7, 20, 1, 36, 7) + assert photo.height == 0 + assert photo.width == 0 + assert photo.guid == "" + assert photo.handle == "" def test_message_to_objects_comment(self): entities = message_to_objects(ACTIVITYPUB_COMMENT, "https://diaspodon.fr/users/jaywink") diff --git a/federation/tests/entities/diaspora/test_mappers.py b/federation/tests/entities/diaspora/test_mappers.py index 75ff181..69c75ae 100644 --- a/federation/tests/entities/diaspora/test_mappers.py +++ b/federation/tests/entities/diaspora/test_mappers.py @@ -51,16 +51,13 @@ class TestDiasporaEntityMappersReceive: assert isinstance(post, DiasporaPost) photo = post._children[0] assert isinstance(photo, DiasporaImage) - assert photo.remote_path == "https://alice.diaspora.example.org/uploads/images/" - assert photo.remote_name == "1234.jpg" + assert photo.url == "https://alice.diaspora.example.org/uploads/images/1234.jpg" + assert photo.name == "" assert photo.raw_content == "" - assert photo.linked_type == "Post" - assert photo.linked_guid == "((guidguidguidguidguidguidguid))" assert photo.height == 120 assert photo.width == 120 assert photo.guid == "((guidguidguidguidguidguidguif))" assert photo.handle == "alice@alice.diaspora.example.org" - assert photo.public == False assert photo.created_at == datetime(2011, 7, 20, 1, 36, 7) @patch("federation.entities.diaspora.mappers.DiasporaComment._validate_signatures") diff --git a/federation/tests/factories/entities.py b/federation/tests/factories/entities.py index 6b64f31..8207170 100644 --- a/federation/tests/factories/entities.py +++ b/federation/tests/factories/entities.py @@ -59,12 +59,12 @@ class DiasporaPostFactory(PostFactory): model = DiasporaPost -class ImageFactory(ActorIDMixinFactory, IDMixinFactory, PublicMixinFactory, factory.Factory): +class ImageFactory(ActorIDMixinFactory, IDMixinFactory, factory.Factory): class Meta: model = Image - remote_path = factory.Faker('uri') - remote_name = factory.Faker('file_path', extension='jpg') + url = factory.Faker('uri') + name = factory.Faker('slug') class ProfileFactory(IDMixinFactory, RawContentMixinFactory, factory.Factory): diff --git a/federation/tests/fixtures/payloads/activitypub.py b/federation/tests/fixtures/payloads/activitypub.py index 92d03b5..1fd251b 100644 --- a/federation/tests/fixtures/payloads/activitypub.py +++ b/federation/tests/fixtures/payloads/activitypub.py @@ -319,3 +319,56 @@ ACTIVITYPUB_POST_OBJECT = { 'partOf': 'https://diaspodon.fr/users/jaywink/statuses/102356911717767237/replies', 'items': []}}, } + +ACTIVITYPUB_POST_IMAGES = {'@context': ['https://www.w3.org/ns/activitystreams', + {'ostatus': 'http://ostatus.org#', + 'atomUri': 'ostatus:atomUri', + 'inReplyToAtomUri': 'ostatus:inReplyToAtomUri', + 'conversation': 'ostatus:conversation', + 'sensitive': 'as:sensitive', + 'Hashtag': 'as:Hashtag', + 'toot': 'http://joinmastodon.org/ns#', + 'Emoji': 'toot:Emoji', + 'focalPoint': {'@container': '@list', '@id': 'toot:focalPoint'}, + 'blurhash': 'toot:blurhash'}], + 'id': 'https://mastodon.social/users/jaywink/statuses/102611770245850345/activity', + 'type': 'Create', + 'actor': 'https://mastodon.social/users/jaywink', + 'published': '2019-08-13T21:22:37Z', + 'to': ['https://www.w3.org/ns/activitystreams#Public'], + 'cc': ['https://mastodon.social/users/jaywink/followers'], + 'object': {'id': 'https://mastodon.social/users/jaywink/statuses/102611770245850345', + 'type': 'Note', + 'summary': None, + 'inReplyTo': None, + 'published': '2019-08-13T21:22:37Z', + 'url': 'https://mastodon.social/@jaywink/102611770245850345', + 'attributedTo': 'https://mastodon.social/users/jaywink', + 'to': ['https://www.w3.org/ns/activitystreams#Public'], + 'cc': ['https://mastodon.social/users/jaywink/followers'], + 'sensitive': False, + 'atomUri': 'https://mastodon.social/users/jaywink/statuses/102611770245850345', + 'inReplyToAtomUri': None, + 'conversation': 'tag:mastodon.social,2019-08-13:objectId=119290371:objectType=Conversation', + 'content': '

image test

', + 'contentMap': {'en': '

image test

'}, + 'attachment': [{'type': 'Document', + 'mediaType': 'image/jpeg', + 'url': 'https://files.mastodon.social/media_attachments/files/017/642/079/original/f51b0aee0ee1f2e1.jpg', + 'name': None, + 'blurhash': 'UaH1x+IpD*RktToft6s:0f%2tQj@xsWWRkNG'}, + {'type': 'Document', + 'mediaType': 'video/mp4', + 'url': 'https://files.mastodon.social/media_attachments/files/017/642/084/original/e18dda257e5e7078.mp4', + 'name': None, + 'blurhash': 'UH9jv0ay00Rj%MM{IU%M%MWBRjofxuayM{t7'}], + 'tag': [], + 'replies': {'id': 'https://mastodon.social/users/jaywink/statuses/102611770245850345/replies', + 'type': 'Collection', + 'first': {'type': 'CollectionPage', + 'partOf': 'https://mastodon.social/users/jaywink/statuses/102611770245850345/replies', + 'items': []}}}, + 'signature': {'type': 'RsaSignature2017', + 'creator': 'https://mastodon.social/users/jaywink#main-key', + 'created': '2019-08-13T21:22:37Z', + 'signatureValue': 'Ia61wdHHIy9gCY5YwqlPtd80eJ2liT9Yi3yHdRdP+fQ5/9np3wHJKNPa7gdzP/BiRzh6aOa2dHWJjB8mOnHYrYBn6Fl3RlCniqousVTDue/ek0zvcFWmlhfja02meDiva+t61O/6Ul1l4tQObMorSf7GbEPePlQiozr/SR/5HIj3SDP0Y8JmlTvhSFgiH6obdroaIYEMQAoYZVcYofGeQUEhotDRp0OGQ4UaPBli4WyzVOUqHMW6pw90QQzZF9XpimwAemk9oAgPmGEPkugFeHfrWt1l84KLdwqwWD8FRIep7gCtu6MpCA8TX4JC5yJvyQ9GbZLZfJSQ6t5wSrcafw=='}} From c2ae43fd4d3c5e4ed4b92c2c180bc6398b9145eb Mon Sep 17 00:00:00 2001 From: Jason Robinson Date: Sat, 17 Aug 2019 18:08:01 +0300 Subject: [PATCH 2/2] Move allowed image types to a constant in AP objects --- federation/entities/activitypub/mappers.py | 4 ++-- federation/entities/activitypub/objects.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/federation/entities/activitypub/mappers.py b/federation/entities/activitypub/mappers.py index 31daead..12b6e3b 100644 --- a/federation/entities/activitypub/mappers.py +++ b/federation/entities/activitypub/mappers.py @@ -5,7 +5,7 @@ from federation.entities.activitypub.constants import NAMESPACE_PUBLIC from federation.entities.activitypub.entities import ( ActivitypubFollow, ActivitypubProfile, ActivitypubAccept, ActivitypubPost, ActivitypubComment, ActivitypubRetraction, ActivitypubShare) -from federation.entities.activitypub.objects import ImageObject +from federation.entities.activitypub.objects import IMAGE_TYPES from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share, Image from federation.entities.mixins import BaseEntity from federation.types import UserType, ReceiverVariant @@ -98,7 +98,7 @@ def extract_attachments(payload: Dict) -> List[Image]: attachments = [] for item in payload.get('attachment', []): # noinspection PyProtectedMember - if item.get("type") == "Document" and item.get("mediaType") in ImageObject._allowed_types: + if item.get("type") == "Document" and item.get("mediaType") in IMAGE_TYPES: attachments.append( Image( url=item.get('url'), diff --git a/federation/entities/activitypub/objects.py b/federation/entities/activitypub/objects.py index 6745001..c6854bf 100644 --- a/federation/entities/activitypub/objects.py +++ b/federation/entities/activitypub/objects.py @@ -2,17 +2,18 @@ import attr from federation.utils.network import fetch_content_type +IMAGE_TYPES = ( + "image/jpeg", + "image/png", + "image/gif", +) + @attr.s class ImageObject: """ An Image object for AS2 serialization. """ - _allowed_types = ( - "image/jpeg", - "image/png", - "image/gif", - ) url: str = attr.ib() type: str = attr.ib(default="Image") mediaType: str = attr.ib() @@ -20,6 +21,6 @@ class ImageObject: @mediaType.default def cache_media_type(self): content_type = fetch_content_type(self.url) - if content_type in self._allowed_types: + if content_type in IMAGE_TYPES: return content_type return ""