funkwhale/api/funkwhale_api/federation/actors.py

380 wiersze
12 KiB
Python

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("<div>{}</div>".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()}