Skip to content
Snippets Groups Projects
actors.py 9.59 KiB
Newer Older
  • Learn to ignore specific revisions
  • 
    from django.conf import settings
    
    from django.urls import reverse
    from django.utils import timezone
    
    from rest_framework.exceptions import PermissionDenied
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.common import preferences, session
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from . import activity, keys, models, serializers, signing, utils
    
    logger = logging.getLogger(__name__)
    
    
    def remove_tags(text):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        logger.debug("Removing tags from %s", text)
        return "".join(
            xml.etree.ElementTree.fromstring("<div>{}</div>".format(text)).itertext()
        )
    
    def get_actor_data(actor_url):
    
            timeout=5,
    
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            headers={"Accept": "application/activity+json"},
    
        response.raise_for_status()
    
        try:
            return response.json()
    
        except Exception:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            raise ValueError("Invalid actor payload: {}".format(response.text))
    
    def get_actor(fid):
    
            actor = models.Actor.objects.get(fid=fid)
    
        except models.Actor.DoesNotExist:
            actor = None
        fetch_delta = datetime.timedelta(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            minutes=preferences.get("federation__actor_fetch_delay")
        )
    
        if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
            # cache is hot, we can return as is
            return actor
    
        data = get_actor_data(fid)
    
        serializer = serializers.ActorSerializer(data=data)
        serializer.is_valid(raise_exception=True)
    
    
        return serializer.save(last_fetch_date=timezone.now())
    
    
    class SystemActor(object):
        additional_attributes = {}
    
        def get_request_auth(self):
            actor = self.get_actor_instance()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return signing.get_auth(actor.private_key, actor.private_key_id)
    
        def serialize(self):
            actor = self.get_actor_instance()
    
            serializer = serializers.ActorSerializer(actor)
    
            return serializer.data
    
    
        def get_actor_instance(self):
    
                return models.Actor.objects.get(fid=self.get_actor_id())
    
            except models.Actor.DoesNotExist:
                pass
            private, public = keys.get_key_pair()
    
            args = self.get_instance_argument(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                self.id, name=self.name, summary=self.summary, **self.additional_attributes
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            args["private_key"] = private.decode("utf-8")
            args["public_key"] = public.decode("utf-8")
    
            return models.Actor.objects.create(**args)
    
    
        def get_actor_id(self):
    
            return utils.full_url(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
            )
    
    
        def get_instance_argument(self, id, name, summary, **kwargs):
            p = {
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "preferred_username": id,
                "domain": settings.FEDERATION_HOSTNAME,
                "type": "Person",
                "name": name.format(host=settings.FEDERATION_HOSTNAME),
                "manually_approves_followers": True,
    
                "fid": self.get_actor_id(),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "shared_inbox_url": utils.full_url(
                    reverse("federation:instance-actors-inbox", kwargs={"actor": id})
                ),
                "inbox_url": utils.full_url(
                    reverse("federation:instance-actors-inbox", kwargs={"actor": id})
                ),
                "outbox_url": utils.full_url(
                    reverse("federation:instance-actors-outbox", kwargs={"actor": id})
                ),
                "summary": summary.format(host=settings.FEDERATION_HOSTNAME),
    
            }
            p.update(kwargs)
            return p
    
        def get_inbox(self, data, actor=None):
            raise NotImplementedError
    
        def post_inbox(self, data, actor=None):
    
            return self.handle(data, actor=actor)
    
    
        def get_outbox(self, data, actor=None):
            raise NotImplementedError
    
        def post_outbox(self, data, actor=None):
            raise NotImplementedError
    
    
        def handle(self, data, actor=None):
            """
            Main entrypoint for handling activities posted to the
            actor's inbox
            """
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            logger.info("Received activity on %s inbox", self.id)
    
    
            if actor is None:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise PermissionDenied("Actor not authenticated")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            serializer = serializers.ActivitySerializer(data=data, context={"actor": actor})
    
            serializer.is_valid(raise_exception=True)
    
            ac = serializer.data
            try:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                handler = getattr(self, "handle_{}".format(ac["type"].lower()))
    
            except (KeyError, AttributeError):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                logger.debug("No handler for activity %s", ac["type"])
    
            return handler(data, actor)
    
            serializer = serializers.FollowSerializer(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                data=ac, context={"follow_actor": sender}
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return logger.info("Invalid follow payload")
    
            approved = True if not self.manually_approves_followers else None
            follow = serializer.save(approved=approved)
            if follow.approved:
                return activity.accept_follow(follow)
    
        def handle_accept(self, ac, sender):
            system_actor = self.get_actor_instance()
            serializer = serializers.AcceptFollowSerializer(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                data=ac, context={"follow_target": sender, "follow_actor": system_actor}
            )
    
            if not serializer.is_valid(raise_exception=True):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return logger.info("Received invalid payload")
    
            return serializer.save()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def handle_undo_follow(self, ac, sender):
    
            system_actor = self.get_actor_instance()
            serializer = serializers.UndoFollowSerializer(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                data=ac, context={"actor": sender, "target": system_actor}
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return logger.info("Received invalid payload")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
        def handle_undo(self, ac, sender):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if ac["object"]["type"] != "Follow":
    
            if ac["object"]["actor"] != sender.fid:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                # not the same actor, permission issue
                return
    
            self.handle_undo_follow(ac, sender)
    
    
    
    class TestActor(SystemActor):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        id = "test"
        name = "{host}'s test account"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "Bot account to test federation with {host}. "
            "Send me /ping and I'll answer you."
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        additional_attributes = {"manually_approves_followers": False}
    
    
        def get_outbox(self, data, actor=None):
            return {
    
                "@context": [
                    "https://www.w3.org/ns/activitystreams",
                    "https://w3id.org/security/v1",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    {},
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    reverse("federation:instance-actors-outbox", kwargs={"actor": self.id})
                ),
    
                "type": "OrderedCollection",
                "totalItems": 0,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "orderedItems": [],
    
            }
    
        def parse_command(self, message):
            """
            Remove any links or fancy markup to extract /command from
            a note message.
            """
            raw = remove_tags(message)
            try:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return raw.split("/")[1]
    
        def handle_create(self, ac, sender):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if ac["object"]["type"] != "Note":
    
                return
    
            # we received a toot \o/
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            command = self.parse_command(ac["object"]["content"])
            logger.debug("Parsed command: %s", command)
            if command != "ping":
    
            now = timezone.now()
            test_actor = self.get_actor_instance()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            reply_url = "https://{}/activities/note/{}".format(
    
                settings.FEDERATION_HOSTNAME, now.timestamp()
            )
            reply_activity = {
                "@context": [
    
                    "https://www.w3.org/ns/activitystreams",
                    "https://w3id.org/security/v1",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    {},
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "type": "Create",
    
                "actor": test_actor.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "id": "{}/activity".format(reply_url),
                "published": now.isoformat(),
                "to": ac["actor"],
                "cc": [],
                "object": {
                    "type": "Note",
                    "content": "Pong!",
                    "summary": None,
                    "published": now.isoformat(),
                    "id": reply_url,
                    "inReplyTo": ac["object"]["id"],
                    "sensitive": False,
                    "url": reply_url,
                    "to": [ac["actor"]],
    
                    "attributedTo": test_actor.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "cc": [],
                    "attachment": [],
                    "tag": [
                        {
                            "type": "Mention",
                            "href": ac["actor"],
    
                            "name": sender.full_username,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                        }
                    ],
                },
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor)
    
        def handle_follow(self, ac, sender):
    
            super().handle_follow(ac, sender)
            # also, we follow back
    
            test_actor = self.get_actor_instance()
    
            follow_back = models.Follow.objects.get_or_create(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                actor=test_actor, target=sender, approved=None
    
            activity.deliver(
    
                serializers.FollowSerializer(follow_back).data,
    
                to=[follow_back.target.fid],
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                on_behalf_of=follow_back.actor,
            )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def handle_undo_follow(self, ac, sender):
            super().handle_undo_follow(ac, sender)
            actor = self.get_actor_instance()
    
            # we also unfollow the sender, if possible
            try:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                follow = models.Follow.objects.get(target=sender, actor=actor)
    
            except models.Follow.DoesNotExist:
                return
    
            undo = serializers.UndoFollowSerializer(follow).data
    
            follow.delete()
    
            activity.deliver(undo, to=[sender.fid], on_behalf_of=actor)
    
    SYSTEM_ACTORS = {"test": TestActor()}