Skip to content
Snippets Groups Projects
Commit 08c26f3e authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '317-user-actors' into 'develop'

Resolve "Have an actor for our users"

Closes #317

See merge request funkwhale/funkwhale!338
parents c335e4d2 6b16a8b9
No related branches found
No related tags found
No related merge requests found
Showing
with 308 additions and 9 deletions
from . import create_actors
from . import create_image_variations from . import create_image_variations
from . import django_permissions_to_user_permissions from . import django_permissions_to_user_permissions
from . import test 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",
]
"""
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"])
...@@ -20,6 +20,11 @@ TYPE_CHOICES = [ ...@@ -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): class Actor(models.Model):
ap_type = "Actor" ap_type = "Actor"
...@@ -47,6 +52,8 @@ class Actor(models.Model): ...@@ -47,6 +52,8 @@ class Actor(models.Model):
related_name="following", related_name="following",
) )
objects = ActorQuerySet.as_manager()
class Meta: class Meta:
unique_together = ["domain", "preferred_username"] unique_together = ["domain", "preferred_username"]
......
import logging import logging
import mimetypes
import urllib.parse import urllib.parse
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
...@@ -63,6 +65,15 @@ class ActorSerializer(serializers.Serializer): ...@@ -63,6 +65,15 @@ class ActorSerializer(serializers.Serializer):
ret["endpoints"] = {} ret["endpoints"] = {}
if instance.shared_inbox_url: if instance.shared_inbox_url:
ret["endpoints"]["sharedInbox"] = 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 return ret
def prepare_missing_fields(self): def prepare_missing_fields(self):
......
...@@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False) ...@@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
router.register( router.register(
r"federation/instance/actors", views.InstanceActorViewSet, "instance-actors" 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") router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"files", views.MusicFilesViewSet, "files") music_router.register(r"files", views.MusicFilesViewSet, "files")
......
...@@ -5,6 +5,8 @@ def full_url(path): ...@@ -5,6 +5,8 @@ def full_url(path):
""" """
Given a relative path, return a full url usable for federation purpose 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 root = settings.FUNKWHALE_URL
if path.startswith("/") and root.endswith("/"): if path.startswith("/") and root.endswith("/"):
return root + path[1:] return root + path[1:]
......
...@@ -32,6 +32,23 @@ class FederationMixin(object): ...@@ -32,6 +32,23 @@ class FederationMixin(object):
return super().dispatch(request, *args, **kwargs) 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): class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
lookup_field = "actor" lookup_field = "actor"
lookup_value_regex = "[a-z]*" lookup_value_regex = "[a-z]*"
...@@ -100,6 +117,8 @@ class WellKnownViewSet(viewsets.GenericViewSet): ...@@ -100,6 +117,8 @@ class WellKnownViewSet(viewsets.GenericViewSet):
resource_type, resource = webfinger.clean_resource(request.GET["resource"]) resource_type, resource = webfinger.clean_resource(request.GET["resource"])
cleaner = getattr(webfinger, "clean_{}".format(resource_type)) cleaner = getattr(webfinger, "clean_{}".format(resource_type))
result = cleaner(resource) result = cleaner(resource)
handler = getattr(self, "handler_{}".format(resource_type))
data = handler(result)
except forms.ValidationError as e: except forms.ValidationError as e:
return response.Response({"errors": {"resource": e.message}}, status=400) return response.Response({"errors": {"resource": e.message}}, status=400)
except KeyError: except KeyError:
...@@ -107,14 +126,19 @@ class WellKnownViewSet(viewsets.GenericViewSet): ...@@ -107,14 +126,19 @@ class WellKnownViewSet(viewsets.GenericViewSet):
{"errors": {"resource": "This field is required"}}, status=400 {"errors": {"resource": "This field is required"}}, status=400
) )
handler = getattr(self, "handler_{}".format(resource_type))
data = handler(result)
return response.Response(data) return response.Response(data)
def handler_acct(self, clean_result): def handler_acct(self, clean_result):
username, hostname = 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 return serializers.ActorWebfingerSerializer(actor).data
......
...@@ -3,7 +3,7 @@ from django.conf import settings ...@@ -3,7 +3,7 @@ from django.conf import settings
from funkwhale_api.common import session from funkwhale_api.common import session
from . import actors, serializers from . import serializers
VALID_RESOURCE_TYPES = ["acct"] VALID_RESOURCE_TYPES = ["acct"]
...@@ -32,9 +32,6 @@ def clean_acct(acct_string, ensure_local=True): ...@@ -32,9 +32,6 @@ def clean_acct(acct_string, ensure_local=True):
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME: if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError("Invalid hostname {}".format(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 return username, hostname
......
...@@ -4,6 +4,8 @@ from django.utils import timezone ...@@ -4,6 +4,8 @@ from django.utils import timezone
from funkwhale_api.factories import ManyToManyFromList, registry from funkwhale_api.factories import ManyToManyFromList, registry
from . import models
@registry.register @registry.register
class GroupFactory(factory.django.DjangoModelFactory): class GroupFactory(factory.django.DjangoModelFactory):
...@@ -47,6 +49,7 @@ class UserFactory(factory.django.DjangoModelFactory): ...@@ -47,6 +49,7 @@ class UserFactory(factory.django.DjangoModelFactory):
password = factory.PostGenerationMethodCall("set_password", "test") password = factory.PostGenerationMethodCall("set_password", "test")
subsonic_api_token = None subsonic_api_token = None
groups = ManyToManyFromList("groups") groups = ManyToManyFromList("groups")
avatar = factory.django.ImageField()
class Meta: class Meta:
model = "users.User" model = "users.User"
...@@ -71,6 +74,14 @@ class UserFactory(factory.django.DjangoModelFactory): ...@@ -71,6 +74,14 @@ class UserFactory(factory.django.DjangoModelFactory):
# A list of permissions were passed in, use them # A list of permissions were passed in, use them
self.user_permissions.add(*perms) 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") @registry.register(name="users.SuperUser")
class SuperUserFactory(UserFactory): class SuperUserFactory(UserFactory):
......
# 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)]),
),
]
...@@ -23,6 +23,9 @@ from versatileimagefield.image_warmer import VersatileImageFieldWarmer ...@@ -23,6 +23,9 @@ from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api.common import fields, preferences from funkwhale_api.common import fields, preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import validators as common_validators 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(): def get_token():
...@@ -110,6 +113,13 @@ class User(AbstractUser): ...@@ -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): def __str__(self):
return self.username return self.username
...@@ -209,6 +219,34 @@ class Invitation(models.Model): ...@@ -209,6 +219,34 @@ class Invitation(models.Model):
return super().save(**kwargs) 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) @receiver(models.signals.post_save, sender=User)
def warm_user_avatar(sender, instance, **kwargs): def warm_user_avatar(sender, instance, **kwargs):
if not instance.avatar: if not instance.avatar:
......
...@@ -29,6 +29,9 @@ class RegisterSerializer(RS): ...@@ -29,6 +29,9 @@ class RegisterSerializer(RS):
if self.validated_data.get("invitation"): if self.validated_data.get("invitation"):
user.invitation = self.validated_data.get("invitation") user.invitation = self.validated_data.get("invitation")
user.save(update_fields=["invitation"]) user.save(update_fields=["invitation"])
user.actor = models.create_actor(user)
user.save(update_fields=["actor"])
return user return user
......
...@@ -681,3 +681,56 @@ def test_tapi_library_track_serializer_import_pending(factories): ...@@ -681,3 +681,56 @@ def test_tapi_library_track_serializer_import_pending(factories):
serializer = serializers.APILibraryTrackSerializer(lt) serializer = serializers.APILibraryTrackSerializer(lt)
assert serializer.get_status(lt) == "import_pending" 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
...@@ -417,3 +417,28 @@ def test_library_track_action_import(factories, superuser_api_client, mocker): ...@@ -417,3 +417,28 @@ def test_library_track_action_import(factories, superuser_api_client, mocker):
for i, job in enumerate(batch.jobs.all()): for i, job in enumerate(batch.jobs.all()):
assert job.library_track == imported_lts[i] assert job.library_track == imported_lts[i]
mocked_run.assert_called_once_with(import_batch_id=batch.pk) 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
import datetime import datetime
import pytest import pytest
from django.urls import reverse
from funkwhale_api.users import models from funkwhale_api.users import models
from funkwhale_api.federation import utils as federation_utils
def test__str__(factories): def test__str__(factories):
...@@ -127,3 +130,26 @@ def test_can_filter_closed_invitations(factories): ...@@ -127,3 +130,26 @@ def test_can_filter_closed_invitations(factories):
assert models.Invitation.objects.count() == 3 assert models.Invitation.objects.count() == 3
assert list(models.Invitation.objects.order_by("id").open(False)) == [expired, used] 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})
)
...@@ -249,3 +249,25 @@ def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar): ...@@ -249,3 +249,25 @@ def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar):
user.refresh_from_db() user.refresh_from_db()
assert user.avatar.read() == content 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
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.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment