From 6b16a8b9639236bcf4c9951d3b11dfe437c04ca5 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Sun, 22 Jul 2018 10:20:16 +0000
Subject: [PATCH] Resolve "Have an actor for our users"

---
 api/funkwhale_api/common/scripts/__init__.py  |  8 ++-
 .../common/scripts/create_actors.py           | 23 ++++++++
 api/funkwhale_api/federation/models.py        |  7 +++
 api/funkwhale_api/federation/serializers.py   | 11 ++++
 api/funkwhale_api/federation/urls.py          |  1 +
 api/funkwhale_api/federation/utils.py         |  2 +
 api/funkwhale_api/federation/views.py         | 32 +++++++++--
 api/funkwhale_api/federation/webfinger.py     |  5 +-
 api/funkwhale_api/users/factories.py          | 11 ++++
 .../migrations/0011_auto_20180721_1317.py     | 28 ++++++++++
 api/funkwhale_api/users/models.py             | 38 +++++++++++++
 api/funkwhale_api/users/serializers.py        |  3 ++
 api/tests/federation/test_serializers.py      | 53 +++++++++++++++++++
 api/tests/federation/test_views.py            | 25 +++++++++
 api/tests/users/test_models.py                | 26 +++++++++
 api/tests/users/test_views.py                 | 22 ++++++++
 changes/changelog.d/317.feature               | 22 ++++++++
 17 files changed, 308 insertions(+), 9 deletions(-)
 create mode 100644 api/funkwhale_api/common/scripts/create_actors.py
 create mode 100644 api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
 create mode 100644 changes/changelog.d/317.feature

diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py
index 863256ba..769fd00e 100644
--- a/api/funkwhale_api/common/scripts/__init__.py
+++ b/api/funkwhale_api/common/scripts/__init__.py
@@ -1,6 +1,12 @@
+from . import create_actors
 from . import create_image_variations
 from . import django_permissions_to_user_permissions
 from . import test
 
 
-__all__ = ["create_image_variations", "django_permissions_to_user_permissions", "test"]
+__all__ = [
+    "create_actors",
+    "create_image_variations",
+    "django_permissions_to_user_permissions",
+    "test",
+]
diff --git a/api/funkwhale_api/common/scripts/create_actors.py b/api/funkwhale_api/common/scripts/create_actors.py
new file mode 100644
index 00000000..93100540
--- /dev/null
+++ b/api/funkwhale_api/common/scripts/create_actors.py
@@ -0,0 +1,23 @@
+"""
+Compute different sizes of image used for Album covers and User avatars
+"""
+from django.db.utils import IntegrityError
+
+from funkwhale_api.users.models import User, create_actor
+
+
+def main(command, **kwargs):
+    qs = User.objects.filter(actor__isnull=True).order_by("username")
+    total = len(qs)
+    command.stdout.write("{} users found without actors".format(total))
+    for i, user in enumerate(qs):
+        command.stdout.write(
+            "{}/{} creating actor for {}".format(i + 1, total, user.username)
+        )
+        try:
+            user.actor = create_actor(user)
+        except IntegrityError as e:
+            # somehow, an actor with the the url exists in the database
+            command.stderr.write("Error while creating actor: {}".format(str(e)))
+            continue
+        user.save(update_fields=["actor"])
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index 1d80395f..17ae0137 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -20,6 +20,11 @@ TYPE_CHOICES = [
 ]
 
 
+class ActorQuerySet(models.QuerySet):
+    def local(self, include=True):
+        return self.exclude(user__isnull=include)
+
+
 class Actor(models.Model):
     ap_type = "Actor"
 
@@ -47,6 +52,8 @@ class Actor(models.Model):
         related_name="following",
     )
 
+    objects = ActorQuerySet.as_manager()
+
     class Meta:
         unique_together = ["domain", "preferred_username"]
 
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 44de5d31..fc694c59 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -1,6 +1,8 @@
 import logging
+import mimetypes
 import urllib.parse
 
+from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import Paginator
 from django.db import transaction
 from rest_framework import serializers
@@ -63,6 +65,15 @@ class ActorSerializer(serializers.Serializer):
         ret["endpoints"] = {}
         if instance.shared_inbox_url:
             ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
+        try:
+            if instance.user.avatar:
+                ret["icon"] = {
+                    "type": "Image",
+                    "mediaType": mimetypes.guess_type(instance.user.avatar.path)[0],
+                    "url": utils.full_url(instance.user.avatar.crop["400x400"].url),
+                }
+        except ObjectDoesNotExist:
+            pass
         return ret
 
     def prepare_missing_fields(self):
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
index 2594f554..319e37be 100644
--- a/api/funkwhale_api/federation/urls.py
+++ b/api/funkwhale_api/federation/urls.py
@@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
 router.register(
     r"federation/instance/actors", views.InstanceActorViewSet, "instance-actors"
 )
