actors.py 12.4 KB
Newer Older
1
import datetime
2
import logging
3
import xml
4
5

from django.conf import settings
6
from django.db import transaction
7
8
from django.urls import reverse
from django.utils import timezone
9

10
11
from rest_framework.exceptions import PermissionDenied

12

13
from funkwhale_api.common import preferences
14
from funkwhale_api.common import session
15
16
17
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
18

19
from . import activity
20
from . import keys
21
from . import models
22
from . import serializers
23
from . import signing
Eliot Berriot's avatar
Eliot Berriot committed
24
from . import utils
25

26
27
logger = logging.getLogger(__name__)

28

29
def remove_tags(text):
Eliot Berriot's avatar
Eliot Berriot committed
30
31
32
33
    logger.debug("Removing tags from %s", text)
    return "".join(
        xml.etree.ElementTree.fromstring("<div>{}</div>".format(text)).itertext()
    )
34
35


36
def get_actor_data(actor_url):
37
    response = session.get_session().get(
38
        actor_url,
Eliot Berriot's avatar
Eliot Berriot committed
39
        timeout=5,
40
        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
Eliot Berriot's avatar
Eliot Berriot committed
41
        headers={"Accept": "application/activity+json"},
42
    )
43
    response.raise_for_status()
44
45
46
    try:
        return response.json()
    except:
Eliot Berriot's avatar
Eliot Berriot committed
47
        raise ValueError("Invalid actor payload: {}".format(response.text))
48

Eliot Berriot's avatar
Eliot Berriot committed
49

50
def get_actor(actor_url):
51
52
53
54
55
    try:
        actor = models.Actor.objects.get(url=actor_url)
    except models.Actor.DoesNotExist:
        actor = None
    fetch_delta = datetime.timedelta(
Eliot Berriot's avatar
Eliot Berriot committed
56
57
        minutes=preferences.get("federation__actor_fetch_delay")
    )
58
59
60
    if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
        # cache is hot, we can return as is
        return actor
61
62
63
64
    data = get_actor_data(actor_url)
    serializer = serializers.ActorSerializer(data=data)
    serializer.is_valid(raise_exception=True)

65
    return serializer.save(last_fetch_date=timezone.now())
66

67
68
69

class SystemActor(object):
    additional_attributes = {}
70
    manually_approves_followers = False
71

72
73
    def get_request_auth(self):
        actor = self.get_actor_instance()
Eliot Berriot's avatar
Eliot Berriot committed
74
        return signing.get_auth(actor.private_key, actor.private_key_id)
75

76
77
    def serialize(self):
        actor = self.get_actor_instance()
78
        serializer = serializers.ActorSerializer(actor)
79
80
        return serializer.data

81
    def get_actor_instance(self):
82
83
84
85
86
        try:
            return models.Actor.objects.get(url=self.get_actor_url())
        except models.Actor.DoesNotExist:
            pass
        private, public = keys.get_key_pair()
87
        args = self.get_instance_argument(
Eliot Berriot's avatar
Eliot Berriot committed
88
            self.id, name=self.name, summary=self.summary, **self.additional_attributes
89
        )
Eliot Berriot's avatar
Eliot Berriot committed
90
91
        args["private_key"] = private.decode("utf-8")
        args["public_key"] = public.decode("utf-8")
92
93
94
95
        return models.Actor.objects.create(**args)

    def get_actor_url(self):
        return utils.full_url(
Eliot Berriot's avatar
Eliot Berriot committed
96
97
            reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
        )
98
99
100

    def get_instance_argument(self, id, name, summary, **kwargs):
        p = {
Eliot Berriot's avatar
Eliot Berriot committed
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
            "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),
117
118
119
120
121
122
123
124
        }
        p.update(kwargs)
        return p

    def get_inbox(self, data, actor=None):
        raise NotImplementedError

    def post_inbox(self, data, actor=None):
Eliot Berriot's avatar
Eliot Berriot committed
125
        return self.handle(data, actor=actor)
126
127
128
129
130
131
132

    def get_outbox(self, data, actor=None):
        raise NotImplementedError

    def post_outbox(self, data, actor=None):
        raise NotImplementedError

Eliot Berriot's avatar
Eliot Berriot committed
133
134
135
136
137
    def handle(self, data, actor=None):
        """
        Main entrypoint for handling activities posted to the
        actor's inbox
        """
Eliot Berriot's avatar
Eliot Berriot committed
138
        logger.info("Received activity on %s inbox", self.id)
Eliot Berriot's avatar
Eliot Berriot committed
139
140

        if actor is None:
Eliot Berriot's avatar
Eliot Berriot committed
141
            raise PermissionDenied("Actor not authenticated")
Eliot Berriot's avatar
Eliot Berriot committed
142

Eliot Berriot's avatar
Eliot Berriot committed
143
        serializer = serializers.ActivitySerializer(data=data, context={"actor": actor})
Eliot Berriot's avatar
Eliot Berriot committed
144
145
146
147
        serializer.is_valid(raise_exception=True)

        ac = serializer.data
        try:
