funkwhale/api/funkwhale_api/federation/actors.py

380 wiersze
12 KiB
Python
Czysty Zwykły widok Historia

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
2018-06-10 08:55:16 +00:00
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
2018-06-10 08:55:16 +00:00
from . import activity, keys, models, serializers, signing, utils
logger = logging.getLogger(__name__)
def remove_tags(text):
2018-06-09 13:36:16 +00:00
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,
2018-04-08 11:33:36 +00:00
timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
2018-06-09 13:36:16 +00:00
headers={"Accept": "application/activity+json"},
)
response.raise_for_status()
try:
return response.json()
except:
2018-06-09 13:36:16 +00:00
raise ValueError("Invalid actor payload: {}".format(response.text))
2018-04-08 11:33:36 +00:00
def get_actor(actor_url):
try:
actor = models.Actor.objects.get(url=actor_url)
except models.Actor.DoesNotExist:
actor = None
fetch_delta = datetime.timedelta(
2018-06-09 13:36:16 +00:00
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()
2018-06-09 13:36:16 +00:00
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(
2018-06-09 13:36:16 +00:00
self.id, name=self.name, summary=self.summary, **self.additional_attributes
)
2018-06-09 13:36:16 +00:00
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(
2018-06-09 13:36:16 +00:00
reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
)
def get_instance_argument(self, id, name, summary, **kwargs):
p = {
2018-06-09 13:36:16 +00:00
"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):
2018-04-03 17:48:50 +00:00
return self.handle(data, actor=actor)
def get_outbox(self, data, actor=None):
raise NotImplementedError
def post_outbox(self, data, actor=None):
raise NotImplementedError
2018-04-03 17:48:50 +00:00
def handle(self, data, actor=None):
"""
Main entrypoint for handling activities posted to the
actor's inbox
"""
2018-06-09 13:36:16 +00:00
logger.info("Received activity on %s inbox", self.id)
2018-04-03 17:48:50 +00:00
if actor is None:
2018-06-09 13:36:16 +00:00
raise PermissionDenied("Actor not authenticated")
2018-04-03 17:48:50 +00:00
2018-06-09 13:36:16 +00:00
serializer = serializers.ActivitySerializer(data=data, context={"actor": actor})
2018-04-03 17:48:50 +00:00
serializer.is_valid(raise_exception=True)
ac = serializer.data
try:
2018-06-09 13:36:16 +00:00
handler = getattr(self, "handle_{}".format(ac["type"].lower()))
2018-04-03 17:48:50 +00:00
except (KeyError, AttributeError):
2018-06-09 13:36:16 +00:00
logger.debug("No handler for activity %s", ac["type"])
2018-04-03 17:48:50 +00:00
return
2018-04-03 21:25:44 +00:00
return handler(data, actor)
2018-04-03 17:48:50 +00:00
def handle_follow(self, ac, sender):
serializer = serializers.FollowSerializer(
2018-06-09 13:36:16 +00:00
data=ac, context={"follow_actor": sender}
)
if not serializer.is_valid():
2018-06-09 13:36:16 +00:00
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(
2018-06-09 13:36:16 +00:00
data=ac, context={"follow_target": sender, "follow_actor": system_actor}
)
if not serializer.is_valid(raise_exception=True):
2018-06-09 13:36:16 +00:00
return logger.info("Received invalid payload")
return serializer.save()
2018-04-04 20:40:57 +00:00
def handle_undo_follow(self, ac, sender):
system_actor = self.get_actor_instance()
serializer = serializers.UndoFollowSerializer(
2018-06-09 13:36:16 +00:00
data=ac, context={"actor": sender, "target": system_actor}
)
if not serializer.is_valid():
2018-06-09 13:36:16 +00:00
return logger.info("Received invalid payload")
serializer.save()
2018-04-04 20:40:57 +00:00
def handle_undo(self, ac, sender):
2018-06-09 13:36:16 +00:00
if ac["object"]["type"] != "Follow":
2018-04-04 20:40:57 +00:00
return
2018-06-09 13:36:16 +00:00
if ac["object"]["actor"] != sender.url:
2018-04-04 20:40:57 +00:00
# not the same actor, permission issue
return
self.handle_undo_follow(ac, sender)
class LibraryActor(SystemActor):
2018-06-09 13:36:16 +00:00
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()
2018-06-09 13:36:16 +00:00
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):
2018-06-09 13:36:16 +00:00
return preferences.get("federation__music_needs_approval")
@transaction.atomic
def handle_create(self, ac, sender):
try:
remote_library = models.Library.objects.get(
2018-06-09 13:36:16 +00:00
actor=sender, federation_enabled=True
)
except models.Library.DoesNotExist:
2018-06-09 13:36:16 +00:00
logger.info("Skipping import, we're not following %s", sender.url)
return
2018-06-09 13:36:16 +00:00
if ac["object"]["type"] != "Collection":
return
2018-06-09 13:36:16 +00:00
if ac["object"]["totalItems"] <= 0:
return
try:
2018-06-09 13:36:16 +00:00
items = ac["object"]["items"]
except KeyError:
2018-06-09 13:36:16 +00:00
logger.warning("No items in collection!")
return
item_serializers = [
2018-06-09 13:36:16 +00:00
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:
2018-06-09 13:36:16 +00:00
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:
2018-06-09 13:36:16 +00:00
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(
2018-06-09 13:36:16 +00:00
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):
2018-06-09 13:36:16 +00:00
id = "test"
name = "{host}'s test account"
summary = (
2018-06-09 13:36:16 +00:00
"Bot account to test federation with {host}. "
"Send me /ping and I'll answer you."
)
2018-06-09 13:36:16 +00:00
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",
2018-06-09 13:36:16 +00:00
{},
],
"id": utils.full_url(
2018-06-09 13:36:16 +00:00
reverse("federation:instance-actors-outbox", kwargs={"actor": self.id})
),
"type": "OrderedCollection",
"totalItems": 0,
2018-06-09 13:36:16 +00:00
"orderedItems": [],
}
def parse_command(self, message):
"""
Remove any links or fancy markup to extract /command from
a note message.
"""
raw = remove_tags(message)
try:
2018-06-09 13:36:16 +00:00
return raw.split("/")[1]
except IndexError:
return
2018-04-03 17:48:50 +00:00
def handle_create(self, ac, sender):
2018-06-09 13:36:16 +00:00
if ac["object"]["type"] != "Note":
2018-04-03 17:48:50 +00:00
return
# we received a toot \o/
2018-06-09 13:36:16 +00:00
command = self.parse_command(ac["object"]["content"])
logger.debug("Parsed command: %s", command)
if command != "ping":
2018-04-03 17:48:50 +00:00
return
now = timezone.now()
test_actor = self.get_actor_instance()
2018-06-09 13:36:16 +00:00
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",
2018-06-09 13:36:16 +00:00
{},
],
2018-06-09 13:36:16 +00:00
"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,
}
],
},
}
2018-06-09 13:36:16 +00:00
activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor)
2018-04-03 17:48:50 +00:00
def handle_follow(self, ac, sender):
super().handle_follow(ac, sender)
# also, we follow back
2018-04-03 17:48:50 +00:00
test_actor = self.get_actor_instance()
follow_back = models.Follow.objects.get_or_create(
2018-06-09 13:36:16 +00:00
actor=test_actor, target=sender, approved=None
)[0]
2018-04-03 17:48:50 +00:00
activity.deliver(
serializers.FollowSerializer(follow_back).data,
to=[follow_back.target.url],
2018-06-09 13:36:16 +00:00
on_behalf_of=follow_back.actor,
)
2018-04-03 17:48:50 +00:00
2018-04-04 20:40:57 +00:00
def handle_undo_follow(self, ac, sender):
super().handle_undo_follow(ac, sender)
actor = self.get_actor_instance()
2018-04-03 21:25:44 +00:00
# we also unfollow the sender, if possible
try:
2018-06-09 13:36:16 +00:00
follow = models.Follow.objects.get(target=sender, actor=actor)
2018-04-03 21:25:44 +00:00
except models.Follow.DoesNotExist:
return
undo = serializers.UndoFollowSerializer(follow).data
2018-04-03 21:25:44 +00:00
follow.delete()
2018-06-09 13:36:16 +00:00
activity.deliver(undo, to=[sender.url], on_behalf_of=actor)
2018-04-04 20:40:57 +00:00
2018-04-03 21:25:44 +00:00
2018-06-09 13:36:16 +00:00
SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}