+router.register(r"federation/actors", views.ActorViewSet, "actors")
 router.register(r".well-known", views.WellKnownViewSet, "well-known")
 
 music_router.register(r"files", views.MusicFilesViewSet, "files")
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index e0987022..71f22746 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -5,6 +5,8 @@ def full_url(path):
     """
     Given a relative path, return a full url usable for federation purpose
     """
+    if path.startswith("http://") or path.startswith("https://"):
+        return path
     root = settings.FUNKWHALE_URL
     if path.startswith("/") and root.endswith("/"):
         return root + path[1:]
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 63a1d7b7..2c01292b 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -32,6 +32,23 @@ class FederationMixin(object):
         return super().dispatch(request, *args, **kwargs)
 
 
+class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
+    lookup_field = "user__username"
+    authentication_classes = [authentication.SignatureAuthentication]
+    permission_classes = []
+    renderer_classes = [renderers.ActivityPubRenderer]
+    queryset = models.Actor.objects.local().select_related("user")
+    serializer_class = serializers.ActorSerializer
+
+    @detail_route(methods=["get", "post"])
+    def inbox(self, request, *args, **kwargs):
+        return response.Response({}, status=200)
+
+    @detail_route(methods=["get", "post"])
+    def outbox(self, request, *args, **kwargs):
+        return response.Response({}, status=200)
+
+
 class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
     lookup_field = "actor"
     lookup_value_regex = "[a-z]*"
@@ -100,6 +117,8 @@ class WellKnownViewSet(viewsets.GenericViewSet):
             resource_type, resource = webfinger.clean_resource(request.GET["resource"])
             cleaner = getattr(webfinger, "clean_{}".format(resource_type))
             result = cleaner(resource)
+            handler = getattr(self, "handler_{}".format(resource_type))
+            data = handler(result)
         except forms.ValidationError as e:
             return response.Response({"errors": {"resource": e.message}}, status=400)
         except KeyError:
@@ -107,14 +126,19 @@ class WellKnownViewSet(viewsets.GenericViewSet):
                 {"errors": {"resource": "This field is required"}}, status=400
             )
 
-        handler = getattr(self, "handler_{}".format(resource_type))
-        data = handler(result)
-
         return response.Response(data)
 
     def handler_acct(self, clean_result):
         username, hostname = clean_result
-        actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
+
+        if username in actors.SYSTEM_ACTORS:
+            actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
+        else:
+            try:
+                actor = models.Actor.objects.local().get(user__username=username)
+            except models.Actor.DoesNotExist:
+                raise forms.ValidationError("Invalid username")
+
         return serializers.ActorWebfingerSerializer(actor).data
 
 
diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py
index b899fe20..874b3c15 100644
--- a/api/funkwhale_api/federation/webfinger.py
+++ b/api/funkwhale_api/federation/webfinger.py
@@ -3,7 +3,7 @@ from django.conf import settings
 
 from funkwhale_api.common import session
 
-from . import actors, serializers
+from . import serializers
 
 VALID_RESOURCE_TYPES = ["acct"]
 
@@ -32,9 +32,6 @@ def clean_acct(acct_string, ensure_local=True):
     if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
         raise forms.ValidationError("Invalid hostname {}".format(hostname))
 
-    if ensure_local and username not in actors.SYSTEM_ACTORS:
-        raise forms.ValidationError("Invalid username")
-
     return username, hostname
 
 
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index 5fceb57b..9bef1da1 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -4,6 +4,8 @@ from django.utils import timezone
 
 from funkwhale_api.factories import ManyToManyFromList, registry
 
+from . import models
+
 
 @registry.register
 class GroupFactory(factory.django.DjangoModelFactory):
@@ -47,6 +49,7 @@ class UserFactory(factory.django.DjangoModelFactory):
     password = factory.PostGenerationMethodCall("set_password", "test")
     subsonic_api_token = None
     groups = ManyToManyFromList("groups")
+    avatar = factory.django.ImageField()
 
     class Meta:
         model = "users.User"
@@ -71,6 +74,14 @@ class UserFactory(factory.django.DjangoModelFactory):
             # A list of permissions were passed in, use them
             self.user_permissions.add(*perms)
 
+    @factory.post_generation
+    def with_actor(self, create, extracted, **kwargs):
+        if not create or not extracted:
+            return
+        self.actor = models.create_actor(self)
+        self.save(update_fields=["actor"])
+        return self.actor
+
 
 @registry.register(name="users.SuperUser")
 class SuperUserFactory(UserFactory):