Eliot Berriot's avatar
Eliot Berriot committed
148
            handler = getattr(self, "handle_{}".format(ac["type"].lower()))
Eliot Berriot's avatar
Eliot Berriot committed
149
        except (KeyError, AttributeError):
Eliot Berriot's avatar
Eliot Berriot committed
150
            logger.debug("No handler for activity %s", ac["type"])
Eliot Berriot's avatar
Eliot Berriot committed
151
152
            return

Eliot Berriot's avatar
Eliot Berriot committed
153
        return handler(data, actor)
Eliot Berriot's avatar
Eliot Berriot committed
154

155
    def handle_follow(self, ac, sender):
156
        serializer = serializers.FollowSerializer(
Eliot Berriot's avatar
Eliot Berriot committed
157
158
            data=ac, context={"follow_actor": sender}
        )
159
        if not serializer.is_valid():
Eliot Berriot's avatar
Eliot Berriot committed
160
            return logger.info("Invalid follow payload")
161
162
163
164
165
166
167
168
        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
169
170
            data=ac, context={"follow_target": sender, "follow_actor": system_actor}
        )
171
        if not serializer.is_valid(raise_exception=True):
Eliot Berriot's avatar
Eliot Berriot committed
172
            return logger.info("Received invalid payload")
173

174
        return serializer.save()
175

Eliot Berriot's avatar
Eliot Berriot committed
176
    def handle_undo_follow(self, ac, sender):
177
178
        system_actor = self.get_actor_instance()
        serializer = serializers.UndoFollowSerializer(
Eliot Berriot's avatar
Eliot Berriot committed
179
180
            data=ac, context={"actor": sender, "target": system_actor}
        )
181
        if not serializer.is_valid():
Eliot Berriot's avatar
Eliot Berriot committed
182
            return logger.info("Received invalid payload")
183
        serializer.save()
Eliot Berriot's avatar
Eliot Berriot committed
184
185

    def handle_undo(self, ac, sender):
Eliot Berriot's avatar
Eliot Berriot committed
186
        if ac["object"]["type"] != "Follow":
Eliot Berriot's avatar
Eliot Berriot committed
187
188
            return

Eliot Berriot's avatar
Eliot Berriot committed
189
        if ac["object"]["actor"] != sender.url:
Eliot Berriot's avatar
Eliot Berriot committed
190
191
192
193
194
            # not the same actor, permission issue
            return

        self.handle_undo_follow(ac, sender)

195
196

class LibraryActor(SystemActor):
Eliot Berriot's avatar
Eliot Berriot committed
197
198
199
200
    id = "library"
    name = "{host}'s library"
    summary = "Bot account to federate with {host}'s library"
    additional_attributes = {"manually_approves_followers": True}
201

202
203
    def serialize(self):
        data = super().serialize()
Eliot Berriot's avatar
Eliot Berriot committed
204
205
206
207
208
209
210
211
212
        urls = data.setdefault("url", [])
        urls.append(
            {
                "type": "Link",
                "mediaType": "application/activity+json",
                "name": "library",
                "href": utils.full_url(reverse("federation:music:files-list")),
            }
        )
213
214
        return data

215
216
    @property
    def manually_approves_followers(self):
Eliot Berriot's avatar
Eliot Berriot committed
217
        return preferences.get("federation__music_needs_approval")
218

219
    @transaction.atomic
220
    def handle_create(self, ac, sender):
221
222
        try:
            remote_library = models.Library.objects.get(
Eliot Berriot's avatar
Eliot Berriot committed
223
                actor=sender, federation_enabled=True
224
225
            )
        except models.Library.DoesNotExist:
Eliot Berriot's avatar
Eliot Berriot committed
226
            logger.info("Skipping import, we're not following %s", sender.url)
227
228
            return

Eliot Berriot's avatar
Eliot Berriot committed
229
        if ac["object"]["type"] != "Collection":
230
231
            return

Eliot Berriot's avatar
Eliot Berriot committed
232
        if ac["object"]["totalItems"] <= 0:
233
234
            return

235
        try:
Eliot Berriot's avatar
Eliot Berriot committed
236
            items = ac["object"]["items"]
237
        except KeyError:
Eliot Berriot's avatar
Eliot Berriot committed
238
            logger.warning("No items in collection!")
239
240
            return

241
        item_serializers = [
Eliot Berriot's avatar
Eliot Berriot committed
242
            serializers.AudioSerializer(data=i, context={"library": remote_library})
243
244
            for i in items
        ]
245
        now = timezone.now()
246
247
248
249
250
        valid_serializers = []
        for s in item_serializers:
            if s.is_valid():
                valid_serializers.append(s)
            else:
Eliot Berriot's avatar
Eliot Berriot committed
251
                logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors)
252

253
        lts = []
254
        for s in valid_serializers:
255
256
257
            lts.append(s.save())

        if remote_library.autoimport:
