federation/federation/entities/activitypub/entities.py

378 wiersze
12 KiB
Python

import logging
import re
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)
from federation.entities.activitypub.enums import ActorType, ObjectType, ActivityType
from federation.entities.base import Profile, Post, Follow, Accept, Comment, Retraction, Share, Image
from federation.entities.mixins import RawContentMixin, BaseEntity, PublicMixin
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()
if self._media_type != "text/markdown":
return
regex = r"!\[([\w ]*)\]\((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()
self._children.append(
ActivitypubImage(
url=groups[1],
name=groups[0] or "",
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()
def remove_tag_links(attrs, new=False):
rel = (None, "rel")
if attrs.get(rel) == "tag":
return
return attrs
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, ActivitypubEntityMixin):
_type = ObjectType.NOTE.value
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 not isinstance(self._source_object, dict):
return
source = self._source_object.get('object') if isinstance(self._source_object.get('object'), dict) else \
self._source_object
for tag in source.get('tag', []):
if tag.get('type') == "Mention" and tag.get('href'):
self._mentions.add(tag.get('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())
return as2
class ActivitypubComment(ActivitypubNoteMixin, 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
except ImportError:
logger.warning("ActivitypubFollow.post_receive - Unable to send automatic Accept back, only supported on "
"Django currently")
return
get_private_key_function = get_function_from_config("get_private_key_function")
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(),
)
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
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 ActivitypubPost(ActivitypubNoteMixin, Post):
pass
class ActivitypubProfile(ActivitypubEntityMixin, Profile):
_type = ActorType.PERSON.value
public = True
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)
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