diff --git a/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py b/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
new file mode 100644
index 00000000..5b5a1cab
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.0.7 on 2018-07-21 13:17
+
+from django.db import migrations, models
+import django.db.models.deletion
+import funkwhale_api.common.utils
+import funkwhale_api.common.validators
+import versatileimagefield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('federation', '0006_auto_20180521_1702'),
+        ('users', '0010_user_avatar'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='actor',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='federation.Actor'),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='avatar',
+            field=versatileimagefield.fields.VersatileImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars', preserve_file_name=False), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(min_height=50, min_width=50), funkwhale_api.common.validators.FileValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif'], max_size=2097152)]),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 6cef3900..26ffb5a9 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -23,6 +23,9 @@ from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 from funkwhale_api.common import fields, preferences
 from funkwhale_api.common import utils as common_utils
 from funkwhale_api.common import validators as common_validators
+from funkwhale_api.federation import keys
+from funkwhale_api.federation import models as federation_models
+from funkwhale_api.federation import utils as federation_utils
 
 
 def get_token():
@@ -110,6 +113,13 @@ class User(AbstractUser):
             ),
         ],
     )
+    actor = models.OneToOneField(
+        "federation.Actor",
+        related_name="user",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
 
     def __str__(self):
         return self.username
@@ -209,6 +219,34 @@ class Invitation(models.Model):
         return super().save(**kwargs)
 
 
+def create_actor(user):
+    username = user.username
+    private, public = keys.get_key_pair()
+    args = {
+        "preferred_username": username,
+        "domain": settings.FEDERATION_HOSTNAME,
+        "type": "Person",
+        "name": username,
+        "manually_approves_followers": False,
+        "url": federation_utils.full_url(
+            reverse("federation:actors-detail", kwargs={"user__username": username})
+        ),
+        "shared_inbox_url": federation_utils.full_url(
+            reverse("federation:actors-inbox", kwargs={"user__username": username})
+        ),
+        "inbox_url": federation_utils.full_url(
+            reverse("federation:actors-inbox", kwargs={"user__username": username})
+        ),
+        "outbox_url": federation_utils.full_url(
+            reverse("federation:actors-outbox", kwargs={"user__username": username})
+        ),
+    }
+    args["private_key"] = private.decode("utf-8")
+    args["public_key"] = public.decode("utf-8")
+
+    return federation_models.Actor.objects.create(**args)
+
+
 @receiver(models.signals.post_save, sender=User)
 def warm_user_avatar(sender, instance, **kwargs):
     if not instance.avatar:
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 74b06022..4421fa3f 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -29,6 +29,9 @@ class RegisterSerializer(RS):
         if self.validated_data.get("invitation"):
             user.invitation = self.validated_data.get("invitation")
             user.save(update_fields=["invitation"])
+        user.actor = models.create_actor(user)
+        user.save(update_fields=["actor"])
+
         return user
 
 
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index e966d171..6b14656b 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -681,3 +681,56 @@ def test_tapi_library_track_serializer_import_pending(factories):
     serializer = serializers.APILibraryTrackSerializer(lt)
 
     assert serializer.get_status(lt) == "import_pending"
+
+
+def test_local_actor_serializer_to_ap(factories):
+    expected = {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://w3id.org/security/v1",
+            {},
+        ],
+        "id": "https://test.federation/user",
+        "type": "Person",
+        "following": "https://test.federation/user/following",
+        "followers": "https://test.federation/user/followers",
+        "inbox": "https://test.federation/user/inbox",
+        "outbox": "https://test.federation/user/outbox",
+        "preferredUsername": "user",
+        "name": "Real User",
+        "summary": "Hello world",
+        "manuallyApprovesFollowers": False,
+        "publicKey": {
+            "id": "https://test.federation/user#main-key",
+            "owner": "https://test.federation/user",
+            "publicKeyPem": "yolo",
+        },
+        "endpoints": {"sharedInbox": "https://test.federation/inbox"},
+    }
+    ac = models.Actor.objects.create(
+        url=expected["id"],
+        inbox_url=expected["inbox"],
+        outbox_url=expected["outbox"],
+        shared_inbox_url=expected["endpoints"]["sharedInbox"],
+        followers_url=expected["followers"],
+        following_url=expected["following"],
+        public_key=expected["publicKey"]["publicKeyPem"],
+        preferred_username=expected["preferredUsername"],
+        name=expected["name"],
+        domain="test.federation",
+        summary=expected["summary"],
+        type="Person",
+        manually_approves_followers=False,
+    )
+    user = factories["users.User"]()
+    user.actor = ac
+    user.save()
+    ac.refresh_from_db()
+    expected["icon"] = {
+        "type": "Image",
+        "mediaType": "image/jpeg",
+        "url": utils.full_url(user.avatar.crop["400x400"].url),
+    }
+    serializer = serializers.ActorSerializer(ac)
+
+    assert serializer.data == expected
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 9e2d66a6..3a67a9f2 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -417,3 +417,28 @@ def test_library_track_action_import(factories, superuser_api_client, mocker):
     for i, job in enumerate(batch.jobs.all()):
         assert job.library_track == imported_lts[i]
     mocked_run.assert_called_once_with(import_batch_id=batch.pk)