Eliot Berriot's avatar
Eliot Berriot committed
258
            batch = music_models.ImportBatch.objects.create(source="federation")
259
260
261
262
263
264
            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(
Eliot Berriot's avatar
Eliot Berriot committed
265
                    batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
266
267
268
269
270
271
                )
                funkwhale_utils.on_commit(
                    music_tasks.import_job_run.delay,
                    import_job_id=job.pk,
                    use_acoustid=False,
                )
272

273

274
class TestActor(SystemActor):
Eliot Berriot's avatar
Eliot Berriot committed
275
276
    id = "test"
    name = "{host}'s test account"
277
    summary = (
Eliot Berriot's avatar
Eliot Berriot committed
278
279
        "Bot account to test federation with {host}. "
        "Send me /ping and I'll answer you."
280
    )
Eliot Berriot's avatar
Eliot Berriot committed
281
    additional_attributes = {"manually_approves_followers": False}
282
    manually_approves_followers = False
283
284
285

    def get_outbox(self, data, actor=None):
        return {
286
287
288
            "@context": [
                "https://www.w3.org/ns/activitystreams",
                "https://w3id.org/security/v1",
Eliot Berriot's avatar
Eliot Berriot committed
289
                {},
290
291
            ],
            "id": utils.full_url(
Eliot Berriot's avatar
Eliot Berriot committed
292
293
                reverse("federation:instance-actors-outbox", kwargs={"actor": self.id})
            ),
294
295
            "type": "OrderedCollection",
            "totalItems": 0,
Eliot Berriot's avatar
Eliot Berriot committed
296
            "orderedItems": [],
297
298
299
300
301
302
303
304
305
        }

    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
306
            return raw.split("/")[1]
307
308
309
        except IndexError:
            return

Eliot Berriot's avatar
Eliot Berriot committed
310
    def handle_create(self, ac, sender):
Eliot Berriot's avatar
Eliot Berriot committed
311
        if ac["object"]["type"] != "Note":
Eliot Berriot's avatar
Eliot Berriot committed
312
313
314
            return

        # we received a toot \o/
Eliot Berriot's avatar
Eliot Berriot committed
315
316
317
        command = self.parse_command(ac["object"]["content"])
        logger.debug("Parsed command: %s", command)
        if command != "ping":
Eliot Berriot's avatar
Eliot Berriot committed
318
319
            return

320
321
        now = timezone.now()
        test_actor = self.get_actor_instance()
Eliot Berriot's avatar
Eliot Berriot committed
322
        reply_url = "https://{}/activities/note/{}".format(
323
324
325
326
            settings.FEDERATION_HOSTNAME, now.timestamp()
        )
        reply_activity = {
            "@context": [
327
328
                "https://www.w3.org/ns/activitystreams",
                "https://w3id.org/security/v1",
Eliot Berriot's avatar
Eliot Berriot committed
329
                {},
330
            ],
Eliot Berriot's avatar
Eliot Berriot committed
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
            "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,
                    }
                ],
            },
358
        }
Eliot Berriot's avatar
Eliot Berriot committed
359
        activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor)
360

Eliot Berriot's avatar
Eliot Berriot committed
361
    def handle_follow(self, ac, sender):
362
363
        super().handle_follow(ac, sender)
        # also, we follow back
Eliot Berriot's avatar
Eliot Berriot committed
364
        test_actor = self.get_actor_instance()
365
        follow_back = models.Follow.objects.get_or_create(
Eliot Berriot's avatar
Eliot Berriot committed
366
            actor=test_actor, target=sender, approved=None
367
        )[0]
Eliot Berriot's avatar
Eliot Berriot committed
368
        activity.deliver(
369
370
            serializers.FollowSerializer(follow_back).data,
            to=[follow_back.target.url],
Eliot Berriot's avatar
Eliot Berriot committed
371
372
            on_behalf_of=follow_back.actor,
        )
Eliot Berriot's avatar
Eliot Berriot committed
373

Eliot Berriot's avatar
Eliot Berriot committed
374
375
376
    def handle_undo_follow(self, ac, sender):
        super().handle_undo_follow(ac, sender)
        actor = self.get_actor_instance()
Eliot Berriot's avatar
Eliot Berriot committed
377
378
        # we also unfollow the sender, if possible
        try:
Eliot Berriot's avatar
Eliot Berriot committed
379
            follow = models.Follow.objects.get(target=sender, actor=actor)
Eliot Berriot's avatar
Eliot Berriot committed
380
381
        except models.Follow.DoesNotExist:
            return
382
        undo = serializers.UndoFollowSerializer(follow).data
Eliot Berriot's avatar
Eliot Berriot committed
383
        follow.delete()
Eliot Berriot's avatar
Eliot Berriot committed
384
        activity.deliver(undo, to=[sender.url], on_behalf_of=actor)
Eliot Berriot's avatar
Eliot Berriot committed
385

Eliot Berriot's avatar
Eliot Berriot committed
386

Eliot Berriot's avatar
Eliot Berriot committed
387
SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}