kopia lustrzana https://gitlab.com/jaywink/federation
Render Activitypub outbound payloads with calamus.
rodzic
bb6cc724f3
commit
9df803dafe
46
CHANGELOG.md
46
CHANGELOG.md
|
@ -4,18 +4,52 @@
|
|||
|
||||
### Added
|
||||
|
||||
* Inbound Activitypub payloads are now processed by calamus (https://github.com/SwissDataScienceCenter/calamus),
|
||||
* Activitypub payloads are now processed by calamus (https://github.com/SwissDataScienceCenter/calamus),
|
||||
which is a jsonld processor based on marshmallow.
|
||||
|
||||
* For performance, requests_cache has been added. It pulls a redis configuration from django if one exists or
|
||||
falls back to a sqlite backend.
|
||||
* A large number of inbound Activitypub objects and properties are deserialized, it's up to the client
|
||||
app to implement the corresponding behavior.
|
||||
|
||||
* GET requests are now signed if the django configuration includes FEDERATION_USER which is used to fetch that
|
||||
* Unsupported objects and properties should be easy to implement. Unsupported payloads are logged as such.
|
||||
|
||||
* More AP platforms are now supported (friendica, pixelfed, misskey, pleroma, gotosocial, litepub, and more).
|
||||
The jsonld context some platforms provide sometimes needs to be patched because of missing jsonld term definitions.
|
||||
|
||||
* Peertube Video objects are translated into Posts.
|
||||
|
||||
* For performance, requests_cache has been added. It pulls a redis configuration from django if one exists or
|
||||
falls back to a sqlite backend. Special case: pyld document loader has been extended to use redis directly.
|
||||
|
||||
* Activitypub GET requests are now signed if the django configuration includes FEDERATION_USER which is used to fetch that
|
||||
user's private key.
|
||||
|
||||
* Activitypub remote GET signature is now verified in order to authorize remote access to limited content.
|
||||
|
||||
* Added Video and Audio objects. Inbound support only.
|
||||
|
||||
* Process Activitypub reply collections.
|
||||
* Process Activitypub reply collections. When supported by the client app, it allows for a more complete view of
|
||||
conversations, especially for shared content.
|
||||
|
||||
* WIP: initial support for providing reponses to Activitypub collections requests. This release
|
||||
only responds with a count for the followers and following collections.
|
||||
|
||||
### Changed
|
||||
|
||||
* outbound.py doesn't need to set the to and cc Activitypub properties, they are now expected to be set by
|
||||
the client app.
|
||||
|
||||
* Attempts are made to remove duplicate img tags some platforms send (friendica, for one).
|
||||
|
||||
* Activitypub receivers of the followers variant are now correctly processed for all known platforms.
|
||||
|
||||
* Accept images with application/octet-stream content type (with the help of the magic library).
|
||||
|
||||
* user@domain is now the only format used for mentions. The client app is expected to comply. For
|
||||
Activitypub, this means making a webfinger request to validate the handle if the client app doesn't
|
||||
already know the corresponding profile.
|
||||
|
||||
* Because of the change above, ensure mentions in Diaspora outbound payloads are as per their protocol
|
||||
spec (i.e. replacing @user@domain with @{user@domain} in the text)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -25,6 +59,8 @@
|
|||
|
||||
* Dropped python 3.6 support.
|
||||
|
||||
* Many tests were fixed/updated.
|
||||
|
||||
## [0.22.0] - 2021-08-15
|
||||
|
||||
### Added
|
||||
|
|
|
@ -11,4 +11,12 @@ CONTEXTS_DEFAULT = [
|
|||
CONTEXT_PYTHON_FEDERATION,
|
||||
]
|
||||
|
||||
CONTEXT = [CONTEXT_ACTIVITYSTREAMS, CONTEXT_LD_SIGNATURES]
|
||||
CONTEXT_DICT = {}
|
||||
for ctx in [CONTEXT_DIASPORA, CONTEXT_HASHTAG, CONTEXT_MANUALLY_APPROVES_FOLLOWERS, CONTEXT_SENSITIVE, CONTEXT_PYTHON_FEDERATION]:
|
||||
CONTEXT_DICT.update(ctx)
|
||||
CONTEXT_SETS = {prop: {'@id': f'as:{prop}', '@container': '@set'} for prop in ['to', 'cc', 'tag', 'attachment']}
|
||||
CONTEXT_DICT.update(CONTEXT_SETS)
|
||||
CONTEXT.append(CONTEXT_DICT)
|
||||
|
||||
NAMESPACE_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
|
||||
|
|
|
@ -1,8 +1,40 @@
|
|||
from cryptography.exceptions import InvalidSignature
|
||||
from django.http import JsonResponse, HttpResponse, HttpResponseNotFound
|
||||
from requests_http_signature import HTTPSignatureHeaderAuth
|
||||
|
||||
from federation.entities.activitypub.mappers import get_outbound_entity
|
||||
from federation.protocols.activitypub.signing import verify_request_signature
|
||||
from federation.types import RequestType
|
||||
from federation.utils.django import get_function_from_config
|
||||
|
||||
|
||||
def get_and_verify_signer(request):
|
||||
"""
|
||||
A remote user might be allowed to access retricted content
|
||||
if a valid signature is provided.
|
||||
|
||||
Only done for content.
|
||||
"""
|
||||
# TODO: revisit this when we start responding to sending follow[ing,ers] collections
|
||||
if request.path.startswith('/u/'): return None
|
||||
get_public_key = get_function_from_config('get_public_key_function')
|
||||
if not request.headers.get('Signature'): return None
|
||||
req = RequestType(
|
||||
url=request.build_absolute_uri(),
|
||||
body=request.body,
|
||||
method=request.method,
|
||||
headers=request.headers)
|
||||
sig = HTTPSignatureHeaderAuth.get_sig_struct(req)
|
||||
signer = sig.get('keyId', '').split('#')[0]
|
||||
key = get_public_key(signer)
|
||||
if key:
|
||||
try:
|
||||
verify_request_signature(req, key)
|
||||
return signer
|
||||
except InvalidSignature:
|
||||
return None
|
||||
|
||||
|
||||
def activitypub_object_view(func):
|
||||
"""
|
||||
Generic ActivityPub object view decorator.
|
||||
|
@ -27,11 +59,11 @@ def activitypub_object_view(func):
|
|||
return func(request, *args, **kwargs)
|
||||
|
||||
get_object_function = get_function_from_config('get_object_function')
|
||||
obj = get_object_function(request)
|
||||
obj = get_object_function(request, get_and_verify_signer(request))
|
||||
if not obj:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
as2_obj = obj.as_protocol('activitypub')
|
||||
as2_obj = get_outbound_entity(obj, None)
|
||||
return JsonResponse(as2_obj.to_as2(), content_type='application/activity+json')
|
||||
|
||||
def post(request, *args, **kwargs):
|
||||
|
@ -44,7 +76,7 @@ def activitypub_object_view(func):
|
|||
|
||||
if request.method == 'GET':
|
||||
return get(request, *args, **kwargs)
|
||||
elif request.method == 'POST' and request.path.endswith('/inbox/'):
|
||||
elif request.method == 'POST' and request.path.startswith('/u/') and request.path.endswith('/inbox/'):
|
||||
return post(request, *args, **kwargs)
|
||||
|
||||
return HttpResponse(status=405)
|
||||
|
|
|
@ -1,403 +0,0 @@
|
|||
import logging
|
||||
import uuid
|
||||
from typing import Dict, List
|
||||
|
||||
import bleach
|
||||
|
||||
from federation.entities.activitypub.constants import (
|
||||
CONTEXTS_DEFAULT, CONTEXT_MANUALLY_APPROVES_FOLLOWERS, CONTEXT_SENSITIVE, CONTEXT_HASHTAG,
|
||||
CONTEXT_LD_SIGNATURES, CONTEXT_DIASPORA)
|
||||
from federation.entities.activitypub.enums import ActorType, ObjectType, ActivityType
|
||||
from federation.entities.base import Profile, Post, Follow, Accept, Comment, Retraction, Share, Image, Audio, Video
|
||||
from federation.entities.mixins import RawContentMixin, BaseEntity, PublicMixin, CreatedAtMixin
|
||||
from federation.entities.utils import get_base_attributes
|
||||
from federation.outbound import handle_send
|
||||
from federation.types import UserType
|
||||
from federation.utils.django import get_configuration
|
||||
from federation.utils.text import with_slash, validate_handle
|
||||
|
||||
logger = logging.getLogger("federation")
|
||||
|
||||
|
||||
class AttachImagesMixin(RawContentMixin):
|
||||
def pre_send(self) -> None:
|
||||
"""
|
||||
Attach any embedded images from raw_content.
|
||||
"""
|
||||
super().pre_send()
|
||||
for image in self.embedded_images:
|
||||
self._children.append(
|
||||
ActivitypubImage(
|
||||
url=image[0],
|
||||
name=image[1],
|
||||
inline=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ActivitypubEntityMixin(BaseEntity):
|
||||
_type = None
|
||||
|
||||
@classmethod
|
||||
def from_base(cls, entity):
|
||||
# noinspection PyArgumentList
|
||||
return cls(**get_base_attributes(entity))
|
||||
|
||||
def to_string(self):
|
||||
# noinspection PyUnresolvedReferences
|
||||
return str(self.to_as2())
|
||||
|
||||
|
||||
class CleanContentMixin(RawContentMixin):
|
||||
def post_receive(self) -> None:
|
||||
"""
|
||||
Make linkified tags normal tags.
|
||||
"""
|
||||
super().post_receive()
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
def remove_tag_links(attrs, new=False):
|
||||
rel = (None, "rel")
|
||||
if attrs.get(rel) == "tag":
|
||||
return
|
||||
return attrs
|
||||
|
||||
if self._media_type == "text/markdown":
|
||||
# Skip when markdown
|
||||
return
|
||||
|
||||
self.raw_content = bleach.linkify(
|
||||
self.raw_content,
|
||||
callbacks=[remove_tag_links],
|
||||
parse_email=False,
|
||||
skip_tags=["code", "pre"],
|
||||
)
|
||||
|
||||
|
||||
class ActivitypubAccept(ActivitypubEntityMixin, Accept):
|
||||
_type = ActivityType.ACCEPT.value
|
||||
object: Dict = None
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"id": self.activity_id,
|
||||
"type": self._type,
|
||||
"actor": self.actor_id,
|
||||
"object": self.object,
|
||||
}
|
||||
return as2
|
||||
|
||||
|
||||
class ActivitypubNoteMixin(AttachImagesMixin, CleanContentMixin, PublicMixin, CreatedAtMixin, ActivitypubEntityMixin):
|
||||
_type = ObjectType.NOTE.value
|
||||
url = ""
|
||||
|
||||
def add_object_tags(self) -> List[Dict]:
|
||||
"""
|
||||
Populate tags to the object.tag list.
|
||||
"""
|
||||
tags = []
|
||||
try:
|
||||
config = get_configuration()
|
||||
except ImportError:
|
||||
tags_path = None
|
||||
else:
|
||||
if config["tags_path"]:
|
||||
tags_path = f"{config['base_url']}{config['tags_path']}"
|
||||
else:
|
||||
tags_path = None
|
||||
for tag in self.tags:
|
||||
_tag = {
|
||||
'type': 'Hashtag',
|
||||
'name': f'#{tag}',
|
||||
}
|
||||
if tags_path:
|
||||
_tag["href"] = tags_path.replace(":tag:", tag)
|
||||
tags.append(_tag)
|
||||
return tags
|
||||
|
||||
def extract_mentions(self):
|
||||
"""
|
||||
Extract mentions from the source object.
|
||||
"""
|
||||
super().extract_mentions()
|
||||
|
||||
if getattr(self, 'tag_list', None):
|
||||
from federation.entities.activitypub.models import Mention # Circulars
|
||||
tag_list = self.tag_list if isinstance(self.tag_list, list) else [self.tag_list]
|
||||
for tag in tag_list:
|
||||
if isinstance(tag, Mention):
|
||||
self._mentions.add(tag.href)
|
||||
|
||||
def pre_send(self):
|
||||
super().pre_send()
|
||||
self.extract_mentions()
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT + [
|
||||
CONTEXT_HASHTAG,
|
||||
CONTEXT_LD_SIGNATURES,
|
||||
CONTEXT_SENSITIVE,
|
||||
],
|
||||
"type": self.activity.value,
|
||||
"id": self.activity_id,
|
||||
"actor": self.actor_id,
|
||||
"object": {
|
||||
"id": self.id,
|
||||
"type": self._type,
|
||||
"attributedTo": self.actor_id,
|
||||
"content": self.rendered_content,
|
||||
"published": self.created_at.isoformat(),
|
||||
"inReplyTo": None,
|
||||
"sensitive": True if "nsfw" in self.tags else False,
|
||||
"summary": None, # TODO Short text? First sentence? First line?
|
||||
"url": self.url,
|
||||
'source': {
|
||||
'content': self.raw_content,
|
||||
'mediaType': self._media_type,
|
||||
},
|
||||
"tag": [],
|
||||
},
|
||||
"published": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
if len(self._children):
|
||||
as2["object"]["attachment"] = []
|
||||
for child in self._children:
|
||||
as2["object"]["attachment"].append(child.to_as2())
|
||||
|
||||
if len(self._mentions):
|
||||
mentions = list(self._mentions)
|
||||
mentions.sort()
|
||||
for mention in mentions:
|
||||
if mention.startswith("http"):
|
||||
as2["object"]["tag"].append({
|
||||
'type': 'Mention',
|
||||
'href': mention,
|
||||
'name': mention,
|
||||
})
|
||||
elif validate_handle(mention):
|
||||
# Look up via WebFinger
|
||||
as2["object"]["tag"].append({
|
||||
'type': 'Mention',
|
||||
'href': mention, # TODO need to implement fetch via webfinger for AP handles first
|
||||
'name': mention,
|
||||
})
|
||||
|
||||
as2["object"]["tag"].extend(self.add_object_tags())
|
||||
|
||||
if self.guid:
|
||||
as2["@context"].append(CONTEXT_DIASPORA)
|
||||
as2["object"]["diaspora:guid"] = self.guid
|
||||
|
||||
return as2
|
||||
|
||||
|
||||
class ActivitypubComment(ActivitypubNoteMixin, Comment):
|
||||
entity_type = "Comment"
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
as2 = super().to_as2()
|
||||
as2["object"]["inReplyTo"] = self.target_id
|
||||
return as2
|
||||
|
||||
|
||||
class ActivitypubFollow(ActivitypubEntityMixin, Follow):
|
||||
_type = ActivityType.FOLLOW.value
|
||||
|
||||
def post_receive(self) -> None:
|
||||
"""
|
||||
Post receive hook - send back follow ack.
|
||||
"""
|
||||
super().post_receive()
|
||||
|
||||
if not self.following:
|
||||
return
|
||||
|
||||
from federation.utils.activitypub import retrieve_and_parse_profile # Circulars
|
||||
try:
|
||||
from federation.utils.django import get_function_from_config
|
||||
get_private_key_function = get_function_from_config("get_private_key_function")
|
||||
except (ImportError, AttributeError):
|
||||
logger.warning("ActivitypubFollow.post_receive - Unable to send automatic Accept back, only supported on "
|
||||
"Django currently")
|
||||
return
|
||||
key = get_private_key_function(self.target_id)
|
||||
if not key:
|
||||
logger.warning("ActivitypubFollow.post_receive - Failed to send automatic Accept back: could not find "
|
||||
"profile to sign it with")
|
||||
return
|
||||
accept = ActivitypubAccept(
|
||||
activity_id=f"{self.target_id}#accept-{uuid.uuid4()}",
|
||||
actor_id=self.target_id,
|
||||
target_id=self.activity_id,
|
||||
object=self.to_as2(),
|
||||
)
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
profile = retrieve_and_parse_profile(self.actor_id)
|
||||
except Exception:
|
||||
profile = None
|
||||
if not profile:
|
||||
logger.warning("ActivitypubFollow.post_receive - Failed to fetch remote profile for sending back Accept")
|
||||
return
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
handle_send(
|
||||
accept,
|
||||
UserType(id=self.target_id, private_key=key),
|
||||
recipients=[{
|
||||
"endpoint": profile.inboxes["private"],
|
||||
"fid": self.actor_id,
|
||||
"protocol": "activitypub",
|
||||
"public": False,
|
||||
}],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ActivitypubFollow.post_receive - Failed to send Accept back")
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
if self.following:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"id": self.activity_id,
|
||||
"type": self._type,
|
||||
"actor": self.actor_id,
|
||||
"object": self.target_id,
|
||||
}
|
||||
else:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"id": self.activity_id,
|
||||
"type": ActivityType.UNDO.value,
|
||||
"actor": self.actor_id,
|
||||
"object": {
|
||||
"id": f"{self.actor_id}#follow-{uuid.uuid4()}",
|
||||
"type": self._type,
|
||||
"actor": self.actor_id,
|
||||
"object": self.target_id,
|
||||
},
|
||||
}
|
||||
return as2
|
||||
|
||||
|
||||
class ActivitypubImage(ActivitypubEntityMixin, Image):
|
||||
_type = ObjectType.IMAGE.value
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
return {
|
||||
"type": self._type,
|
||||
"url": self.url,
|
||||
"mediaType": self.media_type,
|
||||
"name": self.name,
|
||||
"pyfed:inlineImage": self.inline,
|
||||
}
|
||||
|
||||
class ActivitypubAudio(ActivitypubEntityMixin, Audio):
|
||||
pass
|
||||
|
||||
class ActivitypubVideo(ActivitypubEntityMixin, Video):
|
||||
pass
|
||||
|
||||
class ActivitypubPost(ActivitypubNoteMixin, Post):
|
||||
pass
|
||||
|
||||
|
||||
class ActivitypubProfile(ActivitypubEntityMixin, Profile):
|
||||
_type = ActorType.PERSON.value
|
||||
public = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT + [
|
||||
CONTEXT_LD_SIGNATURES,
|
||||
CONTEXT_MANUALLY_APPROVES_FOLLOWERS,
|
||||
],
|
||||
"endpoints": {
|
||||
"sharedInbox": self.inboxes["public"],
|
||||
},
|
||||
"followers": f"{with_slash(self.id)}followers/",
|
||||
"following": f"{with_slash(self.id)}following/",
|
||||
"id": self.id,
|
||||
"inbox": self.inboxes["private"],
|
||||
"manuallyApprovesFollowers": False,
|
||||
"name": self.name,
|
||||
"outbox": f"{with_slash(self.id)}outbox/",
|
||||
"publicKey": {
|
||||
"id": f"{self.id}#main-key",
|
||||
"owner": self.id,
|
||||
"publicKeyPem": self.public_key,
|
||||
},
|
||||
"type": self._type,
|
||||
"url": self.url,
|
||||
}
|
||||
if self.username:
|
||||
as2['preferredUsername'] = self.username
|
||||
if self.raw_content:
|
||||
as2['summary'] = self.raw_content
|
||||
if self.image_urls.get('large'):
|
||||
try:
|
||||
profile_icon = ActivitypubImage(url=self.image_urls.get('large'))
|
||||
if profile_icon.media_type:
|
||||
as2['icon'] = profile_icon.to_as2()
|
||||
except Exception as ex:
|
||||
logger.warning("ActivitypubProfile.to_as2 - failed to set profile icon: %s", ex)
|
||||
|
||||
if self.guid or self.handle:
|
||||
as2["@context"].append(CONTEXT_DIASPORA)
|
||||
if self.guid:
|
||||
as2["diaspora:guid"] = self.guid
|
||||
if self.handle:
|
||||
as2["diaspora:handle"] = self.handle
|
||||
|
||||
return as2
|
||||
|
||||
|
||||
class ActivitypubRetraction(ActivitypubEntityMixin, Retraction):
|
||||
def resolve_object_type(self):
|
||||
return {
|
||||
"Comment": ObjectType.TOMBSTONE.value,
|
||||
"Post": ObjectType.TOMBSTONE.value,
|
||||
"Share": ActivityType.ANNOUNCE.value,
|
||||
}.get(self.entity_type)
|
||||
|
||||
def resolve_type(self):
|
||||
return {
|
||||
"Comment": ActivityType.DELETE.value,
|
||||
"Post": ActivityType.DELETE.value,
|
||||
"Share": ActivityType.UNDO.value,
|
||||
}.get(self.entity_type)
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"id": self.activity_id,
|
||||
"type": self.resolve_type(),
|
||||
"actor": self.actor_id,
|
||||
"object": {
|
||||
"id": self.target_id,
|
||||
"type": self.resolve_object_type(),
|
||||
},
|
||||
"published": self.created_at.isoformat(),
|
||||
}
|
||||
return as2
|
||||
|
||||
|
||||
class ActivitypubShare(ActivitypubEntityMixin, Share):
|
||||
_type = ActivityType.ANNOUNCE.value
|
||||
|
||||
def to_as2(self) -> Dict:
|
||||
as2 = {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"id": self.activity_id,
|
||||
"type": self._type,
|
||||
"actor": self.actor_id,
|
||||
"object": self.target_id,
|
||||
"published": self.created_at.isoformat(),
|
||||
}
|
||||
return as2
|
|
@ -1,113 +1,15 @@
|
|||
import logging
|
||||
from typing import List, Callable, Dict, Union, Optional
|
||||
|
||||
from federation.entities.activitypub.constants import NAMESPACE_PUBLIC
|
||||
from federation.entities.activitypub.entities import (
|
||||
ActivitypubFollow, ActivitypubProfile, ActivitypubAccept, ActivitypubPost, ActivitypubComment,
|
||||
ActivitypubRetraction, ActivitypubShare, ActivitypubImage)
|
||||
from federation.entities.activitypub.models import element_to_objects
|
||||
from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share, Image
|
||||
from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share, Image, Collection
|
||||
from federation.entities.mixins import BaseEntity
|
||||
from federation.types import UserType, ReceiverVariant
|
||||
import federation.entities.activitypub.models as models
|
||||
|
||||
logger = logging.getLogger("federation")
|
||||
|
||||
|
||||
MAPPINGS = {
|
||||
"Accept": ActivitypubAccept,
|
||||
"Announce": ActivitypubShare,
|
||||
"Application": ActivitypubProfile,
|
||||
"Article": ActivitypubPost,
|
||||
"Delete": ActivitypubRetraction,
|
||||
"Follow": ActivitypubFollow, # Technically not correct, but for now we support only following profiles
|
||||
"Group": ActivitypubProfile,
|
||||
"Image": ActivitypubImage,
|
||||
"Note": ActivitypubPost,
|
||||
"Organization": ActivitypubProfile,
|
||||
"Page": ActivitypubPost,
|
||||
"Person": ActivitypubProfile,
|
||||
"Service": ActivitypubProfile,
|
||||
}
|
||||
|
||||
OBJECTS = (
|
||||
"Application",
|
||||
"Article",
|
||||
"Group",
|
||||
"Image",
|
||||
"Note",
|
||||
"Organization",
|
||||
"Page",
|
||||
"Person",
|
||||
"Service",
|
||||
)
|
||||
|
||||
UNDO_MAPPINGS = {
|
||||
"Follow": ActivitypubFollow,
|
||||
"Announce": ActivitypubRetraction,
|
||||
}
|
||||
|
||||
|
||||
def element_to_objects_orig(payload: Dict) -> List:
|
||||
"""
|
||||
Transform an Element to a list of entities.
|
||||
"""
|
||||
cls = None
|
||||
entities = []
|
||||
|
||||
is_object = True if payload.get('type') in OBJECTS else False
|
||||
if payload.get('type') == "Delete":
|
||||
cls = ActivitypubRetraction
|
||||
elif payload.get('type') == "Undo":
|
||||
if isinstance(payload.get('object'), dict):
|
||||
cls = UNDO_MAPPINGS.get(payload["object"]["type"])
|
||||
elif isinstance(payload.get('object'), dict) and payload["object"].get('type'):
|
||||
if payload["object"]["type"] == "Note" and payload["object"].get("inReplyTo"):
|
||||
cls = ActivitypubComment
|
||||
else:
|
||||
cls = MAPPINGS.get(payload["object"]["type"])
|
||||
else:
|
||||
cls = MAPPINGS.get(payload.get('type'))
|
||||
if not cls:
|
||||
return []
|
||||
|
||||
transformed = transform_attributes(payload, cls, is_object=is_object)
|
||||
entity = cls(**transformed)
|
||||
# Extract children
|
||||
if payload.get("object") and isinstance(payload.get("object"), dict):
|
||||
# Try object if exists
|
||||
entity._children = extract_attachments(payload.get("object"))
|
||||
else:
|
||||
# Try payload itself
|
||||
entity._children = extract_attachments(payload)
|
||||
|
||||
entities.append(entity)
|
||||
|
||||
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") in ("Document", "Image") and item.get("mediaType") in Image._valid_media_types:
|
||||
if item.get('pyfed:inlineImage', False):
|
||||
# Skip this image as it's indicated to be inline in content and source already
|
||||
continue
|
||||
attachments.append(
|
||||
ActivitypubImage(
|
||||
url=item.get('url'),
|
||||
name=item.get('name') or "",
|
||||
media_type=item.get("mediaType"),
|
||||
)
|
||||
)
|
||||
return attachments
|
||||
|
||||
|
||||
def get_outbound_entity(entity: BaseEntity, private_key):
|
||||
"""Get the correct outbound entity for this protocol.
|
||||
|
||||
|
@ -127,25 +29,36 @@ def get_outbound_entity(entity: BaseEntity, private_key):
|
|||
outbound = None
|
||||
cls = entity.__class__
|
||||
if cls in [
|
||||
ActivitypubAccept, ActivitypubFollow, ActivitypubProfile, ActivitypubPost, ActivitypubComment,
|
||||
ActivitypubRetraction, ActivitypubShare,
|
||||
]:
|
||||
models.Accept, models.Follow, models.Person, models.Note,
|
||||
models.Delete, models.Tombstone, models.Announce, models.Collection,
|
||||
models.OrderedCollection,
|
||||
] and isinstance(entity, BaseEntity):
|
||||
# Already fine
|
||||
outbound = entity
|
||||
elif cls == Accept:
|
||||
outbound = ActivitypubAccept.from_base(entity)
|
||||
outbound = models.Accept.from_base(entity)
|
||||
elif cls == Follow:
|
||||
outbound = ActivitypubFollow.from_base(entity)
|
||||
outbound = models.Follow.from_base(entity)
|
||||
elif cls == Post:
|
||||
outbound = ActivitypubPost.from_base(entity)
|
||||
elif cls == Profile:
|
||||
outbound = ActivitypubProfile.from_base(entity)
|
||||
elif cls == Retraction:
|
||||
outbound = ActivitypubRetraction.from_base(entity)
|
||||
outbound = models.Post.from_base(entity)
|
||||
elif cls == Comment:
|
||||
outbound = ActivitypubComment.from_base(entity)
|
||||
outbound = models.Comment.from_base(entity)
|
||||
elif cls == Profile:
|
||||
outbound = models.Person.from_base(entity)
|
||||
elif cls == Retraction:
|
||||
if entity.entity_type in ('Post', 'Comment'):
|
||||
outbound = models.Tombstone.from_base(entity)
|
||||
outbound.activity = models.Delete
|
||||
elif entity.entity_type == 'Share':
|
||||
outbound = models.Announce.from_base(entity)
|
||||
outbound.activity = models.Undo
|
||||
outbound._required.remove('id')
|
||||
elif entity.entity_type == 'Profile':
|
||||
outbound = models.Delete.from_base(entity)
|
||||
elif cls == Share:
|
||||
outbound = ActivitypubShare.from_base(entity)
|
||||
outbound = models.Announce.from_base(entity)
|
||||
elif cls == Collection:
|
||||
outbound = models.OrderedCollection.from_base(entity) if entity.ordered else models.Collection.from_base(entity)
|
||||
if not outbound:
|
||||
raise ValueError("Don't know how to convert this base entity to ActivityPub protocol entities.")
|
||||
# TODO LDS signing
|
||||
|
@ -174,100 +87,3 @@ def message_to_objects(
|
|||
return element_to_objects(message)
|
||||
|
||||
|
||||
def transform_attribute(
|
||||
key: str, value: Union[str, Dict, int], transformed: Dict, cls, is_object: bool, payload: Dict,
|
||||
) -> None:
|
||||
if value is None:
|
||||
value = ""
|
||||
if key == "id":
|
||||
if is_object:
|
||||
if cls == ActivitypubRetraction:
|
||||
transformed["target_id"] = value
|
||||
transformed["entity_type"] = "Object"
|
||||
else:
|
||||
transformed["id"] = value
|
||||
elif cls in (ActivitypubProfile, ActivitypubShare):
|
||||
transformed["id"] = value
|
||||
else:
|
||||
transformed["activity_id"] = value
|
||||
elif key == "actor":
|
||||
transformed["actor_id"] = value
|
||||
elif key == "attributedTo" and is_object:
|
||||
transformed["actor_id"] = value
|
||||
elif key in ("content", "source"):
|
||||
if payload.get('source') and isinstance(payload.get("source"), dict) and \
|
||||
payload.get('source').get('mediaType') == "text/markdown":
|
||||
transformed["_media_type"] = "text/markdown"
|
||||
transformed["raw_content"] = payload.get('source').get('content').strip()
|
||||
transformed["_rendered_content"] = payload.get('content').strip()
|
||||
else:
|
||||
# Assume HTML by convention
|
||||
transformed["_media_type"] = "text/html"
|
||||
transformed["raw_content"] = payload.get('content').strip()
|
||||
transformed["_rendered_content"] = transformed["raw_content"]
|
||||
elif key == "diaspora:guid":
|
||||
transformed["guid"] = value
|
||||
elif key == "endpoints" and isinstance(value, dict):
|
||||
if "inboxes" not in transformed:
|
||||
transformed["inboxes"] = {"private": None, "public": None}
|
||||
if value.get('sharedInbox'):
|
||||
transformed["inboxes"]["public"] = value.get("sharedInbox")
|
||||
elif key == "icon":
|
||||
# TODO maybe we should ditch these size constants and instead have a more flexible dict for images
|
||||
# so based on protocol there would either be one url or many by size name
|
||||
if isinstance(value, dict):
|
||||
transformed["image_urls"] = {
|
||||
"small": value['url'],
|
||||
"medium": value['url'],
|
||||
"large": value['url'],
|
||||
}
|
||||
else:
|
||||
transformed["image_urls"] = {
|
||||
"small": value,
|
||||
"medium": value,
|
||||
"large": value,
|
||||
}
|
||||
elif key == "inbox":
|
||||
if "inboxes" not in transformed:
|
||||
transformed["inboxes"] = {"private": None, "public": None}
|
||||
transformed["inboxes"]["private"] = value
|
||||
if not transformed["inboxes"]["public"]:
|
||||
transformed["inboxes"]["public"] = value
|
||||
elif key == "inReplyTo":
|
||||
transformed["target_id"] = value
|
||||
elif key == "name":
|
||||
transformed["name"] = value or ""
|
||||
elif key == "object" and not is_object:
|
||||
if isinstance(value, dict):
|
||||
if cls == ActivitypubAccept:
|
||||
transformed["target_id"] = value.get("id")
|
||||
elif cls == ActivitypubFollow:
|
||||
transformed["target_id"] = value.get("object")
|
||||
else:
|
||||
transform_attributes(value, cls, transformed, is_object=True)
|
||||
else:
|
||||
transformed["target_id"] = value
|
||||
elif key == "preferredUsername":
|
||||
transformed["username"] = value
|
||||
elif key == "publicKey":
|
||||
transformed["public_key"] = value.get('publicKeyPem', '')
|
||||
elif key == "summary" and cls == ActivitypubProfile:
|
||||
transformed["raw_content"] = value
|
||||
elif key in ("to", "cc"):
|
||||
if isinstance(value, list) and NAMESPACE_PUBLIC in value:
|
||||
transformed["public"] = True
|
||||
elif value == NAMESPACE_PUBLIC:
|
||||
transformed["public"] = True
|
||||
elif key == "type":
|
||||
if value == "Undo":
|
||||
transformed["following"] = False
|
||||
else:
|
||||
transformed[key] = value
|
||||
|
||||
|
||||
def transform_attributes(payload: Dict, cls, transformed: Dict = None, is_object: bool = False) -> Dict:
|
||||
if not transformed:
|
||||
transformed = {}
|
||||
for key, value in payload.items():
|
||||
transform_attribute(key, value, transformed, cls, is_object, payload)
|
||||
return transformed
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -1,4 +1,5 @@
|
|||
from typing import Dict, Tuple
|
||||
from magic import from_file
|
||||
from mimetypes import guess_type
|
||||
|
||||
from dirty_validators.basic import Email
|
||||
|
@ -7,7 +8,7 @@ from federation.entities.activitypub.enums import ActivityType
|
|||
from federation.entities.mixins import (
|
||||
PublicMixin, TargetIDMixin, ParticipationMixin, CreatedAtMixin, RawContentMixin, OptionalRawContentMixin,
|
||||
EntityTypeMixin, ProviderDisplayNameMixin, RootTargetIDMixin, BaseEntity)
|
||||
from federation.utils.network import fetch_content_type
|
||||
from federation.utils.network import fetch_content_type, fetch_file
|
||||
|
||||
|
||||
class Accept(CreatedAtMixin, TargetIDMixin, BaseEntity):
|
||||
|
@ -45,6 +46,13 @@ class Image(OptionalRawContentMixin, CreatedAtMixin, BaseEntity):
|
|||
|
||||
def get_media_type(self) -> str:
|
||||
media_type = guess_type(self.url)[0] or fetch_content_type(self.url)
|
||||
if media_type == 'application/octet-stream':
|
||||
try:
|
||||
file = fetch_file(self.url)
|
||||
media_type = from_file(file, mime=True)
|
||||
os.unlink(file)
|
||||
except:
|
||||
pass
|
||||
if media_type in self._valid_media_types:
|
||||
return media_type
|
||||
return ""
|
||||
|
@ -183,3 +191,18 @@ class Share(CreatedAtMixin, TargetIDMixin, EntityTypeMixin, OptionalRawContentMi
|
|||
share.
|
||||
"""
|
||||
entity_type = "Post"
|
||||
|
||||
|
||||
class Collection(BaseEntity):
|
||||
"""Represents collections of objects.
|
||||
|
||||
Only useful to Activitypub outbound payloads.
|
||||
"""
|
||||
ordered = False
|
||||
total_items = 0
|
||||
items = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._required.remove('actor_id')
|
||||
self._required += ['ordered']
|
||||
|
|
|
@ -6,8 +6,13 @@ from federation.entities.diaspora.mixins import DiasporaEntityMixin, DiasporaRel
|
|||
from federation.entities.diaspora.utils import format_dt, struct_to_xml
|
||||
from federation.utils.diaspora import get_private_endpoint, get_public_endpoint
|
||||
|
||||
class DiasporaMentionMixin:
|
||||
def pre_send(self):
|
||||
# add curly braces to mentions
|
||||
for mention in self._mentions:
|
||||
self.raw_content = self.raw_content.replace('@'+mention, '@{'+mention+'}')
|
||||
|
||||
class DiasporaComment(DiasporaRelayableMixin, Comment):
|
||||
class DiasporaComment(DiasporaMentionMixin, DiasporaRelayableMixin, Comment):
|
||||
"""Diaspora comment."""
|
||||
_tag_name = "comment"
|
||||
|
||||
|
@ -35,7 +40,7 @@ class DiasporaImage(DiasporaEntityMixin, Image):
|
|||
_tag_name = "photo"
|
||||
|
||||
|
||||
class DiasporaPost(DiasporaEntityMixin, Post):
|
||||
class DiasporaPost(DiasporaMentionMixin, DiasporaEntityMixin, Post):
|
||||
"""Diaspora post, ie status message."""
|
||||
_tag_name = "status_message"
|
||||
|
||||
|
|
|
@ -287,6 +287,8 @@ def get_outbound_entity(entity: BaseEntity, private_key: RsaKey):
|
|||
# in all situations but is apparently being removed.
|
||||
# TODO: remove this once Diaspora removes the extra signature
|
||||
outbound.parent_signature = outbound.signature
|
||||
if hasattr(outbound, "pre_send"):
|
||||
outbound.pre_send()
|
||||
# Validate the entity
|
||||
outbound.validate(direction="outbound")
|
||||
return outbound
|
||||
|
|
|
@ -175,7 +175,7 @@ class MatrixRoomMessage(Post, MatrixEntityMixin):
|
|||
if not self._profile_room_id:
|
||||
from federation.entities.matrix.mappers import get_outbound_entity
|
||||
# Need to also create the profile
|
||||
profile = get_profile(self.actor_id)
|
||||
profile = get_profile(fid=self.actor_id)
|
||||
profile_entity = get_outbound_entity(profile, None)
|
||||
payloads = profile_entity.payloads()
|
||||
if payloads:
|
||||
|
|
|
@ -7,7 +7,7 @@ from typing import List, Set, Union, Dict, Tuple
|
|||
from commonmark import commonmark
|
||||
|
||||
from federation.entities.activitypub.enums import ActivityType
|
||||
from federation.entities.utils import get_name_for_profile
|
||||
from federation.entities.utils import get_name_for_profile, get_profile
|
||||
from federation.utils.text import process_text_links, find_tags
|
||||
|
||||
|
||||
|
@ -28,9 +28,13 @@ class BaseEntity:
|
|||
base_url: str = ""
|
||||
guid: str = ""
|
||||
handle: str = ""
|
||||
finger: str = ""
|
||||
id: str = ""
|
||||
mxid: str = ""
|
||||
signature: str = ""
|
||||
# for AP
|
||||
to: List = []
|
||||
cc: List = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._required = ["id", "actor_id"]
|
||||
|
@ -39,8 +43,8 @@ class BaseEntity:
|
|||
self._receivers = []
|
||||
|
||||
# make the assumption that if a schema is being used, the payload
|
||||
# is deserialized and validated properly
|
||||
if kwargs.get('has_schema'):
|
||||
# is (de)serialized and validated properly
|
||||
if hasattr(self, 'schema') or kwargs.get('schema'):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
else:
|
||||
|
@ -55,11 +59,6 @@ class BaseEntity:
|
|||
# Fill a default activity if not given and type of entity class has one
|
||||
self.activity = getattr(self, "_default_activity", None)
|
||||
|
||||
def as_protocol(self, protocol):
|
||||
entities = importlib.import_module(f"federation.entities.{protocol}.entities")
|
||||
klass = getattr(entities, f"{protocol.title()}{self.__class__.__name__}")
|
||||
return klass.from_base(self)
|
||||
|
||||
def post_receive(self):
|
||||
"""
|
||||
Run any actions after deserializing the payload into an entity.
|
||||
|
@ -190,6 +189,7 @@ class ParticipationMixin(TargetIDMixin):
|
|||
|
||||
class CreatedAtMixin(BaseEntity):
|
||||
created_at = None
|
||||
times: dict = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -220,7 +220,7 @@ class RawContentMixin(BaseEntity):
|
|||
images = []
|
||||
if self._media_type != "text/markdown" or self.raw_content is None:
|
||||
return images
|
||||
regex = r"!\[([\w ]*)\]\((https?://[\w\d\-\./]+\.[\w]*((?<=jpg)|(?<=gif)|(?<=png)|(?<=jpeg)))\)"
|
||||
regex = r"!\[([\w\s\-\']*)\]\((https?://[\w\d\-\./]+\.[\w]*((?<=jpg)|(?<=gif)|(?<=png)|(?<=jpeg)))\)"
|
||||
matches = re.finditer(regex, self.raw_content, re.MULTILINE | re.IGNORECASE)
|
||||
for match in matches:
|
||||
groups = match.groups()
|
||||
|
@ -254,15 +254,12 @@ class RawContentMixin(BaseEntity):
|
|||
# Do mentions
|
||||
if self._mentions:
|
||||
for mention in self._mentions:
|
||||
# Only linkify mentions that are URL's
|
||||
if not mention.startswith("http"):
|
||||
continue
|
||||
display_name = get_name_for_profile(mention)
|
||||
if not display_name:
|
||||
display_name = mention
|
||||
# Diaspora mentions are linkified as mailto
|
||||
profile = get_profile(finger=mention)
|
||||
href = 'mailto:'+mention if not getattr(profile, 'id', None) else profile.id
|
||||
rendered = rendered.replace(
|
||||
"@{%s}" % mention,
|
||||
f'@<a class="mention" href="{mention}"><span>{display_name}</span></a>',
|
||||
"@%s" % mention,
|
||||
f'@<a class="h-card" href="{href}"><span>{mention}</span></a>',
|
||||
)
|
||||
# Finally linkify remaining URL's that are not links
|
||||
rendered = process_text_links(rendered)
|
||||
|
@ -278,15 +275,20 @@ class RawContentMixin(BaseEntity):
|
|||
return sorted(tags)
|
||||
|
||||
def extract_mentions(self):
|
||||
matches = re.findall(r'@{([\S ][^{}]+)}', self.raw_content)
|
||||
if self._media_type != 'text/markdown': return
|
||||
matches = re.findall(r'@{?[\S ]?[^{}@]+[@;]?\s*[\w\-./@]+[\w/]+}?', self.raw_content)
|
||||
if not matches:
|
||||
return
|
||||
for mention in matches:
|
||||
handle = None
|
||||
splits = mention.split(";")
|
||||
if len(splits) == 1:
|
||||
self._mentions.add(splits[0].strip(' }'))
|
||||
handle = splits[0].strip(' }').lstrip('@{')
|
||||
elif len(splits) == 2:
|
||||
self._mentions.add(splits[1].strip(' }'))
|
||||
handle = splits[1].strip(' }')
|
||||
if handle:
|
||||
self._mentions.add(handle)
|
||||
self.raw_content = self.raw_content.replace(mention, '@'+handle)
|
||||
|
||||
|
||||
class OptionalRawContentMixin(RawContentMixin):
|
||||
|
|
|
@ -5,7 +5,7 @@ if TYPE_CHECKING:
|
|||
from federation.entities.base import Profile
|
||||
|
||||
|
||||
def get_base_attributes(entity):
|
||||
def get_base_attributes(entity, keep=()):
|
||||
"""Build a dict of attributes of an entity.
|
||||
|
||||
Returns attributes and their values, ignoring any properties, functions and anything that starts
|
||||
|
@ -14,7 +14,7 @@ def get_base_attributes(entity):
|
|||
attributes = {}
|
||||
cls = entity.__class__
|
||||
for attr, _ in inspect.getmembers(cls, lambda o: not isinstance(o, property) and not inspect.isroutine(o)):
|
||||
if not attr.startswith("_"):
|
||||
if not attr.startswith("_") or attr in keep:
|
||||
attributes[attr] = getattr(entity, attr)
|
||||
return attributes
|
||||
|
||||
|
@ -41,7 +41,7 @@ def get_name_for_profile(fid: str) -> Optional[str]:
|
|||
pass
|
||||
|
||||
|
||||
def get_profile(fid):
|
||||
def get_profile(**kwargs):
|
||||
# type: (str) -> Profile
|
||||
"""
|
||||
Get a profile via the configured profile getter.
|
||||
|
@ -53,6 +53,6 @@ def get_profile(fid):
|
|||
profile_func = get_function_from_config("get_profile_function")
|
||||
if not profile_func:
|
||||
return
|
||||
return profile_func(fid=fid)
|
||||
return profile_func(**kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
@ -28,7 +28,8 @@ def retrieve_remote_content(
|
|||
protocol_name = identify_protocol_by_id(id).PROTOCOL_NAME
|
||||
utils = importlib.import_module("federation.utils.%s" % protocol_name)
|
||||
return utils.retrieve_and_parse_content(
|
||||
id=id, guid=guid, handle=handle, entity_type=entity_type, sender_key_fetcher=sender_key_fetcher,
|
||||
id=id, guid=guid, handle=handle, entity_type=entity_type,
|
||||
cache=cache, sender_key_fetcher=sender_key_fetcher,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -204,16 +204,6 @@ def handle_send(
|
|||
logger.warning("handle_send - skipping activitypub due to failure to generate payload: %s", ex)
|
||||
continue
|
||||
payload = copy.copy(ready_payloads[protocol]["payload"])
|
||||
if public:
|
||||
payload["to"] = [NAMESPACE_PUBLIC]
|
||||
payload["cc"] = [fid]
|
||||
if isinstance(payload.get("object"), dict):
|
||||
payload["object"]["to"] = [NAMESPACE_PUBLIC]
|
||||
payload["object"]["cc"] = [fid]
|
||||
else:
|
||||
payload["to"] = [fid]
|
||||
if isinstance(payload.get("object"), dict):
|
||||
payload["object"]["to"] = [fid]
|
||||
rendered_payload = json.dumps(payload).encode("utf-8")
|
||||
except Exception:
|
||||
logger.error(
|
||||
|
|
|
@ -15,7 +15,7 @@ def disable_network_calls(monkeypatch):
|
|||
"""Disable network calls."""
|
||||
monkeypatch.setattr("requests.post", Mock())
|
||||
|
||||
class MockResponse(str):
|
||||
class MockGetResponse(str):
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
|
@ -29,8 +29,17 @@ def disable_network_calls(monkeypatch):
|
|||
return saved_get(*args, **kwargs)
|
||||
return DEFAULT
|
||||
|
||||
monkeypatch.setattr("requests.get", Mock(return_value=MockResponse, side_effect=side_effect))
|
||||
monkeypatch.setattr("requests.get", Mock(return_value=MockGetResponse, side_effect=side_effect))
|
||||
|
||||
class MockHeadResponse(dict):
|
||||
status_code = 200
|
||||
headers = {'Content-Type':'image/jpeg'}
|
||||
|
||||
@staticmethod
|
||||
def raise_for_status():
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("requests.head", Mock(return_value=MockHeadResponse))
|
||||
|
||||
@pytest.fixture
|
||||
def private_key():
|
||||
|
|
|
@ -7,6 +7,7 @@ FEDERATION = {
|
|||
"federation_id": "https://example.com/u/john/",
|
||||
"get_object_function": "federation.tests.django.utils.get_object_function",
|
||||
"get_private_key_function": "federation.tests.django.utils.get_private_key",
|
||||
"get_public_key_function": "federation.tests.django.utils.get_public_key",
|
||||
"get_profile_function": "federation.tests.django.utils.get_profile",
|
||||
"matrix_config_function": "federation.tests.django.utils.matrix_config_func",
|
||||
"process_payload_function": "federation.tests.django.utils.process_payload",
|
||||
|
|
|
@ -4,7 +4,7 @@ from typing import Dict
|
|||
from Crypto.PublicKey.RSA import RsaKey
|
||||
|
||||
from federation.entities.base import Profile
|
||||
from federation.tests.fixtures.keys import get_dummy_private_key
|
||||
from federation.tests.fixtures.keys import get_dummy_private_key, get_dummy_public_key
|
||||
|
||||
|
||||
def dummy_profile():
|
||||
|
@ -18,7 +18,7 @@ def dummy_profile():
|
|||
)
|
||||
|
||||
|
||||
def get_object_function(object_id):
|
||||
def get_object_function(object_id, signer=None):
|
||||
return dummy_profile()
|
||||
|
||||
|
||||
|
@ -26,6 +26,10 @@ def get_private_key(identifier: str) -> RsaKey:
|
|||
return get_dummy_private_key()
|
||||
|
||||
|
||||
def get_public_key(identifier: str) -> RsaKey:
|
||||
return get_dummy_public_key()
|
||||
|
||||
|
||||
def get_profile(fid=None, handle=None, guid=None, request=None):
|
||||
return dummy_profile()
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ class DummyRestrictedView(View):
|
|||
return HttpResponse("foo")
|
||||
|
||||
|
||||
def dummy_get_object_function(request):
|
||||
def dummy_get_object_function(request, signer=None):
|
||||
if request.method == 'GET':
|
||||
return False
|
||||
return True
|
||||
|
@ -59,13 +59,13 @@ class TestActivityPubObjectView:
|
|||
assert response.content == b'foo'
|
||||
|
||||
def test_receives_messages_to_inbox(self):
|
||||
request = RequestFactory().post("/inbox/", data='{"foo": "bar"}', content_type='application/json')
|
||||
request = RequestFactory().post("/u/bla/inbox/", data='{"foo": "bar"}', content_type='application/json')
|
||||
response = dummy_view(request=request)
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
def test_receives_messages_to_inbox__cbv(self):
|
||||
request = RequestFactory().post("/inbox/", data='{"foo": "bar"}', content_type="application/json")
|
||||
request = RequestFactory().post("/u/bla/inbox/", data='{"foo": "bar"}', content_type="application/json")
|
||||
view = DummyView.as_view()
|
||||
response = view(request=request)
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import pytest
|
||||
from unittest.mock import patch
|
||||
from pprint import pprint
|
||||
|
||||
# noinspection PyPackageRequirements
|
||||
from Crypto.PublicKey.RSA import RsaKey
|
||||
|
||||
from federation.entities.activitypub.constants import (
|
||||
CONTEXTS_DEFAULT, CONTEXT_MANUALLY_APPROVES_FOLLOWERS, CONTEXT_LD_SIGNATURES, CONTEXT_DIASPORA)
|
||||
from federation.entities.activitypub.entities import ActivitypubAccept
|
||||
from federation.entities.activitypub.constants import CONTEXT
|
||||
from federation.entities.activitypub.models import Accept
|
||||
from federation.tests.fixtures.keys import PUBKEY
|
||||
from federation.types import UserType
|
||||
|
||||
|
@ -15,12 +15,11 @@ class TestEntitiesConvertToAS2:
|
|||
def test_accept_to_as2(self, activitypubaccept):
|
||||
result = activitypubaccept.to_as2()
|
||||
assert result == {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"@context": CONTEXT,
|
||||
"id": "https://localhost/accept",
|
||||
"type": "Accept",
|
||||
"actor": "https://localhost/profile",
|
||||
"object": {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"id": "https://localhost/follow",
|
||||
"type": "Follow",
|
||||
"actor": "https://localhost/profile",
|
||||
|
@ -28,10 +27,10 @@ class TestEntitiesConvertToAS2:
|
|||
},
|
||||
}
|
||||
|
||||
def test_accounce_to_as2(self, activitypubannounce):
|
||||
def test_announce_to_as2(self, activitypubannounce):
|
||||
result = activitypubannounce.to_as2()
|
||||
assert result == {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"@context": CONTEXT,
|
||||
"id": "http://127.0.0.1:8000/post/123456/#create",
|
||||
"type": "Announce",
|
||||
"actor": "http://127.0.0.1:8000/profile/123456/",
|
||||
|
@ -40,15 +39,10 @@ class TestEntitiesConvertToAS2:
|
|||
}
|
||||
|
||||
def test_comment_to_as2(self, activitypubcomment):
|
||||
activitypubcomment.pre_send()
|
||||
result = activitypubcomment.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -60,9 +54,6 @@ class TestEntitiesConvertToAS2:
|
|||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': 'http://127.0.0.1:8000/post/012345/',
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [],
|
||||
'url': '',
|
||||
'source': {
|
||||
'content': 'raw_content',
|
||||
'mediaType': 'text/markdown',
|
||||
|
@ -73,15 +64,10 @@ class TestEntitiesConvertToAS2:
|
|||
|
||||
def test_comment_to_as2__url_in_raw_content(self, activitypubcomment):
|
||||
activitypubcomment.raw_content = 'raw_content http://example.com'
|
||||
activitypubcomment.pre_send()
|
||||
result = activitypubcomment.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -94,9 +80,6 @@ class TestEntitiesConvertToAS2:
|
|||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': 'http://127.0.0.1:8000/post/012345/',
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [],
|
||||
'url': '',
|
||||
'source': {
|
||||
'content': 'raw_content http://example.com',
|
||||
'mediaType': 'text/markdown',
|
||||
|
@ -108,7 +91,7 @@ class TestEntitiesConvertToAS2:
|
|||
def test_follow_to_as2(self, activitypubfollow):
|
||||
result = activitypubfollow.to_as2()
|
||||
assert result == {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"@context": CONTEXT,
|
||||
"id": "https://localhost/follow",
|
||||
"type": "Follow",
|
||||
"actor": "https://localhost/profile",
|
||||
|
@ -117,9 +100,10 @@ class TestEntitiesConvertToAS2:
|
|||
|
||||
def test_follow_to_as2__undo(self, activitypubundofollow):
|
||||
result = activitypubundofollow.to_as2()
|
||||
result["id"] = "https://localhost/undo" # Real object will have a random UUID postfix here
|
||||
result["object"]["id"] = "https://localhost/follow" # Real object will have a random UUID postfix here
|
||||
assert result == {
|
||||
"@context": CONTEXTS_DEFAULT,
|
||||
"@context": CONTEXT,
|
||||
"id": "https://localhost/undo",
|
||||
"type": "Undo",
|
||||
"actor": "https://localhost/profile",
|
||||
|
@ -132,29 +116,24 @@ class TestEntitiesConvertToAS2:
|
|||
}
|
||||
|
||||
def test_post_to_as2(self, activitypubpost):
|
||||
activitypubpost.pre_send()
|
||||
result = activitypubpost.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
'cc': ['https://http://127.0.0.1:8000/profile/123456/followers/'],
|
||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'object': {
|
||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||
'cc': ['https://http://127.0.0.1:8000/profile/123456/followers/'],
|
||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'type': 'Note',
|
||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||
'content': '<h1>raw_content</h1>',
|
||||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': None,
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [],
|
||||
'url': '',
|
||||
'source': {
|
||||
'content': '# raw_content',
|
||||
'mediaType': 'text/markdown',
|
||||
|
@ -163,17 +142,13 @@ class TestEntitiesConvertToAS2:
|
|||
'published': '2019-04-27T00:00:00',
|
||||
}
|
||||
|
||||
# TODO: fix this test.
|
||||
@pytest.mark.skip
|
||||
def test_post_to_as2__with_mentions(self, activitypubpost_mentions):
|
||||
activitypubpost_mentions.pre_send()
|
||||
result = activitypubpost_mentions.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -185,15 +160,8 @@ class TestEntitiesConvertToAS2:
|
|||
'href="http://localhost.local/someone" rel="nofollow" target="_blank">'
|
||||
'<span>Bob Bobértson</span></a></p>',
|
||||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': None,
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [
|
||||
{
|
||||
"type": "Mention",
|
||||
"href": "http://127.0.0.1:8000/profile/999999",
|
||||
"name": "http://127.0.0.1:8000/profile/999999",
|
||||
},
|
||||
{
|
||||
"type": "Mention",
|
||||
"href": "http://localhost.local/someone",
|
||||
|
@ -210,7 +178,6 @@ class TestEntitiesConvertToAS2:
|
|||
"name": "someone@localhost.local",
|
||||
},
|
||||
],
|
||||
'url': '',
|
||||
'source': {
|
||||
'content': '# raw_content\n\n@{someone@localhost.local} @{http://localhost.local/someone}',
|
||||
'mediaType': 'text/markdown',
|
||||
|
@ -220,15 +187,10 @@ class TestEntitiesConvertToAS2:
|
|||
}
|
||||
|
||||
def test_post_to_as2__with_tags(self, activitypubpost_tags):
|
||||
activitypubpost_tags.pre_send()
|
||||
result = activitypubpost_tags.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -246,9 +208,7 @@ class TestEntitiesConvertToAS2:
|
|||
'noreferrer nofollow" '
|
||||
'target="_blank">#<span>barfoo</span></a></p>',
|
||||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': None,
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [
|
||||
{
|
||||
"type": "Hashtag",
|
||||
|
@ -261,7 +221,6 @@ class TestEntitiesConvertToAS2:
|
|||
"name": "#foobar",
|
||||
},
|
||||
],
|
||||
'url': '',
|
||||
'source': {
|
||||
'content': '# raw_content\n#foobar\n#barfoo',
|
||||
'mediaType': 'text/markdown',
|
||||
|
@ -271,15 +230,10 @@ class TestEntitiesConvertToAS2:
|
|||
}
|
||||
|
||||
def test_post_to_as2__with_images(self, activitypubpost_images):
|
||||
activitypubpost_images.pre_send()
|
||||
result = activitypubpost_images.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -289,16 +243,11 @@ class TestEntitiesConvertToAS2:
|
|||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||
'content': '<p>raw_content</p>',
|
||||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': None,
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [],
|
||||
'url': '',
|
||||
'attachment': [
|
||||
{
|
||||
'type': 'Image',
|
||||
'mediaType': 'image/jpeg',
|
||||
'name': '',
|
||||
'url': 'foobar',
|
||||
'pyfed:inlineImage': False,
|
||||
},
|
||||
|
@ -319,16 +268,10 @@ class TestEntitiesConvertToAS2:
|
|||
}
|
||||
|
||||
def test_post_to_as2__with_diaspora_guid(self, activitypubpost_diaspora_guid):
|
||||
activitypubpost_diaspora_guid.pre_send()
|
||||
result = activitypubpost_diaspora_guid.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
{'Hashtag': 'as:Hashtag'},
|
||||
'https://w3id.org/security/v1',
|
||||
{'sensitive': 'as:sensitive'},
|
||||
{'diaspora': 'https://diasporafoundation.org/ns/'},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Create',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -339,11 +282,7 @@ class TestEntitiesConvertToAS2:
|
|||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||
'content': '<p>raw_content</p>',
|
||||
'published': '2019-04-27T00:00:00',
|
||||
'inReplyTo': None,
|
||||
'sensitive': False,
|
||||
'summary': None,
|
||||
'tag': [],
|
||||
'url': '',
|
||||
'source': {
|
||||
'content': 'raw_content',
|
||||
'mediaType': 'text/markdown',
|
||||
|
@ -353,14 +292,10 @@ class TestEntitiesConvertToAS2:
|
|||
}
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@patch("federation.entities.base.fetch_content_type", return_value="image/jpeg")
|
||||
def test_profile_to_as2(self, mock_fetch, activitypubprofile):
|
||||
def test_profile_to_as2(self, activitypubprofile):
|
||||
result = activitypubprofile.to_as2()
|
||||
assert result == {
|
||||
"@context": CONTEXTS_DEFAULT + [
|
||||
CONTEXT_LD_SIGNATURES,
|
||||
CONTEXT_MANUALLY_APPROVES_FOLLOWERS,
|
||||
],
|
||||
"@context": CONTEXT,
|
||||
"endpoints": {
|
||||
"sharedInbox": "https://example.com/public",
|
||||
},
|
||||
|
@ -376,6 +311,7 @@ class TestEntitiesConvertToAS2:
|
|||
"owner": "https://example.com/bob",
|
||||
"publicKeyPem": PUBKEY,
|
||||
},
|
||||
'published': '2022-09-06T00:00:00',
|
||||
"type": "Person",
|
||||
"url": "https://example.com/bob-bobertson",
|
||||
"summary": "foobar",
|
||||
|
@ -383,21 +319,15 @@ class TestEntitiesConvertToAS2:
|
|||
"type": "Image",
|
||||
"url": "urllarge",
|
||||
"mediaType": "image/jpeg",
|
||||
"name": "",
|
||||
"pyfed:inlineImage": False,
|
||||
}
|
||||
}
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
@patch("federation.entities.base.fetch_content_type", return_value="image/jpeg")
|
||||
def test_profile_to_as2__with_diaspora_guid(self, mock_fetch, activitypubprofile_diaspora_guid):
|
||||
def test_profile_to_as2__with_diaspora_guid(self, activitypubprofile_diaspora_guid):
|
||||
result = activitypubprofile_diaspora_guid.to_as2()
|
||||
assert result == {
|
||||
"@context": CONTEXTS_DEFAULT + [
|
||||
CONTEXT_LD_SIGNATURES,
|
||||
CONTEXT_MANUALLY_APPROVES_FOLLOWERS,
|
||||
CONTEXT_DIASPORA,
|
||||
],
|
||||
"@context": CONTEXT,
|
||||
"endpoints": {
|
||||
"sharedInbox": "https://example.com/public",
|
||||
},
|
||||
|
@ -415,6 +345,7 @@ class TestEntitiesConvertToAS2:
|
|||
"owner": "https://example.com/bob",
|
||||
"publicKeyPem": PUBKEY,
|
||||
},
|
||||
'published': '2022-09-06T00:00:00',
|
||||
"type": "Person",
|
||||
"url": "https://example.com/bob-bobertson",
|
||||
"summary": "foobar",
|
||||
|
@ -422,7 +353,6 @@ class TestEntitiesConvertToAS2:
|
|||
"type": "Image",
|
||||
"url": "urllarge",
|
||||
"mediaType": "image/jpeg",
|
||||
"name": "",
|
||||
"pyfed:inlineImage": False,
|
||||
}
|
||||
}
|
||||
|
@ -430,10 +360,7 @@ class TestEntitiesConvertToAS2:
|
|||
def test_retraction_to_as2(self, activitypubretraction):
|
||||
result = activitypubretraction.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Delete',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#delete',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
|
@ -447,31 +374,30 @@ class TestEntitiesConvertToAS2:
|
|||
def test_retraction_to_as2__announce(self, activitypubretraction_announce):
|
||||
result = activitypubretraction_announce.to_as2()
|
||||
assert result == {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"},
|
||||
],
|
||||
'@context': CONTEXT,
|
||||
'type': 'Undo',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/#delete',
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
'object': {
|
||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||
'id': 'http://127.0.0.1:8000/post/123456/activity',
|
||||
'object': 'http://127.0.0.1:8000/post/123456',
|
||||
'type': 'Announce',
|
||||
},
|
||||
'published': '2019-04-27T00:00:00',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestEntitiesPostReceive:
|
||||
@patch("federation.utils.activitypub.retrieve_and_parse_profile", autospec=True)
|
||||
@patch("federation.entities.activitypub.entities.handle_send", autospec=True)
|
||||
@patch("federation.entities.activitypub.models.retrieve_and_parse_profile", autospec=True)
|
||||
@patch("federation.entities.activitypub.models.handle_send", autospec=True)
|
||||
def test_follow_post_receive__sends_correct_accept_back(
|
||||
self, mock_send, mock_retrieve, activitypubfollow, profile
|
||||
):
|
||||
mock_retrieve.return_value = profile
|
||||
activitypubfollow.post_receive()
|
||||
args, kwargs = mock_send.call_args_list[0]
|
||||
assert isinstance(args[0], ActivitypubAccept)
|
||||
assert isinstance(args[0], Accept)
|
||||
assert args[0].activity_id.startswith("https://example.com/profile#accept-")
|
||||
assert args[0].actor_id == "https://example.com/profile"
|
||||
assert args[0].target_id == "https://localhost/follow"
|
||||
|
@ -485,13 +411,13 @@ class TestEntitiesPostReceive:
|
|||
"public": False,
|
||||
}]
|
||||
|
||||
@patch("federation.entities.activitypub.entities.bleach.linkify", autospec=True)
|
||||
@patch("federation.entities.activitypub.models.bleach.linkify", autospec=True)
|
||||
def test_post_post_receive__linkifies_if_not_markdown(self, mock_linkify, activitypubpost):
|
||||
activitypubpost._media_type = 'text/html'
|
||||
activitypubpost.post_receive()
|
||||
mock_linkify.assert_called_once()
|
||||
|
||||
@patch("federation.entities.activitypub.entities.bleach.linkify", autospec=True)
|
||||
@patch("federation.entities.activitypub.models.bleach.linkify", autospec=True)
|
||||
def test_post_post_receive__skips_linkify_if_markdown(self, mock_linkify, activitypubpost):
|
||||
activitypubpost.post_receive()
|
||||
mock_linkify.assert_not_called()
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
from datetime import datetime
|
||||
from unittest.mock import patch, Mock
|
||||
from unittest.mock import patch, Mock, DEFAULT
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from federation.entities.activitypub.entities import (
|
||||
ActivitypubFollow, ActivitypubAccept, ActivitypubProfile, ActivitypubPost, ActivitypubComment,
|
||||
ActivitypubRetraction, ActivitypubShare)
|
||||
#from federation.entities.activitypub.entities import (
|
||||
# models.Follow, models.Accept, models.Person, models.Note, models.Note,
|
||||
# models.Delete, models.Announce)
|
||||
import federation.entities.activitypub.models as models
|
||||
from federation.entities.activitypub.mappers import message_to_objects, get_outbound_entity
|
||||
from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image, Share
|
||||
from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image, Share, Retraction
|
||||
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_POST_IMAGES, ACTIVITYPUB_POST_WITH_SOURCE_MARKDOWN, ACTIVITYPUB_POST_WITH_TAGS,
|
||||
ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, ACTIVITYPUB_POST_WITH_MENTIONS, ACTIVITYPUB_PROFILE_WITH_DIASPORA_GUID)
|
||||
ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, ACTIVITYPUB_POST_WITH_MENTIONS, ACTIVITYPUB_PROFILE_WITH_DIASPORA_GUID,
|
||||
ACTIVITYPUB_REMOTE_PROFILE, ACTIVITYPUB_COLLECTION)
|
||||
from federation.types import UserType, ReceiverVariant
|
||||
|
||||
|
||||
class TestActivitypubEntityMappersReceive:
|
||||
@patch.object(ActivitypubFollow, "post_receive", autospec=True)
|
||||
@patch.object(models.Follow, "post_receive", autospec=True)
|
||||
def test_message_to_objects__calls_post_receive_hook(self, mock_post_receive):
|
||||
message_to_objects(ACTIVITYPUB_FOLLOW, "https://example.com/actor")
|
||||
assert mock_post_receive.called
|
||||
|
@ -26,7 +29,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_SHARE, "https://mastodon.social/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
assert isinstance(entity, ActivitypubShare)
|
||||
assert isinstance(entity, models.Announce)
|
||||
assert entity.actor_id == "https://mastodon.social/users/jaywink"
|
||||
assert entity.target_id == "https://mastodon.social/users/Gargron/statuses/102559779793316012"
|
||||
assert entity.id == "https://mastodon.social/users/jaywink/statuses/102560701449465612/activity"
|
||||
|
@ -38,7 +41,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_FOLLOW, "https://example.com/actor")
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
assert isinstance(entity, ActivitypubFollow)
|
||||
assert isinstance(entity, models.Follow)
|
||||
assert entity.actor_id == "https://example.com/actor"
|
||||
assert entity.target_id == "https://example.org/actor"
|
||||
assert entity.following is True
|
||||
|
@ -47,7 +50,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_UNDO_FOLLOW, "https://example.com/actor")
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
assert isinstance(entity, ActivitypubFollow)
|
||||
assert isinstance(entity, models.Follow)
|
||||
assert entity.actor_id == "https://example.com/actor"
|
||||
assert entity.target_id == "https://example.org/actor"
|
||||
assert entity.following is False
|
||||
|
@ -65,7 +68,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_POST, "https://diaspodon.fr/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
post = entities[0]
|
||||
assert isinstance(post, ActivitypubPost)
|
||||
assert isinstance(post, models.Note)
|
||||
assert isinstance(post, Post)
|
||||
assert post.raw_content == '<p><span class="h-card"><a class="u-url mention" ' \
|
||||
'href="https://dev.jasonrobinson.me/u/jaywink/">' \
|
||||
|
@ -82,15 +85,17 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_POST_WITH_TAGS, "https://diaspodon.fr/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
post = entities[0]
|
||||
assert isinstance(post, ActivitypubPost)
|
||||
assert isinstance(post, models.Note)
|
||||
assert isinstance(post, Post)
|
||||
assert post.raw_content == '<p>boom #test</p>'
|
||||
|
||||
# TODO: fix this test
|
||||
@pytest.mark.skip
|
||||
def test_message_to_objects_simple_post__with_mentions(self):
|
||||
entities = message_to_objects(ACTIVITYPUB_POST_WITH_MENTIONS, "https://mastodon.social/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
post = entities[0]
|
||||
assert isinstance(post, ActivitypubPost)
|
||||
assert isinstance(post, models.Note)
|
||||
assert isinstance(post, Post)
|
||||
assert len(post._mentions) == 1
|
||||
assert list(post._mentions)[0] == "https://dev3.jasonrobinson.me/u/jaywink/"
|
||||
|
@ -99,7 +104,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, "https://diaspodon.fr/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
post = entities[0]
|
||||
assert isinstance(post, ActivitypubPost)
|
||||
assert isinstance(post, models.Note)
|
||||
assert isinstance(post, Post)
|
||||
assert post.rendered_content == '<p><span class="h-card"><a class="u-url mention" href="https://dev.jasonrobinson.me/u/jaywink/">' \
|
||||
'@<span>jaywink</span></a></span> boom</p>'
|
||||
|
@ -111,7 +116,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_POST_WITH_SOURCE_MARKDOWN, "https://diaspodon.fr/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
post = entities[0]
|
||||
assert isinstance(post, ActivitypubPost)
|
||||
assert isinstance(post, models.Note)
|
||||
assert isinstance(post, Post)
|
||||
assert post.rendered_content == '<p><span class="h-card"><a href="https://dev.jasonrobinson.me/u/jaywink/" ' \
|
||||
'class="u-url mention">@<span>jaywink</span></a></span> boom</p>'
|
||||
|
@ -126,7 +131,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_POST_IMAGES, "https://mastodon.social/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
post = entities[0]
|
||||
assert isinstance(post, ActivitypubPost)
|
||||
assert isinstance(post, models.Note)
|
||||
# TODO: test video and audio attachment
|
||||
assert len(post._children) == 2
|
||||
photo = post._children[0]
|
||||
|
@ -144,7 +149,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_COMMENT, "https://diaspodon.fr/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
comment = entities[0]
|
||||
assert isinstance(comment, ActivitypubComment)
|
||||
assert isinstance(comment, models.Note)
|
||||
assert isinstance(comment, Comment)
|
||||
assert comment.raw_content == '<p><span class="h-card"><a class="u-url mention" ' \
|
||||
'href="https://dev.jasonrobinson.me/u/jaywink/">' \
|
||||
|
@ -216,7 +221,22 @@ class TestActivitypubEntityMappersReceive:
|
|||
assert profile.id == "https://friendica.feneas.org/profile/feneas"
|
||||
assert profile.guid == "76158462365bd347844d248732383358"
|
||||
|
||||
def test_message_to_objects_receivers_are_saved(self):
|
||||
#@patch('federation.tests.django.utils.get_profile', return_value=None)
|
||||
@patch('federation.entities.activitypub.models.get_profile', return_value=None)
|
||||
@patch('federation.utils.activitypub.fetch_document')
|
||||
def test_message_to_objects_receivers_are_saved(self, mock_fetch, mock_func):
|
||||
def side_effect(*args, **kwargs):
|
||||
payloads = {'https://diaspodon.fr/users/jaywink': json.dumps(ACTIVITYPUB_PROFILE),
|
||||
'https://fosstodon.org/users/astdenis': json.dumps(ACTIVITYPUB_REMOTE_PROFILE),
|
||||
'https://diaspodon.fr/users/jaywink/followers': json.dumps(ACTIVITYPUB_COLLECTION),
|
||||
}
|
||||
if args[0] in payloads.keys():
|
||||
return payloads[args[0]], 200, None
|
||||
else:
|
||||
return None, None, 'Nothing here'
|
||||
|
||||
mock_fetch.side_effect = side_effect
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
entities = message_to_objects(
|
||||
ACTIVITYPUB_POST,
|
||||
|
@ -229,7 +249,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
id='https://diaspodon.fr/users/jaywink', receiver_variant=ReceiverVariant.FOLLOWERS,
|
||||
),
|
||||
UserType(
|
||||
id='https://dev.jasonrobinson.me/p/d4574854-a5d7-42be-bfac-f70c16fcaa97/',
|
||||
id='https://fosstodon.org/users/astdenis',
|
||||
receiver_variant=ReceiverVariant.ACTOR,
|
||||
)
|
||||
}
|
||||
|
@ -238,7 +258,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_RETRACTION, "https://friendica.feneas.org/profile/jaywink")
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
assert isinstance(entity, ActivitypubRetraction)
|
||||
assert isinstance(entity, Retraction)
|
||||
assert entity.actor_id == "https://friendica.feneas.org/profile/jaywink"
|
||||
assert entity.target_id == "https://friendica.feneas.org/objects/76158462-165d-3386-aa23-ba2090614385"
|
||||
assert entity.entity_type == "Object"
|
||||
|
@ -247,7 +267,7 @@ class TestActivitypubEntityMappersReceive:
|
|||
entities = message_to_objects(ACTIVITYPUB_RETRACTION_SHARE, "https://mastodon.social/users/jaywink")
|
||||
assert len(entities) == 1
|
||||
entity = entities[0]
|
||||
assert isinstance(entity, ActivitypubRetraction)
|
||||
assert isinstance(entity, Retraction)
|
||||
assert entity.actor_id == "https://mastodon.social/users/jaywink"
|
||||
assert entity.target_id == "https://mastodon.social/users/jaywink/statuses/102571932479036987/activity"
|
||||
assert entity.entity_type == "Object"
|
||||
|
@ -296,30 +316,30 @@ class TestActivitypubEntityMappersReceive:
|
|||
|
||||
class TestGetOutboundEntity:
|
||||
def test_already_fine_entities_are_returned_as_is(self, private_key):
|
||||
entity = ActivitypubAccept()
|
||||
entity = models.Accept()
|
||||
entity.validate = Mock()
|
||||
assert get_outbound_entity(entity, private_key) == entity
|
||||
entity = ActivitypubFollow()
|
||||
entity = models.Follow()
|
||||
entity.validate = Mock()
|
||||
assert get_outbound_entity(entity, private_key) == entity
|
||||
entity = ActivitypubProfile()
|
||||
entity = models.Person()
|
||||
entity.validate = Mock()
|
||||
assert get_outbound_entity(entity, private_key) == entity
|
||||
|
||||
@patch.object(ActivitypubAccept, "validate", new=Mock())
|
||||
@patch.object(models.Accept, "validate", new=Mock())
|
||||
def test_accept_is_converted_to_activitypubaccept(self, private_key):
|
||||
entity = Accept()
|
||||
assert isinstance(get_outbound_entity(entity, private_key), ActivitypubAccept)
|
||||
assert isinstance(get_outbound_entity(entity, private_key), models.Accept)
|
||||
|
||||
@patch.object(ActivitypubFollow, "validate", new=Mock())
|
||||
@patch.object(models.Follow, "validate", new=Mock())
|
||||
def test_follow_is_converted_to_activitypubfollow(self, private_key):
|
||||
entity = Follow()
|
||||
assert isinstance(get_outbound_entity(entity, private_key), ActivitypubFollow)
|
||||
assert isinstance(get_outbound_entity(entity, private_key), models.Follow)
|
||||
|
||||
@patch.object(ActivitypubProfile, "validate", new=Mock())
|
||||
@patch.object(models.Person, "validate", new=Mock())
|
||||
def test_profile_is_converted_to_activitypubprofile(self, private_key):
|
||||
entity = Profile()
|
||||
assert isinstance(get_outbound_entity(entity, private_key), ActivitypubProfile)
|
||||
assert isinstance(get_outbound_entity(entity, private_key), models.Person)
|
||||
|
||||
def test_entity_is_validated__fail(self, private_key):
|
||||
entity = Share(
|
||||
|
|
|
@ -19,7 +19,7 @@ class TestGetBaseAttributes:
|
|||
assert set(attrs) == {
|
||||
"created_at", "location", "provider_display_name", "public", "raw_content",
|
||||
"signature", "base_url", "actor_id", "id", "handle", "guid", "activity", "activity_id",
|
||||
"url", "mxid",
|
||||
"url", "mxid", "times", "to", "cc", "finger",
|
||||
}
|
||||
entity = Profile()
|
||||
attrs = get_base_attributes(entity).keys()
|
||||
|
@ -27,7 +27,7 @@ class TestGetBaseAttributes:
|
|||
"created_at", "name", "email", "gender", "raw_content", "location", "public",
|
||||
"nsfw", "public_key", "image_urls", "tag_list", "signature", "url", "atom_url",
|
||||
"base_url", "id", "actor_id", "handle", "handle", "guid", "activity", "activity_id", "username",
|
||||
"inboxes", "mxid",
|
||||
"inboxes", "mxid", "times", "to", "cc", "finger",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -87,3 +87,4 @@ class ShareFactory(ActorIDMixinFactory, EntityTypeMixinFactory, IDMixinFactory,
|
|||
|
||||
raw_content = ""
|
||||
provider_display_name = ""
|
||||
to = ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import pytest
|
||||
# noinspection PyPackageRequirements
|
||||
from freezegun import freeze_time
|
||||
from unittest.mock import patch
|
||||
|
||||
from federation.entities.activitypub.entities import (
|
||||
ActivitypubPost, ActivitypubAccept, ActivitypubFollow, ActivitypubProfile, ActivitypubComment,
|
||||
ActivitypubRetraction, ActivitypubShare, ActivitypubImage)
|
||||
from federation.entities.base import Profile, Post
|
||||
from federation.entities.activitypub.mappers import get_outbound_entity
|
||||
import federation.entities.activitypub.models as models
|
||||
from federation.entities.base import Profile, Post, Comment, Retraction
|
||||
from federation.entities.diaspora.entities import (
|
||||
DiasporaPost, DiasporaComment, DiasporaLike, DiasporaProfile, DiasporaRetraction,
|
||||
DiasporaContact, DiasporaReshare,
|
||||
|
@ -18,8 +18,8 @@ from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD
|
|||
@pytest.fixture
|
||||
def activitypubannounce():
|
||||
with freeze_time("2019-08-05"):
|
||||
return ActivitypubShare(
|
||||
activity_id="http://127.0.0.1:8000/post/123456/#create",
|
||||
return models.Announce(
|
||||
id="http://127.0.0.1:8000/post/123456/#create",
|
||||
actor_id="http://127.0.0.1:8000/profile/123456/",
|
||||
target_id="http://127.0.0.1:8000/post/012345/",
|
||||
)
|
||||
|
@ -28,7 +28,7 @@ def activitypubannounce():
|
|||
@pytest.fixture
|
||||
def activitypubcomment():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubComment(
|
||||
obj = models.Comment(
|
||||
raw_content="raw_content",
|
||||
public=True,
|
||||
provider_display_name="Socialhome",
|
||||
|
@ -37,11 +37,13 @@ def activitypubcomment():
|
|||
actor_id=f"http://127.0.0.1:8000/profile/123456/",
|
||||
target_id="http://127.0.0.1:8000/post/012345/",
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubfollow():
|
||||
return ActivitypubFollow(
|
||||
return models.Follow(
|
||||
activity_id="https://localhost/follow",
|
||||
actor_id="https://localhost/profile",
|
||||
target_id="https://example.com/profile",
|
||||
|
@ -50,18 +52,18 @@ def activitypubfollow():
|
|||
|
||||
@pytest.fixture
|
||||
def activitypubaccept(activitypubfollow):
|
||||
return ActivitypubAccept(
|
||||
return models.Accept(
|
||||
activity_id="https://localhost/accept",
|
||||
actor_id="https://localhost/profile",
|
||||
target_id="https://example.com/follow/1234",
|
||||
object=activitypubfollow.to_as2(),
|
||||
object_=activitypubfollow,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubpost():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubPost(
|
||||
obj = models.Post(
|
||||
raw_content="# raw_content",
|
||||
public=True,
|
||||
provider_display_name="Socialhome",
|
||||
|
@ -69,13 +71,17 @@ def activitypubpost():
|
|||
activity_id=f"http://127.0.0.1:8000/post/123456/#create",
|
||||
actor_id=f"http://127.0.0.1:8000/profile/123456/",
|
||||
_media_type="text/markdown",
|
||||
to=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
cc=["https://http://127.0.0.1:8000/profile/123456/followers/"]
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubpost_diaspora_guid():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubPost(
|
||||
obj = models.Post(
|
||||
raw_content="raw_content",
|
||||
public=True,
|
||||
provider_display_name="Socialhome",
|
||||
|
@ -84,12 +90,14 @@ def activitypubpost_diaspora_guid():
|
|||
actor_id=f"http://127.0.0.1:8000/profile/123456/",
|
||||
guid="totallyrandomguid",
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubpost_images():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubPost(
|
||||
obj = models.Post(
|
||||
raw_content="raw_content",
|
||||
public=True,
|
||||
provider_display_name="Socialhome",
|
||||
|
@ -97,34 +105,38 @@ def activitypubpost_images():
|
|||
activity_id=f"http://127.0.0.1:8000/post/123456/#create",
|
||||
actor_id=f"http://127.0.0.1:8000/profile/123456/",
|
||||
_children=[
|
||||
ActivitypubImage(url="foobar", media_type="image/jpeg"),
|
||||
ActivitypubImage(url="barfoo", name="spam and eggs", media_type="image/jpeg"),
|
||||
models.Image(url="foobar", media_type="image/jpeg"),
|
||||
models.Image(url="barfoo", name="spam and eggs", media_type="image/jpeg"),
|
||||
],
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubpost_mentions():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubPost(
|
||||
raw_content="""# raw_content\n\n@{someone@localhost.local} @{http://localhost.local/someone}""",
|
||||
obj = models.Post(
|
||||
raw_content="""# raw_content\n\n@someone@localhost.local @jaywink@localhost.local""",
|
||||
public=True,
|
||||
provider_display_name="Socialhome",
|
||||
id=f"http://127.0.0.1:8000/post/123456/",
|
||||
activity_id=f"http://127.0.0.1:8000/post/123456/#create",
|
||||
actor_id=f"http://127.0.0.1:8000/profile/123456/",
|
||||
_mentions={
|
||||
"http://127.0.0.1:8000/profile/999999",
|
||||
"jaywink@localhost.local",
|
||||
"http://localhost.local/someone",
|
||||
}
|
||||
# _mentions={
|
||||
# "http://127.0.0.1:8000/profile/999999",
|
||||
# "jaywink@localhost.local",
|
||||
# "http://localhost.local/someone",
|
||||
# }
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubpost_tags():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubPost(
|
||||
obj = models.Post(
|
||||
raw_content="# raw_content\n#foobar\n#barfoo",
|
||||
public=True,
|
||||
provider_display_name="Socialhome",
|
||||
|
@ -132,12 +144,14 @@ def activitypubpost_tags():
|
|||
activity_id=f"http://127.0.0.1:8000/post/123456/#create",
|
||||
actor_id=f"http://127.0.0.1:8000/profile/123456/",
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubpost_embedded_images():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubPost(
|
||||
obj = models.Post(
|
||||
raw_content="""
|
||||
#Cycling #lauttasaari #sea #sun
|
||||
|
||||
|
@ -158,11 +172,15 @@ https://jasonrobinson.me/media/uploads/2019/07/16/daa24d89-cedf-4fc7-bad8-74a902
|
|||
activity_id=f"http://127.0.0.1:8000/post/123456/#create",
|
||||
actor_id=f"https://jasonrobinson.me/u/jaywink/",
|
||||
)
|
||||
obj.times={'edited':False, 'created':obj.created_at}
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubprofile():
|
||||
return ActivitypubProfile(
|
||||
@patch.object(models.base.Image, 'get_media_type', return_value="image/jpeg")
|
||||
def activitypubprofile(mock_fetch):
|
||||
with freeze_time("2022-09-06"):
|
||||
return models.Person(
|
||||
id="https://example.com/bob", raw_content="foobar", name="Bob Bobertson", public=True,
|
||||
tag_list=["socialfederation", "federation"], image_urls={
|
||||
"large": "urllarge", "medium": "urlmedium", "small": "urlsmall"
|
||||
|
@ -174,8 +192,10 @@ def activitypubprofile():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubprofile_diaspora_guid():
|
||||
return ActivitypubProfile(
|
||||
@patch.object(models.base.Image, 'get_media_type', return_value="image/jpeg")
|
||||
def activitypubprofile_diaspora_guid(mock_fetch):
|
||||
with freeze_time("2022-09-06"):
|
||||
return models.Person(
|
||||
id="https://example.com/bob", raw_content="foobar", name="Bob Bobertson", public=True,
|
||||
tag_list=["socialfederation", "federation"], image_urls={
|
||||
"large": "urllarge", "medium": "urlmedium", "small": "urlsmall"
|
||||
|
@ -190,28 +210,31 @@ def activitypubprofile_diaspora_guid():
|
|||
@pytest.fixture
|
||||
def activitypubretraction():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubRetraction(
|
||||
obj = Retraction(
|
||||
target_id="http://127.0.0.1:8000/post/123456/",
|
||||
activity_id="http://127.0.0.1:8000/post/123456/#delete",
|
||||
actor_id="http://127.0.0.1:8000/profile/123456/",
|
||||
entity_type="Post",
|
||||
)
|
||||
return get_outbound_entity(obj, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubretraction_announce():
|
||||
with freeze_time("2019-04-27"):
|
||||
return ActivitypubRetraction(
|
||||
target_id="http://127.0.0.1:8000/post/123456/activity",
|
||||
obj = Retraction(
|
||||
id="http://127.0.0.1:8000/post/123456/activity",
|
||||
target_id="http://127.0.0.1:8000/post/123456",
|
||||
activity_id="http://127.0.0.1:8000/post/123456/#delete",
|
||||
actor_id="http://127.0.0.1:8000/profile/123456/",
|
||||
entity_type="Share",
|
||||
)
|
||||
return get_outbound_entity(obj, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activitypubundofollow():
|
||||
return ActivitypubFollow(
|
||||
return models.Follow(
|
||||
activity_id="https://localhost/undo",
|
||||
actor_id="https://localhost/profile",
|
||||
target_id="https://example.com/profile",
|
||||
|
@ -232,7 +255,7 @@ def profile():
|
|||
inboxes={
|
||||
"private": "https://example.com/bob/private",
|
||||
"public": "https://example.com/public",
|
||||
}, public_key=PUBKEY,
|
||||
}, public_key=PUBKEY, to=["https://www.w3.org/ns/activitystreams#Public"]
|
||||
)
|
||||
|
||||
|
||||
|
@ -380,7 +403,7 @@ def diasporaretraction():
|
|||
|
||||
@pytest.fixture
|
||||
def post():
|
||||
return Post(
|
||||
return models.Post(
|
||||
raw_content="""One more test before sleep 😅 This time with an image.
|
||||
|
||||
""",
|
||||
|
|
|
@ -69,3 +69,7 @@ XML2 = "<comment><guid>d728fe501584013514526c626dd55703</guid><parent_guid>d641b
|
|||
|
||||
def get_dummy_private_key():
|
||||
return RSA.importKey(PRIVATE_KEY)
|
||||
|
||||
|
||||
def get_dummy_public_key():
|
||||
return PUBKEY
|
||||
|
|
|
@ -128,6 +128,85 @@ ACTIVITYPUB_PROFILE = {
|
|||
}
|
||||
}
|
||||
|
||||
ACTIVITYPUB_REMOTE_PROFILE = {
|
||||
"@context": ["https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{"Curve25519Key": "toot:Curve25519Key",
|
||||
"Device": "toot:Device",
|
||||
"Ed25519Key": "toot:Ed25519Key",
|
||||
"Ed25519Signature": "toot:Ed25519Signature",
|
||||
"EncryptedMessage": "toot:EncryptedMessage",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
||||
"cipherText": "toot:cipherText",
|
||||
"claim": {"@id": "toot:claim", "@type": "@id"},
|
||||
"deviceId": "toot:deviceId",
|
||||
"devices": {"@id": "toot:devices", "@type": "@id"},
|
||||
"discoverable": "toot:discoverable",
|
||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||
"featuredTags": {"@id": "toot:featuredTags", "@type": "@id"},
|
||||
"fingerprintKey": {"@id": "toot:fingerprintKey", "@type": "@id"},
|
||||
"focalPoint": {"@container": "@list", "@id": "toot:focalPoint"},
|
||||
"identityKey": {"@id": "toot:identityKey", "@type": "@id"},
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
"messageFranking": "toot:messageFranking",
|
||||
"messageType": "toot:messageType",
|
||||
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
|
||||
"publicKeyBase64": "toot:publicKeyBase64",
|
||||
"schema": "http://schema.org#",
|
||||
"suspended": "toot:suspended",
|
||||
"toot": "http://joinmastodon.org/ns#",
|
||||
"value": "schema:value"}],
|
||||
"attachment": [{"name": "OS", "type": "PropertyValue", "value": "Manjaro"},
|
||||
{"name": "Self Hosting",
|
||||
"type": "PropertyValue",
|
||||
"value": "Matrix HS, Nextcloud"}],
|
||||
"devices": "https://fosstodon.org/users/astdenis/collections/devices",
|
||||
"discoverable": True,
|
||||
"endpoints": {"sharedInbox": "https://fosstodon.org/inbox"},
|
||||
"featured": "https://fosstodon.org/users/astdenis/collections/featured",
|
||||
"featuredTags": "https://fosstodon.org/users/astdenis/collections/tags",
|
||||
"followers": "https://fosstodon.org/users/astdenis/followers",
|
||||
"following": "https://fosstodon.org/users/astdenis/following",
|
||||
"icon": {"mediaType": "image/jpeg",
|
||||
"type": "Image",
|
||||
"url": "https://cdn.fosstodon.org/accounts/avatars/000/252/976/original/09b7067cde009950.jpg"},
|
||||
"id": "https://fosstodon.org/users/astdenis",
|
||||
"image": {"mediaType": "image/jpeg",
|
||||
"type": "Image",
|
||||
"url": "https://cdn.fosstodon.org/accounts/headers/000/252/976/original/555a1ac1819e4e7f.jpg"},
|
||||
"inbox": "https://fosstodon.org/users/astdenis/inbox",
|
||||
"manuallyApprovesFollowers": False,
|
||||
"name": "Alain",
|
||||
"outbox": "https://fosstodon.org/users/astdenis/outbox",
|
||||
"preferredUsername": "astdenis",
|
||||
"publicKey": {"id": "https://fosstodon.org/users/astdenis#main-key",
|
||||
"owner": "https://fosstodon.org/users/astdenis",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n"
|
||||
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuaoIq/b+aUNqGAJNYF76\n"
|
||||
"WY8tk49Vb1udyb7X+oseBXYtOwCDGfbZMalnFfqur1bAzogkKzuyjCeA3BfVs6R3\n"
|
||||
"Cll897kUveMNHVc24pslhOx5ZzwpNT8e4q97dNaeHWLSLH5H+4JJGbeoD23G5SaY\n"
|
||||
"9ZKt5iP+qRUlO/kSsUPwqsX9i2qSEqzwDiSvyRYhvvx4O588cUaaY9rAliLgtc/P\n"
|
||||
"4EID3v6Edexe2QosUaghwGbb8zZWsYq0O4Umn2QMN4LzmQ0FjP+lq1TFX8FkGDZH\n"
|
||||
"lnP+AMEQMyuac9Yb12t4RwvdsAIk4MXhAKvutMJm/X1GVQIyrsLEmvAO3rgk8dMr\n"
|
||||
"6QIDAQAB\n"
|
||||
"-----END PUBLIC KEY-----\n"},
|
||||
"published": "2020-07-25T00:00:00Z",
|
||||
"summary": "<p>Linux user and sysadmin since 1994, retired from the HPC field "
|
||||
"since 2019.</p><p>Utilisateur et sysadmin Linux depuis 1994, "
|
||||
"retraité du domaine du CHP depuis 2019.</p>",
|
||||
"tag": [],
|
||||
"type": "Person",
|
||||
"url": "https://fosstodon.org/@astdenis"
|
||||
}
|
||||
|
||||
ACTIVITYPUB_COLLECTION = {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://diaspodon.fr/users/jaywink/followers",
|
||||
"totalItems": 231,
|
||||
"type": "OrderedCollection"
|
||||
}
|
||||
|
||||
ACTIVITYPUB_PROFILE_INVALID = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
|
@ -313,7 +392,7 @@ ACTIVITYPUB_POST = {
|
|||
'published': '2019-06-29T21:08:45Z',
|
||||
'to': 'https://www.w3.org/ns/activitystreams#Public',
|
||||
'cc': ['https://diaspodon.fr/users/jaywink/followers',
|
||||
'https://dev.jasonrobinson.me/p/d4574854-a5d7-42be-bfac-f70c16fcaa97/'],
|
||||
'https://fosstodon.org/users/astdenis'],
|
||||
'object': {'id': 'https://diaspodon.fr/users/jaywink/statuses/102356911717767237',
|
||||
'type': 'Note',
|
||||
'summary': None,
|
||||
|
@ -323,7 +402,7 @@ ACTIVITYPUB_POST = {
|
|||
'attributedTo': 'https://diaspodon.fr/users/jaywink',
|
||||
'to': 'https://www.w3.org/ns/activitystreams#Public',
|
||||
'cc': ['https://diaspodon.fr/users/jaywink/followers',
|
||||
'https://dev.jasonrobinson.me/p/d4574854-a5d7-42be-bfac-f70c16fcaa97/'],
|
||||
'https://fosstodon.org/users/astdenis'],
|
||||
'sensitive': False,
|
||||
'atomUri': 'https://diaspodon.fr/users/jaywink/statuses/102356911717767237',
|
||||
'inReplyToAtomUri': None,
|
||||
|
|
|
@ -10,7 +10,7 @@ class TestRetrieveRemoteContent:
|
|||
mock_import.return_value = mock_retrieve
|
||||
retrieve_remote_content("https://example.com/foobar")
|
||||
mock_retrieve.retrieve_and_parse_content.assert_called_once_with(
|
||||
id="https://example.com/foobar", guid=None, handle=None, entity_type=None, sender_key_fetcher=None,
|
||||
id="https://example.com/foobar", guid=None, handle=None, entity_type=None, cache=True, sender_key_fetcher=None,
|
||||
)
|
||||
|
||||
@patch("federation.fetchers.importlib.import_module")
|
||||
|
@ -19,7 +19,7 @@ class TestRetrieveRemoteContent:
|
|||
mock_import.return_value = mock_retrieve
|
||||
retrieve_remote_content("1234", handle="user@example.com", entity_type="post", sender_key_fetcher=sum)
|
||||
mock_retrieve.retrieve_and_parse_content.assert_called_once_with(
|
||||
id="1234", guid="1234", handle="user@example.com", entity_type="post", sender_key_fetcher=sum,
|
||||
id="1234", guid="1234", handle="user@example.com", entity_type="post", cache=True, sender_key_fetcher=sum,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -70,7 +70,9 @@ class TestHandleSend:
|
|||
assert kwargs['headers'] == {
|
||||
'Content-Type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
}
|
||||
assert encode_if_text("https://www.w3.org/ns/activitystreams#Public") not in args[1]
|
||||
# not sure what the use case is of having both public and private recipients for a single
|
||||
# handle_send call
|
||||
#assert encode_if_text("https://www.w3.org/ns/activitystreams#Public") not in args[1]
|
||||
|
||||
# Ensure third call is a public activitypub payload
|
||||
args, kwargs = mock_send.call_args_list[2]
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from datetime import timedelta
|
||||
import json
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from federation.entities.activitypub.entities import ActivitypubFollow, ActivitypubPost
|
||||
from federation.entities.activitypub.models import Follow, Note
|
||||
from federation.tests.fixtures.payloads import (
|
||||
ACTIVITYPUB_FOLLOW, ACTIVITYPUB_POST, ACTIVITYPUB_POST_OBJECT, ACTIVITYPUB_POST_OBJECT_IMAGES)
|
||||
from federation.utils.activitypub import (
|
||||
|
@ -47,40 +48,44 @@ class TestRetrieveAndParseDocument:
|
|||
# auth argument is passed with kwargs
|
||||
auth = mock_fetch.call_args.kwargs.get('auth', None)
|
||||
mock_fetch.assert_called_once_with(
|
||||
"https://example.com/foobar", extra_headers={'accept': 'application/activity+json'}, auth=auth,
|
||||
"https://example.com/foobar", extra_headers={'accept': 'application/activity+json'}, cache=True, auth=auth,
|
||||
)
|
||||
|
||||
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
|
||||
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
|
||||
json.dumps(ACTIVITYPUB_FOLLOW), None, None),
|
||||
)
|
||||
@patch.object(ActivitypubFollow, "post_receive")
|
||||
def test_returns_entity_for_valid_document__follow(self, mock_post_receive, mock_fetch):
|
||||
@patch.object(Follow, "post_receive")
|
||||
def test_returns_entity_for_valid_document__follow(self, mock_post_receive, mock_fetch, mock_recv):
|
||||
entity = retrieve_and_parse_document("https://example.com/foobar")
|
||||
assert isinstance(entity, ActivitypubFollow)
|
||||
assert isinstance(entity, Follow)
|
||||
|
||||
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
|
||||
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
|
||||
json.dumps(ACTIVITYPUB_POST_OBJECT), None, None),
|
||||
)
|
||||
def test_returns_entity_for_valid_document__post__without_activity(self, mock_fetch):
|
||||
def test_returns_entity_for_valid_document__post__without_activity(self, mock_fetch, mock_recv):
|
||||
entity = retrieve_and_parse_document("https://example.com/foobar")
|
||||
assert isinstance(entity, ActivitypubPost)
|
||||
assert isinstance(entity, Note)
|
||||
|
||||
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
|
||||
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
|
||||
json.dumps(ACTIVITYPUB_POST_OBJECT_IMAGES), None, None),
|
||||
)
|
||||
def test_returns_entity_for_valid_document__post__without_activity__with_images(self, mock_fetch):
|
||||
def test_returns_entity_for_valid_document__post__without_activity__with_images(self, mock_fetch, mock_recv):
|
||||
entity = retrieve_and_parse_document("https://example.com/foobar")
|
||||
assert isinstance(entity, ActivitypubPost)
|
||||
assert isinstance(entity, Note)
|
||||
assert len(entity._children) == 1
|
||||
assert entity._children[0].url == "https://files.mastodon.social/media_attachments/files/017/792/237/original" \
|
||||
"/foobar.jpg"
|
||||
|
||||
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
|
||||
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
|
||||
json.dumps(ACTIVITYPUB_POST), None, None),
|
||||
)
|
||||
def test_returns_entity_for_valid_document__post__wrapped_in_activity(self, mock_fetch):
|
||||
def test_returns_entity_for_valid_document__post__wrapped_in_activity(self, mock_fetch, mock_recv):
|
||||
entity = retrieve_and_parse_document("https://example.com/foobar")
|
||||
assert isinstance(entity, ActivitypubPost)
|
||||
assert isinstance(entity, Note)
|
||||
|
||||
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=('{"foo": "bar"}', None, None))
|
||||
def test_returns_none_for_invalid_document(self, mock_fetch):
|
||||
|
|
|
@ -127,7 +127,7 @@ class TestRetrieveAndParseContent:
|
|||
@patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs")
|
||||
def test_calls_fetch_document(self, mock_get, mock_fetch):
|
||||
retrieve_and_parse_content(id="eggs", guid="eggs", handle="user@example.com", entity_type="spam")
|
||||
mock_fetch.assert_called_once_with("https://example.com/fetch/spam/eggs")
|
||||
mock_fetch.assert_called_once_with("https://example.com/fetch/spam/eggs", cache=True)
|
||||
|
||||
@patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None))
|
||||
@patch("federation.utils.diaspora.get_fetch_content_endpoint")
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import timedelta
|
||||
from unittest.mock import patch, Mock, call
|
||||
|
||||
import pytest
|
||||
|
@ -12,24 +13,25 @@ from federation.utils.network import (
|
|||
class TestFetchDocument:
|
||||
call_args = {"timeout": 10, "headers": {'user-agent': USER_AGENT}}
|
||||
|
||||
@patch("federation.utils.network.requests.get", return_value=Mock(status_code=200, text="foo"))
|
||||
@patch("federation.utils.network.session.get", return_value=Mock(status_code=200, text="foo"))
|
||||
def test_extra_headers(self, mock_get):
|
||||
fetch_document("https://example.com/foo", extra_headers={'accept': 'application/activity+json'})
|
||||
mock_get.assert_called_once_with('https://example.com/foo', timeout=10, headers={
|
||||
'user-agent': USER_AGENT, 'accept': 'application/activity+json',
|
||||
})
|
||||
'user-agent': USER_AGENT, 'accept': 'application/activity+json'},
|
||||
expire_after=timedelta(hours=6)
|
||||
)
|
||||
|
||||
def test_raises_without_url_and_host(self):
|
||||
with pytest.raises(ValueError):
|
||||
fetch_document()
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_url_is_called(self, mock_get):
|
||||
mock_get.return_value = Mock(status_code=200, text="foo")
|
||||
fetch_document("https://localhost")
|
||||
assert mock_get.called
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_host_is_called_with_https_first_then_http(self, mock_get):
|
||||
def mock_failing_https_get(url, *args, **kwargs):
|
||||
if url.find("https://") > -1:
|
||||
|
@ -43,7 +45,7 @@ class TestFetchDocument:
|
|||
call("http://localhost/", **self.call_args),
|
||||
]
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_host_is_sanitized(self, mock_get):
|
||||
mock_get.return_value = Mock(status_code=200, text="foo")
|
||||
fetch_document(host="http://localhost")
|
||||
|
@ -51,7 +53,7 @@ class TestFetchDocument:
|
|||
call("https://localhost/", **self.call_args)
|
||||
]
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_path_is_sanitized(self, mock_get):
|
||||
mock_get.return_value = Mock(status_code=200, text="foo")
|
||||
fetch_document(host="localhost", path="foobar/bazfoo")
|
||||
|
@ -59,7 +61,7 @@ class TestFetchDocument:
|
|||
call("https://localhost/foobar/bazfoo", **self.call_args)
|
||||
]
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_exception_is_raised_if_both_protocols_fail(self, mock_get):
|
||||
mock_get.side_effect = HTTPError
|
||||
doc, code, exc = fetch_document(host="localhost")
|
||||
|
@ -68,7 +70,7 @@ class TestFetchDocument:
|
|||
assert code == None
|
||||
assert exc.__class__ == HTTPError
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_exception_is_raised_if_url_fails(self, mock_get):
|
||||
mock_get.side_effect = HTTPError
|
||||
doc, code, exc = fetch_document("localhost")
|
||||
|
@ -77,7 +79,7 @@ class TestFetchDocument:
|
|||
assert code == None
|
||||
assert exc.__class__ == HTTPError
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_exception_is_raised_if_http_fails_and_raise_ssl_errors_true(self, mock_get):
|
||||
mock_get.side_effect = SSLError
|
||||
doc, code, exc = fetch_document("localhost")
|
||||
|
@ -86,7 +88,7 @@ class TestFetchDocument:
|
|||
assert code == None
|
||||
assert exc.__class__ == SSLError
|
||||
|
||||
@patch("federation.utils.network.requests.get")
|
||||
@patch("federation.utils.network.session.get")
|
||||
def test_exception_is_raised_on_network_error(self, mock_get):
|
||||
mock_get.side_effect = RequestException
|
||||
doc, code, exc = fetch_document(host="localhost")
|
||||
|
|
|
@ -2,7 +2,6 @@ import json
|
|||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
from federation.entities.activitypub.entities import ActivitypubProfile
|
||||
from federation.protocols.activitypub.signing import get_http_authentication
|
||||
from federation.utils.network import fetch_document, try_retrieve_webfinger_document
|
||||
from federation.utils.text import decode_if_bytes, validate_handle
|
||||
|
@ -35,25 +34,28 @@ def get_profile_id_from_webfinger(handle: str) -> Optional[str]:
|
|||
|
||||
|
||||
def retrieve_and_parse_content(**kwargs) -> Optional[Any]:
|
||||
return retrieve_and_parse_document(kwargs.get("id"))
|
||||
return retrieve_and_parse_document(kwargs.get("id"), cache=kwargs.get('cache',True))
|
||||
|
||||
|
||||
def retrieve_and_parse_document(fid: str) -> Optional[Any]:
|
||||
def retrieve_and_parse_document(fid: str, cache: bool=True) -> Optional[Any]:
|
||||
"""
|
||||
Retrieve remote document by ID and return the entity.
|
||||
"""
|
||||
from federation.entities.activitypub.models import element_to_objects # Circulars
|
||||
document, status_code, ex = fetch_document(fid, extra_headers={'accept': 'application/activity+json'},
|
||||
document, status_code, ex = fetch_document(fid, extra_headers={'accept': 'application/activity+json'}, cache=cache,
|
||||
auth=get_http_authentication(federation_user.rsa_private_key,f'{federation_user.id}#main-key') if federation_user else None)
|
||||
if document:
|
||||
try:
|
||||
document = json.loads(decode_if_bytes(document))
|
||||
except json.decoder.JSONDecodeError:
|
||||
return None
|
||||
entities = element_to_objects(document)
|
||||
if entities:
|
||||
logger.info("retrieve_and_parse_document - using first entity: %s", entities[0])
|
||||
return entities[0]
|
||||
|
||||
|
||||
def retrieve_and_parse_profile(fid: str) -> Optional[ActivitypubProfile]:
|
||||
def retrieve_and_parse_profile(fid: str) -> Optional[Any]:
|
||||
"""
|
||||
Retrieve the remote fid and return a Profile object.
|
||||
"""
|
||||
|
|
|
@ -152,6 +152,7 @@ def parse_profile_from_hcard(hcard: str, handle: str):
|
|||
public=True,
|
||||
id=handle,
|
||||
handle=handle,
|
||||
finger=handle,
|
||||
guid=_get_element_text_or_none(doc, ".uid"),
|
||||
public_key=_get_element_text_or_none(doc, ".key"),
|
||||
username=handle.split('@')[0],
|
||||
|
@ -161,7 +162,8 @@ def parse_profile_from_hcard(hcard: str, handle: str):
|
|||
|
||||
|
||||
def retrieve_and_parse_content(
|
||||
id: str, guid: str, handle: str, entity_type: str, sender_key_fetcher: Callable[[str], str]=None):
|
||||
id: str, guid: str, handle: str, entity_type: str, cache: bool=True,
|
||||
sender_key_fetcher: Callable[[str], str]=None):
|
||||
"""Retrieve remote content and return an Entity class instance.
|
||||
|
||||
This is basically the inverse of receiving an entity. Instead, we fetch it, then call "handle_receive".
|
||||
|
@ -174,7 +176,7 @@ def retrieve_and_parse_content(
|
|||
return
|
||||
_username, domain = handle.split("@")
|
||||
url = get_fetch_content_endpoint(domain, entity_type.lower(), guid)
|
||||
document, status_code, error = fetch_document(url)
|
||||
document, status_code, error = fetch_document(url, cache=cache)
|
||||
if status_code == 200:
|
||||
request = RequestType(body=document)
|
||||
_sender, _protocol, entities = handle_receive(request, sender_key_fetcher=sender_key_fetcher)
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import importlib
|
||||
import redis
|
||||
from requests_cache import RedisCache, SQLiteCache
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
@ -59,3 +61,20 @@ def get_federation_user():
|
|||
|
||||
return UserType(id=config['federation_id'], private_key=key)
|
||||
|
||||
def get_redis():
|
||||
"""
|
||||
Returns a connected redis object if available
|
||||
"""
|
||||
config = get_configuration()
|
||||
if not config.get('redis'): return None
|
||||
|
||||
return redis.Redis(**config['redis'])
|
||||
|
||||
def get_requests_cache_backend(namespace):
|
||||
"""
|
||||
Use RedisCache is available, else fallback to SQLiteCache
|
||||
"""
|
||||
config = get_configuration()
|
||||
if not config.get('redis'): return SQLiteCache()
|
||||
|
||||
return RedisCache(namespace, **config['redis'])
|
||||
|
|
|
@ -8,30 +8,34 @@ from urllib.parse import quote
|
|||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from requests_cache import CachedSession, DO_NOT_CACHE
|
||||
from requests.exceptions import RequestException, HTTPError, SSLError
|
||||
from requests.exceptions import ConnectionError
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from federation import __version__
|
||||
from federation.utils.django import get_requests_cache_backend
|
||||
|
||||
logger = logging.getLogger("federation")
|
||||
|
||||
USER_AGENT = "python/federation/%s" % __version__
|
||||
|
||||
session = CachedSession('fed_cache', backend=get_requests_cache_backend('fed_cache'))
|
||||
EXPIRATION = datetime.timedelta(hours=6)
|
||||
|
||||
def fetch_content_type(url: str) -> Optional[str]:
|
||||
"""
|
||||
Fetch the HEAD of the remote url to determine the content type.
|
||||
"""
|
||||
try:
|
||||
response = requests.head(url, headers={'user-agent': USER_AGENT}, timeout=10)
|
||||
response = session.head(url, headers={'user-agent': USER_AGENT}, timeout=10)
|
||||
except RequestException as ex:
|
||||
logger.warning("fetch_content_type - %s when fetching url %s", ex, url)
|
||||
else:
|
||||
return response.headers.get('Content-Type')
|
||||
|
||||
|
||||
def fetch_document(url=None, host=None, path="/", timeout=10, raise_ssl_errors=True, extra_headers=None, **kwargs):
|
||||
def fetch_document(url=None, host=None, path="/", timeout=10, raise_ssl_errors=True, extra_headers=None, cache=True, **kwargs):
|
||||
"""Helper method to fetch remote document.
|
||||
|
||||
Must be given either the ``url`` or ``host``.
|
||||
|
@ -60,7 +64,8 @@ def fetch_document(url=None, host=None, path="/", timeout=10, raise_ssl_errors=T
|
|||
# Use url since it was given
|
||||
logger.debug("fetch_document: trying %s", url)
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, headers=headers, **kwargs)
|
||||
response = session.get(url, timeout=timeout, headers=headers,
|
||||
expire_after=EXPIRATION if cache else DO_NOT_CACHE, **kwargs)
|
||||
logger.debug("fetch_document: found document, code %s", response.status_code)
|
||||
response.raise_for_status()
|
||||
return response.text, response.status_code, None
|
||||
|
@ -73,7 +78,7 @@ def fetch_document(url=None, host=None, path="/", timeout=10, raise_ssl_errors=T
|
|||
url = "https://%s%s" % (host_string, path_string)
|
||||
logger.debug("fetch_document: trying %s", url)
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, headers=headers)
|
||||
response = session.get(url, timeout=timeout, headers=headers)
|
||||
logger.debug("fetch_document: found document, code %s", response.status_code)
|
||||
response.raise_for_status()
|
||||
return response.text, response.status_code, None
|
||||
|
@ -85,7 +90,7 @@ def fetch_document(url=None, host=None, path="/", timeout=10, raise_ssl_errors=T
|
|||
url = url.replace("https://", "http://")
|
||||
logger.debug("fetch_document: trying %s", url)
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout, headers=headers)
|
||||
response = session.get(url, timeout=timeout, headers=headers)
|
||||
logger.debug("fetch_document: found document, code %s", response.status_code)
|
||||
response.raise_for_status()
|
||||
return response.text, response.status_code, None
|
||||
|
@ -116,7 +121,7 @@ def fetch_file(url: str, timeout: int = 30, extra_headers: Dict = None) -> str:
|
|||
headers = {'user-agent': USER_AGENT}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
response = requests.get(url, timeout=timeout, headers=headers, stream=True)
|
||||
response = session.get(url, timeout=timeout, headers=headers, stream=True)
|
||||
response.raise_for_status()
|
||||
name = f"/tmp/{str(uuid4())}"
|
||||
with open(name, "wb") as f:
|
||||
|
@ -215,7 +220,7 @@ def try_retrieve_webfinger_document(handle: str) -> Optional[str]:
|
|||
"""
|
||||
try:
|
||||
host = handle.split("@")[1]
|
||||
except AttributeError:
|
||||
except (AttributeError, IndexError):
|
||||
logger.warning("retrieve_webfinger_document: invalid handle given: %s", handle)
|
||||
return None
|
||||
document, code, exception = fetch_document(
|
||||
|
|
6
setup.py
6
setup.py
|
@ -31,7 +31,7 @@ setup(
|
|||
"bleach>3.0",
|
||||
"calamus",
|
||||
"commonmark",
|
||||
"cryptography",
|
||||
"cryptography<=3.4.7",
|
||||
"cssselect>=0.9.2",
|
||||
"dirty-validators>=0.3.0",
|
||||
"lxml>=3.4.0",
|
||||
|
@ -39,10 +39,12 @@ setup(
|
|||
"jsonschema>=2.0.0",
|
||||
"pycryptodome>=3.4.10",
|
||||
"python-dateutil>=2.4.0",
|
||||
"python-magic",
|
||||
"python-slugify>=5.0.0",
|
||||
"python-xrd>=0.1",
|
||||
"pytz",
|
||||
"PyYAML",
|
||||
"redis",
|
||||
"requests>=2.8.0",
|
||||
"requests-cache",
|
||||
"requests-http-signature-jaywink>=0.1.0.dev0",
|
||||
|
@ -58,6 +60,8 @@ setup(
|
|||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: Implementation :: CPython',
|
||||
'Topic :: Communications',
|
||||
'Topic :: Internet',
|
||||
|
|
Ładowanie…
Reference in New Issue