import datetime import logging import xml from django.conf import settings from django.db import transaction from django.urls import reverse from django.utils import timezone from rest_framework.exceptions import PermissionDenied from funkwhale_api.common import preferences, session from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.music import models as music_models from funkwhale_api.music import tasks as music_tasks from . import activity, keys, models, serializers, signing, utils logger = logging.getLogger(__name__) def remove_tags(text): logger.debug("Removing tags from %s", text) return "".join( xml.etree.ElementTree.fromstring("
{}
".format(text)).itertext() ) def get_actor_data(actor_url): response = session.get_session().get( actor_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, headers={"Accept": "application/activity+json"}, ) response.raise_for_status() try: return response.json() except: raise ValueError("Invalid actor payload: {}".format(response.text)) def get_actor(actor_url): try: actor = models.Actor.objects.get(url=actor_url) except models.Actor.DoesNotExist: actor = None fetch_delta = datetime.timedelta( 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(actor_url) serializer = serializers.ActorSerializer(data=data) serializer.is_valid(raise_exception=True) return serializer.save(last_fetch_date=timezone.now()) class SystemActor(object): additional_attributes = {} manually_approves_followers = False def get_request_auth(self): actor = self.get_actor_instance() 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): try: return models.Actor.objects.get(url=self.get_actor_url()) except models.Actor.DoesNotExist: pass private, public = keys.get_key_pair() args = self.get_instance_argument( self.id, name=self.name, summary=self.summary, **self.additional_attributes ) args["private_key"] = private.decode("utf-8") args["public_key"] = public.decode("utf-8") return models.Actor.objects.create(**args) def get_actor_url(self): return utils.full_url( reverse("federation:instance-actors-detail", kwargs={"actor": self.id}) ) def get_instance_argument(self, id, name, summary, **kwargs): p = { "preferred_username": id, "domain": settings.FEDERATION_HOSTNAME, "type": "Person", "name": name.format(host=settings.FEDERATION_HOSTNAME), "manually_approves_followers": True, "url": self.get_actor_url(), "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 """ logger.info("Received activity on %s inbox", self.id) if actor is None: raise PermissionDenied("Actor not authenticated") serializer = serializers.ActivitySerializer(data=data, context={"actor": actor}) serializer.is_valid(raise_exception=True) ac = serializer.data try: handler = getattr(self, "handle_{}".format(ac["type"].lower())) except (KeyError, AttributeError): logger.debug("No handler for activity %s", ac["type"]) return return handler(data, actor) def handle_follow(self, ac, sender): serializer = serializers.FollowSerializer( data=ac, context={"follow_actor": sender} ) if not serializer.is_valid(): 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( data=ac, context={"follow_target": sender, "follow_actor": system_actor} ) if not serializer.is_valid(raise_exception=True): return logger.info("Received invalid payload") return serializer.save() def handle_undo_follow(self, ac, sender): system_actor = self.get_actor_instance() serializer = serializers.UndoFollowSerializer( data=ac, context={"actor": sender, "target": system_actor} ) if not serializer.is_valid(): return logger.info("Received invalid payload") serializer.save() def handle_undo(self, ac, sender): if ac["object"]["type"] != "Follow": return if ac["object"]["actor"] != sender.url: # not the same actor, permission issue return self.handle_undo_follow(ac, sender) class LibraryActor(SystemActor): id = "library" name = "{host}'s library" summary = "Bot account to federate with {host}'s library" additional_attributes = {"manually_approves_followers": True} def serialize(self): data = super().serialize() urls = data.setdefault("url", []) urls.append( { "type": "Link", "mediaType": "application/activity+json", "name": "library", "href": utils.full_url(reverse("federation:music:files-list")), } ) return data @property def manually_approves_followers(self): return preferences.get("federation__music_needs_approval") @transaction.atomic def handle_create(self, ac, sender): try: remote_library = models.Library.objects.get( actor=sender, federation_enabled=True ) except models.Library.DoesNotExist: logger.info("Skipping import, we're not following %s", sender.url) return if ac["object"]["type"] != "Collection": return if ac["object"]["totalItems"] <= 0: return try: items = ac["object"]["items"] except KeyError: logger.warning("No items in collection!") return item_serializers = [ serializers.AudioSerializer(data=i, context={"library": remote_library}) for i in items ] now = timezone.now() valid_serializers = [] for s in item_serializers: if s.is_valid(): valid_serializers.append(s) else: logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors) lts = [] for s in valid_serializers: lts.append(s.save()) if remote_library.autoimport: batch = music_models.ImportBatch.objects.create(source="federation") for lt in lts: if lt.creation_date < now: # track was already in the library, we do not trigger # an import continue job = music_models.ImportJob.objects.create( batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url ) funkwhale_utils.on_commit( music_tasks.import_job_run.delay, import_job_id=job.pk, use_acoustid=False, ) class TestActor(SystemActor): id = "test" name = "{host}'s test account" summary = ( "Bot account to test federation with {host}. " "Send me /ping and I'll answer you." ) additional_attributes = {"manually_approves_followers": False} manually_approves_followers = False def get_outbox(self, data, actor=None): return { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}, ], "id": utils.full_url( reverse("federation:instance-actors-outbox", kwargs={"actor": self.id}) ), "type": "OrderedCollection", "totalItems": 0, "orderedItems": [], } def parse_command(self, message): """ Remove any links or fancy markup to extract /command from a note message. """ raw = remove_tags(message) try: return raw.split("/")[1] except IndexError: return def handle_create(self, ac, sender): if ac["object"]["type"] != "Note": return # we received a toot \o/ command = self.parse_command(ac["object"]["content"]) logger.debug("Parsed command: %s", command) if command != "ping": return now = timezone.now() test_actor = self.get_actor_instance() 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", {}, ], "type": "Create", "actor": test_actor.url, "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.url, "cc": [], "attachment": [], "tag": [ { "type": "Mention", "href": ac["actor"], "name": sender.mention_username, } ], }, } 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( actor=test_actor, target=sender, approved=None )[0] activity.deliver( serializers.FollowSerializer(follow_back).data, to=[follow_back.target.url], on_behalf_of=follow_back.actor, ) 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: 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.url], on_behalf_of=actor) SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}