2019-05-12 15:32:41 +00:00
|
|
|
import logging
|
2019-08-04 14:16:15 +00:00
|
|
|
from typing import List, Callable, Dict, Union, Optional
|
2018-10-11 17:40:23 +00:00
|
|
|
|
2019-08-25 19:55:00 +00:00
|
|
|
from markdownify import markdownify
|
|
|
|
|
2019-05-12 20:30:35 +00:00
|
|
|
from federation.entities.activitypub.constants import NAMESPACE_PUBLIC
|
2019-05-12 17:17:40 +00:00
|
|
|
from federation.entities.activitypub.entities import (
|
2019-07-20 22:29:58 +00:00
|
|
|
ActivitypubFollow, ActivitypubProfile, ActivitypubAccept, ActivitypubPost, ActivitypubComment,
|
2019-09-07 22:59:31 +00:00
|
|
|
ActivitypubRetraction, ActivitypubShare, ActivitypubImage)
|
2019-08-11 21:19:07 +00:00
|
|
|
from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share, Image
|
2019-03-03 01:09:25 +00:00
|
|
|
from federation.entities.mixins import BaseEntity
|
2019-08-04 14:16:15 +00:00
|
|
|
from federation.types import UserType, ReceiverVariant
|
2018-10-11 17:40:23 +00:00
|
|
|
|
2019-05-12 15:32:41 +00:00
|
|
|
logger = logging.getLogger("federation")
|
|
|
|
|
|
|
|
|
2018-10-11 17:40:23 +00:00
|
|
|
MAPPINGS = {
|
2019-03-17 01:42:42 +00:00
|
|
|
"Accept": ActivitypubAccept,
|
2019-08-04 22:15:11 +00:00
|
|
|
"Announce": ActivitypubShare,
|
2019-08-29 20:15:34 +00:00
|
|
|
"Application": ActivitypubProfile,
|
2019-05-12 17:17:40 +00:00
|
|
|
"Article": ActivitypubPost,
|
2019-07-20 22:29:58 +00:00
|
|
|
"Delete": ActivitypubRetraction,
|
2019-06-28 21:55:23 +00:00
|
|
|
"Follow": ActivitypubFollow, # Technically not correct, but for now we support only following profiles
|
2019-08-29 20:15:34 +00:00
|
|
|
"Group": ActivitypubProfile,
|
2019-09-07 22:59:31 +00:00
|
|
|
"Image": ActivitypubImage,
|
2019-05-12 17:17:40 +00:00
|
|
|
"Note": ActivitypubPost,
|
2019-08-29 20:15:34 +00:00
|
|
|
"Organization": ActivitypubProfile,
|
2019-05-12 17:17:40 +00:00
|
|
|
"Page": ActivitypubPost,
|
2018-10-28 21:31:33 +00:00
|
|
|
"Person": ActivitypubProfile,
|
2019-08-29 20:15:34 +00:00
|
|
|
"Service": ActivitypubProfile,
|
2018-10-11 17:40:23 +00:00
|
|
|
}
|
|
|
|
|
2019-08-06 20:18:24 +00:00
|
|
|
OBJECTS = (
|
2019-08-29 20:15:34 +00:00
|
|
|
"Application",
|
2019-08-06 20:18:24 +00:00
|
|
|
"Article",
|
2019-08-29 20:15:34 +00:00
|
|
|
"Group",
|
2019-09-07 22:59:31 +00:00
|
|
|
"Image",
|
2019-08-06 20:18:24 +00:00
|
|
|
"Note",
|
2019-08-29 20:15:34 +00:00
|
|
|
"Organization",
|
2019-08-06 20:18:24 +00:00
|
|
|
"Page",
|
|
|
|
"Person",
|
2019-08-29 20:15:34 +00:00
|
|
|
"Service",
|
2019-08-06 20:18:24 +00:00
|
|
|
)
|
|
|
|
|
2019-08-06 20:56:55 +00:00
|
|
|
UNDO_MAPPINGS = {
|
|
|
|
"Follow": ActivitypubFollow,
|
|
|
|
"Announce": ActivitypubRetraction,
|
|
|
|
}
|
2018-10-11 17:40:23 +00:00
|
|
|
|
2019-08-11 21:19:07 +00:00
|
|
|
|
2019-03-17 21:54:06 +00:00
|
|
|
def element_to_objects(payload: Dict) -> List:
|
2018-10-11 17:40:23 +00:00
|
|
|
"""
|
2019-08-04 14:16:15 +00:00
|
|
|
Transform an Element to a list of entities.
|
2018-10-11 17:40:23 +00:00
|
|
|
"""
|
2019-08-06 20:56:55 +00:00
|
|
|
cls = None
|
2018-10-11 17:40:23 +00:00
|
|
|
entities = []
|
2019-08-06 20:18:24 +00:00
|
|
|
is_object = True if payload.get('type') in OBJECTS else False
|
2019-07-20 22:29:58 +00:00
|
|
|
if payload.get('type') == "Delete":
|
|
|
|
cls = ActivitypubRetraction
|
2019-08-06 20:56:55 +00:00
|
|
|
elif payload.get('type') == "Undo":
|
|
|
|
if isinstance(payload.get('object'), dict):
|
|
|
|
cls = UNDO_MAPPINGS.get(payload["object"]["type"])
|
2019-07-20 22:29:58 +00:00
|
|
|
elif isinstance(payload.get('object'), dict) and payload["object"].get('type'):
|
2019-06-29 22:02:27 +00:00
|
|
|
if payload["object"]["type"] == "Note" and payload["object"].get("inReplyTo"):
|
|
|
|
cls = ActivitypubComment
|
|
|
|
else:
|
|
|
|
cls = MAPPINGS.get(payload["object"]["type"])
|
2019-05-12 18:57:44 +00:00
|
|
|
else:
|
2019-06-29 22:02:27 +00:00
|
|
|
cls = MAPPINGS.get(payload.get('type'))
|
2018-10-11 17:40:23 +00:00
|
|
|
if not cls:
|
|
|
|
return []
|
|
|
|
|
2019-08-06 20:18:24 +00:00
|
|
|
transformed = transform_attributes(payload, cls, is_object=is_object)
|
2019-03-17 01:17:10 +00:00
|
|
|
entity = cls(**transformed)
|
2019-05-12 15:32:41 +00:00
|
|
|
# Add protocol name
|
|
|
|
entity._source_protocol = "activitypub"
|
|
|
|
# Save element object to entity for possible later use
|
|
|
|
entity._source_object = payload
|
2019-08-04 14:16:15 +00:00
|
|
|
# Extract receivers
|
|
|
|
entity._receivers = extract_receivers(payload)
|
2019-08-11 21:19:07 +00:00
|
|
|
# Extract children
|
|
|
|
if payload.get("object") and isinstance(payload.get("object"), dict):
|
2019-09-02 20:31:07 +00:00
|
|
|
# Try object if exists
|
2019-08-11 21:19:07 +00:00
|
|
|
entity._children = extract_attachments(payload.get("object"))
|
2019-09-02 20:31:07 +00:00
|
|
|
else:
|
|
|
|
# Try payload itself
|
|
|
|
entity._children = extract_attachments(payload)
|
2019-03-17 01:17:10 +00:00
|
|
|
|
|
|
|
if hasattr(entity, "post_receive"):
|
2019-03-17 21:54:06 +00:00
|
|
|
entity.post_receive()
|
2019-03-17 01:17:10 +00:00
|
|
|
|
2019-05-12 15:32:41 +00:00
|
|
|
try:
|
|
|
|
entity.validate()
|
|
|
|
except ValueError as ex:
|
|
|
|
logger.error("Failed to validate entity %s: %s", entity, ex, extra={
|
|
|
|
"transformed": transformed,
|
|
|
|
})
|
|
|
|
return []
|
|
|
|
# Extract mentions
|
2019-09-08 20:04:08 +00:00
|
|
|
if hasattr(entity, "extract_mentions"):
|
|
|
|
entity.extract_mentions()
|
2019-05-12 15:32:41 +00:00
|
|
|
|
2019-03-17 01:17:10 +00:00
|
|
|
entities.append(entity)
|
2018-10-11 17:40:23 +00:00
|
|
|
|
|
|
|
return entities
|
|
|
|
|
|
|
|
|
2019-08-11 21:19:07 +00:00
|
|
|
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', []):
|
2019-09-07 21:09:32 +00:00
|
|
|
# noinspection PyProtectedMember
|
2019-09-07 22:59:31 +00:00
|
|
|
if item.get("type") in ("Document", "Image") and item.get("mediaType") in Image._valid_media_types:
|
2019-08-18 18:43:27 +00:00
|
|
|
if item.get('pyfed:inlineImage', False):
|
|
|
|
# Skip this image as it's indicated to be inline in content and source already
|
|
|
|
continue
|
2019-08-11 21:19:07 +00:00
|
|
|
attachments.append(
|
2019-09-07 22:59:31 +00:00
|
|
|
ActivitypubImage(
|
2019-08-11 21:19:07 +00:00
|
|
|
url=item.get('url'),
|
|
|
|
name=item.get('name') or "",
|
2019-09-07 21:09:32 +00:00
|
|
|
media_type=item.get("mediaType"),
|
2019-08-11 21:19:07 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
return attachments
|
|
|
|
|
|
|
|
|
2019-08-04 14:16:15 +00:00
|
|
|
def extract_receiver(payload: Dict, receiver: str) -> Optional[UserType]:
|
|
|
|
"""
|
|
|
|
Transform a single receiver ID to a UserType.
|
|
|
|
"""
|
2019-08-05 22:13:24 +00:00
|
|
|
actor = payload.get("actor") or payload.get("attributedTo") or ""
|
2019-08-04 14:16:15 +00:00
|
|
|
if receiver == NAMESPACE_PUBLIC:
|
|
|
|
# Ignore since we already store "public" as a boolean on the entity
|
|
|
|
return
|
|
|
|
# Check for this being a list reference to followers of an actor?
|
|
|
|
# TODO: terrible hack! the way some platforms deliver to sharedInbox using just
|
|
|
|
# the followers collection as a target is annoying to us since we would have to
|
|
|
|
# store the followers collection references on application side, which we don't
|
|
|
|
# want to do since it would make application development another step more complex.
|
|
|
|
# So for now we're going to do a terrible assumption that
|
|
|
|
# 1) if "followers" in ID and
|
|
|
|
# 2) if ID starts with actor ID
|
|
|
|
# then; assume this is the followers collection of said actor ID.
|
|
|
|
# When we have a caching system, just fetch each receiver and check what it is.
|
|
|
|
# Without caching this would be too expensive to do.
|
2019-08-05 22:13:24 +00:00
|
|
|
elif receiver.find("followers") > -1 and receiver.startswith(actor):
|
|
|
|
return UserType(id=actor, receiver_variant=ReceiverVariant.FOLLOWERS)
|
2019-08-04 14:16:15 +00:00
|
|
|
# Assume actor ID
|
|
|
|
return UserType(id=receiver, receiver_variant=ReceiverVariant.ACTOR)
|
|
|
|
|
|
|
|
|
|
|
|
def extract_receivers(payload: Dict) -> List[UserType]:
|
|
|
|
"""
|
|
|
|
Exctract receivers from a payload.
|
|
|
|
"""
|
|
|
|
receivers = []
|
|
|
|
for key in ("to", "cc"):
|
|
|
|
receiver = payload.get(key)
|
|
|
|
if isinstance(receiver, list):
|
|
|
|
for item in receiver:
|
|
|
|
extracted = extract_receiver(payload, item)
|
|
|
|
if extracted:
|
|
|
|
receivers.append(extracted)
|
|
|
|
elif isinstance(receiver, str):
|
|
|
|
extracted = extract_receiver(payload, receiver)
|
|
|
|
if extracted:
|
|
|
|
receivers.append(extracted)
|
|
|
|
return receivers
|
|
|
|
|
|
|
|
|
2019-03-17 01:42:42 +00:00
|
|
|
def get_outbound_entity(entity: BaseEntity, private_key):
|
2019-03-03 01:09:25 +00:00
|
|
|
"""Get the correct outbound entity for this protocol.
|
|
|
|
|
|
|
|
We might have to look at entity values to decide the correct outbound entity.
|
|
|
|
If we cannot find one, we should raise as conversion cannot be guaranteed to the given protocol.
|
|
|
|
|
|
|
|
Private key of author is needed to be passed for signing the outbound entity.
|
|
|
|
|
|
|
|
:arg entity: An entity instance which can be of a base or protocol entity class.
|
|
|
|
:arg private_key: Private key of sender in str format
|
|
|
|
:returns: Protocol specific entity class instance.
|
|
|
|
:raises ValueError: If conversion cannot be done.
|
|
|
|
"""
|
|
|
|
if getattr(entity, "outbound_doc", None):
|
|
|
|
# If the entity already has an outbound doc, just return the entity as is
|
|
|
|
return entity
|
|
|
|
outbound = None
|
|
|
|
cls = entity.__class__
|
2019-07-20 22:29:58 +00:00
|
|
|
if cls in [
|
|
|
|
ActivitypubAccept, ActivitypubFollow, ActivitypubProfile, ActivitypubPost, ActivitypubComment,
|
2019-08-04 22:15:11 +00:00
|
|
|
ActivitypubRetraction, ActivitypubShare,
|
2019-07-20 22:29:58 +00:00
|
|
|
]:
|
2019-03-03 01:09:25 +00:00
|
|
|
# Already fine
|
|
|
|
outbound = entity
|
2019-03-17 01:42:42 +00:00
|
|
|
elif cls == Accept:
|
|
|
|
outbound = ActivitypubAccept.from_base(entity)
|
2019-03-03 01:09:25 +00:00
|
|
|
elif cls == Follow:
|
|
|
|
outbound = ActivitypubFollow.from_base(entity)
|
2019-05-12 17:17:40 +00:00
|
|
|
elif cls == Post:
|
|
|
|
outbound = ActivitypubPost.from_base(entity)
|
2019-03-03 01:09:25 +00:00
|
|
|
elif cls == Profile:
|
|
|
|
outbound = ActivitypubProfile.from_base(entity)
|
2019-07-20 22:29:58 +00:00
|
|
|
elif cls == Retraction:
|
|
|
|
outbound = ActivitypubRetraction.from_base(entity)
|
2019-06-29 22:09:47 +00:00
|
|
|
elif cls == Comment:
|
|
|
|
outbound = ActivitypubComment.from_base(entity)
|
2019-08-04 22:15:11 +00:00
|
|
|
elif cls == Share:
|
|
|
|
outbound = ActivitypubShare.from_base(entity)
|
2019-03-03 01:09:25 +00:00
|
|
|
if not outbound:
|
|
|
|
raise ValueError("Don't know how to convert this base entity to ActivityPub protocol entities.")
|
|
|
|
# TODO LDS signing
|
|
|
|
# if isinstance(outbound, DiasporaRelayableMixin) and not outbound.signature:
|
|
|
|
# # Sign by author if not signed yet. We don't want to overwrite any existing signature in the case
|
|
|
|
# # that this is being sent by the parent author
|
|
|
|
# outbound.sign(private_key)
|
|
|
|
# # If missing, also add same signature to `parent_author_signature`. This is required at the moment
|
|
|
|
# # in all situations but is apparently being removed.
|
|
|
|
# # TODO: remove this once Diaspora removes the extra signature
|
|
|
|
# outbound.parent_signature = outbound.signature
|
2019-08-18 00:20:35 +00:00
|
|
|
if hasattr(outbound, "pre_send"):
|
2019-08-17 18:02:25 +00:00
|
|
|
outbound.pre_send()
|
2019-03-03 01:09:25 +00:00
|
|
|
return outbound
|
|
|
|
|
|
|
|
|
2018-10-11 17:40:23 +00:00
|
|
|
def message_to_objects(
|
2019-03-14 20:06:39 +00:00
|
|
|
message: Dict, sender: str, sender_key_fetcher: Callable[[str], str] = None, user: UserType = None,
|
2018-10-11 17:40:23 +00:00
|
|
|
) -> List:
|
|
|
|
"""
|
|
|
|
Takes in a message extracted by a protocol and maps it to entities.
|
|
|
|
"""
|
|
|
|
# We only really expect one element here for ActivityPub.
|
2019-03-17 21:54:06 +00:00
|
|
|
return element_to_objects(message)
|
2018-10-11 17:40:23 +00:00
|
|
|
|
|
|
|
|
2019-08-18 19:37:18 +00:00
|
|
|
def transform_attribute(
|
|
|
|
key: str, value: Union[str, Dict, int], transformed: Dict, cls, is_object: bool, payload: Dict,
|
|
|
|
) -> None:
|
2018-10-11 17:40:23 +00:00
|
|
|
if value is None:
|
|
|
|
value = ""
|
|
|
|
if key == "id":
|
2019-07-20 22:29:58 +00:00
|
|
|
if is_object:
|
|
|
|
if cls == ActivitypubRetraction:
|
|
|
|
transformed["target_id"] = value
|
|
|
|
transformed["entity_type"] = "Object"
|
|
|
|
else:
|
|
|
|
transformed["id"] = value
|
2019-08-04 22:15:11 +00:00
|
|
|
elif cls in (ActivitypubProfile, ActivitypubShare):
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["id"] = value
|
2018-10-11 17:40:23 +00:00
|
|
|
else:
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["activity_id"] = value
|
2019-08-06 20:18:24 +00:00
|
|
|
elif key == "actor":
|
|
|
|
transformed["actor_id"] = value
|
|
|
|
elif key == "attributedTo" and is_object:
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["actor_id"] = value
|
2019-08-18 19:37:18 +00:00
|
|
|
elif key in ("content", "source"):
|
|
|
|
if payload.get('source') and isinstance(payload.get("source"), dict):
|
2019-09-03 20:25:45 +00:00
|
|
|
transformed["_rendered_content"] = payload.get('content').strip()
|
|
|
|
if payload.get('source').get('mediaType') == "text/markdown":
|
|
|
|
transformed["_media_type"] = "text/markdown"
|
|
|
|
transformed["raw_content"] = payload.get('source').get('content').strip()
|
2019-08-25 19:55:00 +00:00
|
|
|
else:
|
2019-09-03 20:25:45 +00:00
|
|
|
transformed["raw_content"] = markdownify(payload.get('content').strip())
|
2019-08-25 19:55:00 +00:00
|
|
|
transformed["_media_type"] = payload.get('source').get('mediaType')
|
2019-08-18 19:37:18 +00:00
|
|
|
else:
|
2019-09-03 20:25:45 +00:00
|
|
|
transformed["raw_content"] = markdownify(payload.get('content').strip()).strip()
|
2019-08-18 19:37:18 +00:00
|
|
|
# Assume HTML by convention
|
2019-09-03 20:25:45 +00:00
|
|
|
transformed["_rendered_content"] = payload.get('content').strip()
|
2019-08-18 19:37:18 +00:00
|
|
|
transformed["_media_type"] = "text/html"
|
2019-03-17 14:09:34 +00:00
|
|
|
elif key == "inboxes" and isinstance(value, dict):
|
|
|
|
if "inboxes" not in transformed:
|
|
|
|
transformed["inboxes"] = {"private": None, "public": None}
|
2019-03-17 19:33:58 +00:00
|
|
|
if value.get('sharedInbox'):
|
|
|
|
transformed["endpoints"]["public"] = value.get("sharedInbox")
|
2018-10-28 21:31:33 +00:00
|
|
|
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
|
2019-03-14 21:03:24 +00:00
|
|
|
if isinstance(value, dict):
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["image_urls"] = {
|
2019-03-14 21:03:24 +00:00
|
|
|
"small": value['url'],
|
|
|
|
"medium": value['url'],
|
|
|
|
"large": value['url'],
|
2019-03-17 14:09:34 +00:00
|
|
|
}
|
2019-03-14 21:03:24 +00:00
|
|
|
else:
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["image_urls"] = {
|
2019-03-14 21:03:24 +00:00
|
|
|
"small": value,
|
|
|
|
"medium": value,
|
|
|
|
"large": value,
|
2019-03-17 14:09:34 +00:00
|
|
|
}
|
|
|
|
elif key == "inbox":
|
|
|
|
if "inboxes" not in transformed:
|
|
|
|
transformed["inboxes"] = {"private": None, "public": None}
|
|
|
|
transformed["inboxes"]["private"] = value
|
2019-03-17 19:33:58 +00:00
|
|
|
if not transformed["inboxes"]["public"]:
|
|
|
|
transformed["inboxes"]["public"] = value
|
2019-06-29 22:02:27 +00:00
|
|
|
elif key == "inReplyTo":
|
|
|
|
transformed["target_id"] = value
|
2018-10-28 21:31:33 +00:00
|
|
|
elif key == "name":
|
2019-08-11 21:19:07 +00:00
|
|
|
transformed["name"] = value or ""
|
2019-08-06 20:56:55 +00:00
|
|
|
elif key == "object" and not is_object:
|
2018-10-11 17:40:23 +00:00
|
|
|
if isinstance(value, dict):
|
2019-06-28 21:55:23 +00:00
|
|
|
if cls == ActivitypubAccept:
|
2019-03-17 21:54:06 +00:00
|
|
|
transformed["target_id"] = value.get("id")
|
2019-06-28 21:55:23 +00:00
|
|
|
elif cls == ActivitypubFollow:
|
|
|
|
transformed["target_id"] = value.get("object")
|
2019-03-17 21:54:06 +00:00
|
|
|
else:
|
2019-05-12 19:28:12 +00:00
|
|
|
transform_attributes(value, cls, transformed, is_object=True)
|
2018-10-11 17:40:23 +00:00
|
|
|
else:
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["target_id"] = value
|
2018-10-28 21:31:33 +00:00
|
|
|
elif key == "preferredUsername":
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["username"] = value
|
2018-10-28 21:31:33 +00:00
|
|
|
elif key == "publicKey":
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["public_key"] = value.get('publicKeyPem', '')
|
2019-05-12 20:07:43 +00:00
|
|
|
elif key == "summary" and cls == ActivitypubProfile:
|
2019-03-17 14:09:34 +00:00
|
|
|
transformed["raw_content"] = value
|
2019-05-12 20:30:35 +00:00
|
|
|
elif key in ("to", "cc"):
|
|
|
|
if isinstance(value, list) and NAMESPACE_PUBLIC in value:
|
|
|
|
transformed["public"] = True
|
2019-08-10 22:34:27 +00:00
|
|
|
elif value == NAMESPACE_PUBLIC:
|
|
|
|
transformed["public"] = True
|
2019-06-28 21:55:23 +00:00
|
|
|
elif key == "type":
|
|
|
|
if value == "Undo":
|
|
|
|
transformed["following"] = False
|
2019-09-07 23:00:11 +00:00
|
|
|
else:
|
|
|
|
transformed[key] = value
|
2018-10-11 17:40:23 +00:00
|
|
|
|
|
|
|
|
2019-05-12 19:28:12 +00:00
|
|
|
def transform_attributes(payload: Dict, cls, transformed: Dict = None, is_object: bool = False) -> Dict:
|
2019-03-17 14:09:34 +00:00
|
|
|
if not transformed:
|
|
|
|
transformed = {}
|
2018-10-11 17:40:23 +00:00
|
|
|
for key, value in payload.items():
|
2019-08-18 19:37:18 +00:00
|
|
|
transform_attribute(key, value, transformed, cls, is_object, payload)
|
2018-10-11 17:40:23 +00:00
|
|
|
return transformed
|