415 lines
13 KiB
Python
415 lines
13 KiB
Python
import logging
|
|
import uuid
|
|
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 dynamic_preferences.registries import global_preferences_registry
|
|
|
|
from funkwhale_api.common import 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
|
|
from . import keys
|
|
from . import models
|
|
from . import serializers
|
|
from . import signing
|
|
from . import 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):
|
|
data = get_actor_data(actor_url)
|
|
serializer = serializers.ActorSerializer(data=data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
return serializer.build()
|
|
|
|
|
|
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):
|
|
system_actor = self.get_actor_instance()
|
|
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 settings.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_content = '{} Pong!'.format(
|
|
sender.mention_username
|
|
)
|
|
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(),
|
|
}
|