+
+
+def test_local_actor_detail(factories, api_client):
+    user = factories["users.User"](with_actor=True)
+    url = reverse("federation:actors-detail", kwargs={"user__username": user.username})
+    serializer = serializers.ActorSerializer(user.actor)
+    response = api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == serializer.data
+
+
+def test_wellknown_webfinger_local(factories, api_client, settings, mocker):
+    user = factories["users.User"](with_actor=True)
+    url = reverse("federation:well-known-webfinger")
+    response = api_client.get(
+        url,
+        data={"resource": "acct:{}".format(user.actor.webfinger_subject)},
+        HTTP_ACCEPT="application/jrd+json",
+    )
+    serializer = serializers.ActorWebfingerSerializer(user.actor)
+
+    assert response.status_code == 200
+    assert response["Content-Type"] == "application/jrd+json"
+    assert response.data == serializer.data
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index f4a27b40..39a5bd32 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -1,7 +1,10 @@
 import datetime
 import pytest
 
+from django.urls import reverse
+
 from funkwhale_api.users import models
+from funkwhale_api.federation import utils as federation_utils
 
 
 def test__str__(factories):
@@ -127,3 +130,26 @@ def test_can_filter_closed_invitations(factories):
 
     assert models.Invitation.objects.count() == 3
     assert list(models.Invitation.objects.order_by("id").open(False)) == [expired, used]
+
+
+def test_creating_actor_from_user(factories, settings):
+    user = factories["users.User"]()
+    actor = models.create_actor(user)
+
+    assert actor.preferred_username == user.username
+    assert actor.domain == settings.FEDERATION_HOSTNAME
+    assert actor.type == "Person"
+    assert actor.name == user.username
+    assert actor.manually_approves_followers is False
+    assert actor.url == federation_utils.full_url(
+        reverse("federation:actors-detail", kwargs={"user__username": user.username})
+    )
+    assert actor.shared_inbox_url == federation_utils.full_url(
+        reverse("federation:actors-inbox", kwargs={"user__username": user.username})
+    )
+    assert actor.inbox_url == federation_utils.full_url(
+        reverse("federation:actors-inbox", kwargs={"user__username": user.username})
+    )
+    assert actor.outbox_url == federation_utils.full_url(
+        reverse("federation:actors-outbox", kwargs={"user__username": user.username})
+    )
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 9bea4ced..268148c2 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -249,3 +249,25 @@ def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar):
     user.refresh_from_db()
 
     assert user.avatar.read() == content
+
+
+def test_creating_user_creates_actor_as_well(
+    api_client, factories, mocker, preferences
+):
+    actor = factories["federation.Actor"]()
+    url = reverse("rest_register")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "testtest",
+        "password2": "testtest",
+    }
+    preferences["users__registration_enabled"] = True
+    mocker.patch("funkwhale_api.users.models.create_actor", return_value=actor)
+    response = api_client.post(url, data)
+
+    assert response.status_code == 201
+
+    user = User.objects.get(username="test1")
+
+    assert user.actor == actor
diff --git a/changes/changelog.d/317.feature b/changes/changelog.d/317.feature
new file mode 100644
index 00000000..bd293877
--- /dev/null
+++ b/changes/changelog.d/317.feature
@@ -0,0 +1,22 @@
+Expose ActivityPub actors for users (#317)
+
+Users now have an ActivityPub Actor [Manual action required]
+------------------------------------------------------------
+
+In the process of implementing federation for user activity such as listening
+history, we are now making user profiles (a.k.a. ActivityPub actors) available through federation.
+
+This does not means the federation is working, but this is a needed step to implement it.
+
+Those profiles will be created automatically for new users, but you have to run a command
+to create them for existing users.
+
+On docker setups::
+
+    docker-compose run --rm api python manage.py script create_actors --no-input
+
+On non-docker setups::
+
+    python manage.py script create_actors --no-input
+
+This should only take a few seconds to run. It is safe to interrupt the process or rerun it multiple times.
-- 
GitLab