diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 51230bfdc..97a088338 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -69,6 +69,8 @@ else: FUNKWHALE_HOSTNAME = _parsed.netloc FUNKWHALE_PROTOCOL = _parsed.scheme +FUNKWHALE_PROTOCOL = FUNKWHALE_PROTOCOL.lower() +FUNKWHALE_HOSTNAME = FUNKWHALE_HOSTNAME.lower() FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) FUNKWHALE_SPA_HTML_ROOT = env( "FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/" @@ -83,7 +85,7 @@ APP_NAME = "Funkwhale" # XXX: deprecated, see #186 FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True) -FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME) +FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME).lower() # XXX: deprecated, see #186 FEDERATION_COLLECTION_PAGE_SIZE = env.int("FEDERATION_COLLECTION_PAGE_SIZE", default=50) # XXX: deprecated, see #186 diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py index b8106bef4..d9422f6fa 100644 --- a/api/funkwhale_api/common/channels.py +++ b/api/funkwhale_api/common/channels.py @@ -5,7 +5,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.core.serializers.json import DjangoJSONEncoder -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) channel_layer = get_channel_layer() group_add = async_to_sync(channel_layer.group_add) diff --git a/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py index 48144f8ea..0a07fa7fe 100644 --- a/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py +++ b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py @@ -10,7 +10,6 @@ from funkwhale_api.users import models mapping = { "dynamic_preferences.change_globalpreferencemodel": "settings", "music.add_importbatch": "library", - "federation.change_library": "federation", } diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 498c76a99..211b8230a 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -42,23 +42,39 @@ ACTIVITY_TYPES = [ "View", ] - -OBJECT_TYPES = [ - "Article", - "Audio", - "Collection", - "Document", - "Event", - "Image", - "Note", - "OrderedCollection", - "Page", - "Place", - "Profile", - "Relationship", - "Tombstone", - "Video", -] + ACTIVITY_TYPES +FUNKWHALE_OBJECT_TYPES = [ + ("Domain", "Domain"), + ("Artist", "Artist"), + ("Album", "Album"), + ("Track", "Track"), + ("Library", "Library"), +] +OBJECT_TYPES = ( + [ + "Application", + "Article", + "Audio", + "Collection", + "Document", + "Event", + "Group", + "Image", + "Note", + "Object", + "OrderedCollection", + "Organization", + "Page", + "Person", + "Place", + "Profile", + "Relationship", + "Service", + "Tombstone", + "Video", + ] + + ACTIVITY_TYPES + + FUNKWHALE_OBJECT_TYPES +) BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] @@ -386,15 +402,3 @@ def get_actors_from_audience(urls): if not final_query: return models.Actor.objects.none() return models.Actor.objects.filter(final_query) - - -def get_inbox_urls(actor_queryset): - """ - Given an actor queryset, returns a deduplicated set containing - all inbox or shared inbox urls where we should deliver our payloads for - those actors - """ - values = actor_queryset.values("inbox_url", "shared_inbox_url") - - urls = set([actor["shared_inbox_url"] or actor["inbox_url"] for actor in values]) - return sorted(urls) diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py index 98bc65247..acb2e5b67 100644 --- a/api/funkwhale_api/federation/admin.py +++ b/api/funkwhale_api/federation/admin.py @@ -24,6 +24,12 @@ def redeliver_activities(modeladmin, request, queryset): redeliver_activities.short_description = "Redeliver" +@admin.register(models.Domain) +class DomainAdmin(admin.ModelAdmin): + list_display = ["name", "creation_date"] + search_fields = ["name"] + + @admin.register(models.Activity) class ActivityAdmin(admin.ModelAdmin): list_display = ["type", "fid", "url", "actor", "creation_date"] diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index a52cf88be..cbe0bee85 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -66,24 +66,39 @@ def create_user(actor): return user_factories.UserFactory(actor=actor) +@registry.register +class Domain(factory.django.DjangoModelFactory): + name = factory.Faker("domain_name") + + class Meta: + model = "federation.Domain" + django_get_or_create = ("name",) + + @registry.register class ActorFactory(factory.DjangoModelFactory): public_key = None private_key = None preferred_username = factory.Faker("user_name") summary = factory.Faker("paragraph") - domain = factory.Faker("domain_name") + domain = factory.SubFactory(Domain) fid = factory.LazyAttribute( - lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username) + lambda o: "https://{}/users/{}".format(o.domain.name, o.preferred_username) ) followers_url = factory.LazyAttribute( - lambda o: "https://{}/users/{}followers".format(o.domain, o.preferred_username) + lambda o: "https://{}/users/{}followers".format( + o.domain.name, o.preferred_username + ) ) inbox_url = factory.LazyAttribute( - lambda o: "https://{}/users/{}/inbox".format(o.domain, o.preferred_username) + lambda o: "https://{}/users/{}/inbox".format( + o.domain.name, o.preferred_username + ) ) outbox_url = factory.LazyAttribute( - lambda o: "https://{}/users/{}/outbox".format(o.domain, o.preferred_username) + lambda o: "https://{}/users/{}/outbox".format( + o.domain.name, o.preferred_username + ) ) class Meta: @@ -95,7 +110,9 @@ class ActorFactory(factory.DjangoModelFactory): return from funkwhale_api.users.factories import UserFactory - self.domain = settings.FEDERATION_HOSTNAME + self.domain = models.Domain.objects.get_or_create( + name=settings.FEDERATION_HOSTNAME + )[0] self.save(update_fields=["domain"]) if not create: if extracted and hasattr(extracted, "pk"): diff --git a/api/funkwhale_api/federation/migrations/0013_auto_20181226_1935.py b/api/funkwhale_api/federation/migrations/0013_auto_20181226_1935.py new file mode 100644 index 000000000..98a481271 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0013_auto_20181226_1935.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.9 on 2018-12-26 19:35 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [("federation", "0012_auto_20180920_1803")] + + operations = [ + migrations.AlterField( + model_name="actor", + name="private_key", + field=models.TextField(blank=True, max_length=5000, null=True), + ), + migrations.AlterField( + model_name="actor", + name="public_key", + field=models.TextField(blank=True, max_length=5000, null=True), + ), + ] diff --git a/api/funkwhale_api/federation/migrations/0014_auto_20181205_0958.py b/api/funkwhale_api/federation/migrations/0014_auto_20181205_0958.py new file mode 100644 index 000000000..7be361f87 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0014_auto_20181205_0958.py @@ -0,0 +1,46 @@ +# Generated by Django 2.0.9 on 2018-12-05 09:58 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [("federation", "0013_auto_20181226_1935")] + + operations = [ + migrations.CreateModel( + name="Domain", + fields=[ + ( + "name", + models.CharField(max_length=255, primary_key=True, serialize=False), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ], + ), + migrations.AlterField( + model_name="actor", + name="domain", + field=models.CharField(max_length=1000, null=True), + ), + migrations.RenameField("actor", "domain", "old_domain"), + migrations.AddField( + model_name="actor", + name="domain", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="actors", + to="federation.Domain", + ), + ), + migrations.AlterUniqueTogether(name="actor", unique_together=set()), + migrations.AlterUniqueTogether( + name="actor", unique_together={("domain", "preferred_username")} + ), + ] diff --git a/api/funkwhale_api/federation/migrations/0015_populate_domains.py b/api/funkwhale_api/federation/migrations/0015_populate_domains.py new file mode 100644 index 000000000..0f0036c94 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0015_populate_domains.py @@ -0,0 +1,56 @@ +# Generated by Django 2.0.9 on 2018-11-14 08:55 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +def populate_domains(apps, schema_editor): + Domain = apps.get_model("federation", "Domain") + Actor = apps.get_model("federation", "Actor") + + domains = set( + [v.lower() for v in Actor.objects.values_list("old_domain", flat=True)] + ) + for domain in sorted(domains): + print("Populating domain {}...".format(domain)) + first_actor = ( + Actor.objects.order_by("creation_date") + .exclude(creation_date=None) + .filter(old_domain__iexact=domain) + .first() + ) + + if first_actor: + first_seen = first_actor.creation_date + else: + first_seen = django.utils.timezone.now() + + Domain.objects.update_or_create( + name=domain, defaults={"creation_date": first_seen} + ) + + for domain in Domain.objects.all(): + Actor.objects.filter(old_domain__iexact=domain.name).update(domain=domain) + + +def skip(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [("federation", "0014_auto_20181205_0958")] + + operations = [ + migrations.RunPython(populate_domains, skip), + migrations.AlterField( + model_name="actor", + name="domain", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="actors", + to="federation.Domain", + ), + ), + ] diff --git a/api/funkwhale_api/federation/migrations/0016_auto_20181227_1605.py b/api/funkwhale_api/federation/migrations/0016_auto_20181227_1605.py new file mode 100644 index 000000000..8b705e72f --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0016_auto_20181227_1605.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.9 on 2018-12-27 16:05 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import funkwhale_api.federation.models + + +class Migration(migrations.Migration): + + dependencies = [("federation", "0015_populate_domains")] + + operations = [ + migrations.AddField( + model_name="domain", + name="nodeinfo", + field=django.contrib.postgres.fields.jsonb.JSONField( + default=funkwhale_api.federation.models.empty_dict, max_length=50000 + ), + ), + migrations.AddField( + model_name="domain", + name="nodeinfo_fetch_date", + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 058bb9c46..4b7730402 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -62,6 +62,81 @@ class ActorQuerySet(models.QuerySet): return qs +class DomainQuerySet(models.QuerySet): + def external(self): + return self.exclude(pk=settings.FEDERATION_HOSTNAME) + + def with_last_activity_date(self): + activities = Activity.objects.filter( + actor__domain=models.OuterRef("pk") + ).order_by("-creation_date") + + return self.annotate( + last_activity_date=models.Subquery(activities.values("creation_date")[:1]) + ) + + def with_actors_count(self): + return self.annotate(actors_count=models.Count("actors", distinct=True)) + + def with_outbox_activities_count(self): + return self.annotate( + outbox_activities_count=models.Count("actors__outbox_activities") + ) + + +class Domain(models.Model): + name = models.CharField(primary_key=True, max_length=255) + creation_date = models.DateTimeField(default=timezone.now) + nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True) + nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True) + + objects = DomainQuerySet.as_manager() + + def __str__(self): + return self.name + + def save(self, **kwargs): + lowercase_fields = ["name"] + for field in lowercase_fields: + v = getattr(self, field, None) + if v: + setattr(self, field, v.lower()) + + super().save(**kwargs) + + def get_stats(self): + from funkwhale_api.music import models as music_models + + data = Domain.objects.filter(pk=self.pk).aggregate( + actors=models.Count("actors", distinct=True), + outbox_activities=models.Count("actors__outbox_activities", distinct=True), + libraries=models.Count("actors__libraries", distinct=True), + uploads=models.Count("actors__libraries__uploads", distinct=True), + received_library_follows=models.Count( + "actors__libraries__received_follows", distinct=True + ), + emitted_library_follows=models.Count( + "actors__library_follows", distinct=True + ), + ) + data["artists"] = music_models.Artist.objects.filter( + from_activity__actor__domain_id=self.pk + ).count() + data["albums"] = music_models.Album.objects.filter( + from_activity__actor__domain_id=self.pk + ).count() + data["tracks"] = music_models.Track.objects.filter( + from_activity__actor__domain_id=self.pk + ).count() + + uploads = music_models.Upload.objects.filter(library__actor__domain_id=self.pk) + data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0 + data["media_downloaded_size"] = ( + uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0 + ) + return data + + class Actor(models.Model): ap_type = "Actor" @@ -74,7 +149,7 @@ class Actor(models.Model): shared_inbox_url = models.URLField(max_length=500, null=True, blank=True) type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25) name = models.CharField(max_length=200, null=True, blank=True) - domain = models.CharField(max_length=1000) + domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors") summary = models.CharField(max_length=500, null=True, blank=True) preferred_username = models.CharField(max_length=200, null=True, blank=True) public_key = models.TextField(max_length=5000, null=True, blank=True) @@ -110,36 +185,9 @@ class Actor(models.Model): def __str__(self): return "{}@{}".format(self.preferred_username, self.domain) - def save(self, **kwargs): - lowercase_fields = ["domain"] - for field in lowercase_fields: - v = getattr(self, field, None) - if v: - setattr(self, field, v.lower()) - - super().save(**kwargs) - @property def is_local(self): - return self.domain == settings.FEDERATION_HOSTNAME - - @property - def is_system(self): - from . import actors - - return all( - [ - settings.FEDERATION_HOSTNAME == self.domain, - self.preferred_username in actors.SYSTEM_ACTORS, - ] - ) - - @property - def system_conf(self): - from . import actors - - if self.is_system: - return actors.SYSTEM_ACTORS[self.preferred_username] + return self.domain_id == settings.FEDERATION_HOSTNAME def get_approved_followers(self): follows = self.received_follows.filter(approved=True) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 6c4ffeb58..1cece3b97 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -114,7 +114,7 @@ class ActorSerializer(serializers.Serializer): if maf is not None: kwargs["manually_approves_followers"] = maf domain = urllib.parse.urlparse(kwargs["fid"]).netloc - kwargs["domain"] = domain + kwargs["domain"] = models.Domain.objects.get_or_create(pk=domain)[0] for endpoint, url in self.initial_data.get("endpoints", {}).items(): if endpoint == "sharedInbox": kwargs["shared_inbox_url"] = url @@ -888,3 +888,12 @@ class CollectionSerializer(serializers.Serializer): if self.context.get("include_ap_context", True): d["@context"] = AP_CONTEXT return d + + +class NodeInfoLinkSerializer(serializers.Serializer): + href = serializers.URLField() + rel = serializers.URLField() + + +class NodeInfoSerializer(serializers.Serializer): + links = serializers.ListField(child=NodeInfoLinkSerializer(), min_length=1) diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 33f94cad3..4ed07aa25 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -1,6 +1,7 @@ import datetime import logging import os +import requests from django.conf import settings from django.db.models import Q, F @@ -14,6 +15,7 @@ from funkwhale_api.music import models as music_models from funkwhale_api.taskapp import celery from . import models, signing +from . import serializers from . import routes logger = logging.getLogger(__name__) @@ -147,3 +149,40 @@ def deliver_to_remote(delivery): delivery.attempts = F("attempts") + 1 delivery.is_delivered = True delivery.save(update_fields=["last_attempt_date", "attempts", "is_delivered"]) + + +def fetch_nodeinfo(domain_name): + s = session.get_session() + wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name) + response = s.get( + url=wellknown_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL + ) + response.raise_for_status() + serializer = serializers.NodeInfoSerializer(data=response.json()) + serializer.is_valid(raise_exception=True) + nodeinfo_url = None + for link in serializer.validated_data["links"]: + if link["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0": + nodeinfo_url = link["href"] + break + + response = s.get( + url=nodeinfo_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL + ) + response.raise_for_status() + return response.json() + + +@celery.app.task(name="federation.update_domain_nodeinfo") +@celery.require_instance( + models.Domain.objects.external(), "domain", id_kwarg_name="domain_name" +) +def update_domain_nodeinfo(domain): + now = timezone.now() + try: + nodeinfo = {"status": "ok", "payload": fetch_nodeinfo(domain.name)} + except (requests.RequestException, serializers.serializers.ValidationError) as e: + nodeinfo = {"status": "error", "error": str(e)} + domain.nodeinfo_fetch_date = now + domain.nodeinfo = nodeinfo + domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date"]) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 7f6e328db..d9b9bfc1d 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,6 +1,7 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.federation import models as federation_models from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -20,6 +21,14 @@ class ManageUploadFilterSet(filters.FilterSet): fields = ["q", "track__album", "track__artist", "track"] +class ManageDomainFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["name"]) + + class Meta: + model = federation_models.Domain + fields = ["name"] + + class ManageUserFilterSet(filters.FilterSet): q = fields.SearchFilter(search_fields=["username", "email", "name"]) @@ -31,10 +40,9 @@ class ManageUserFilterSet(filters.FilterSet): "privacy_level", "is_staff", "is_superuser", - "permission_upload", "permission_library", "permission_settings", - "permission_federation", + "permission_moderation", ] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 9b5e24f66..a401381e6 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -3,6 +3,7 @@ from django.db import transaction from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.federation import models as federation_models from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -168,3 +169,30 @@ class ManageInvitationActionSerializer(common_serializers.ActionSerializer): @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class ManageDomainSerializer(serializers.ModelSerializer): + actors_count = serializers.SerializerMethodField() + last_activity_date = serializers.SerializerMethodField() + outbox_activities_count = serializers.SerializerMethodField() + + class Meta: + model = federation_models.Domain + fields = [ + "name", + "creation_date", + "actors_count", + "last_activity_date", + "outbox_activities_count", + "nodeinfo", + "nodeinfo_fetch_date", + ] + + def get_actors_count(self, o): + return getattr(o, "actors_count", 0) + + def get_last_activity_date(self, o): + return getattr(o, "last_activity_date", None) + + def get_outbox_activities_count(self, o): + return getattr(o, "outbox_activities_count", 0) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 9f5503978..26832f946 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -3,6 +3,8 @@ from rest_framework import routers from . import views +federation_router = routers.SimpleRouter() +federation_router.register(r"domains", views.ManageDomainViewSet, "domains") library_router = routers.SimpleRouter() library_router.register(r"uploads", views.ManageUploadViewSet, "uploads") users_router = routers.SimpleRouter() @@ -10,6 +12,10 @@ users_router.register(r"users", views.ManageUserViewSet, "users") users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") urlpatterns = [ + url( + r"^federation/", + include((federation_router.urls, "federation"), namespace="federation"), + ), url(r"^library/", include((library_router.urls, "instance"), namespace="library")), url(r"^users/", include((users_router.urls, "instance"), namespace="users")), ] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index bfd5b2ef2..98ba220e5 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,7 +1,9 @@ from rest_framework import mixins, response, viewsets -from rest_framework.decorators import list_route +from rest_framework.decorators import detail_route, list_route from funkwhale_api.common import preferences +from funkwhale_api.federation import models as federation_models +from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models from funkwhale_api.users.permissions import HasUserPermission @@ -92,3 +94,39 @@ class ManageInvitationViewSet( serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) + + +class ManageDomainViewSet( + mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + lookup_value_regex = r"[a-zA-Z0-9\-\.]+" + queryset = ( + federation_models.Domain.objects.external() + .with_last_activity_date() + .with_actors_count() + .with_outbox_activities_count() + .order_by("name") + ) + serializer_class = serializers.ManageDomainSerializer + filter_class = filters.ManageDomainFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["moderation"] + ordering_fields = [ + "name", + "creation_date", + "last_activity_date", + "actors_count", + "outbox_activities_count", + ] + + @detail_route(methods=["get"]) + def nodeinfo(self, request, *args, **kwargs): + domain = self.get_object() + federation_tasks.update_domain_nodeinfo(domain_name=domain.name) + domain.refresh_from_db() + return response.Response(domain.nodeinfo, status=200) + + @detail_route(methods=["get"]) + def stats(self, request, *args, **kwargs): + domain = self.get_object() + return response.Response(domain.get_stats(), status=200) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index ff7561b4b..e36da4ee3 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -29,7 +29,7 @@ from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import utils as federation_utils from . import importers, metadata, utils -logger = logging.getLogger(__file__) +logger = logging.getLogger(__name__) def empty_dict(): @@ -617,6 +617,9 @@ class UploadQuerySet(models.QuerySet): def for_federation(self): return self.filter(import_status="finished", mimetype__startswith="audio/") + def with_file(self): + return self.exclude(audio_file=None).exclude(audio_file="") + TRACK_FILE_IMPORT_STATUS_CHOICES = ( ("pending", "Pending"), diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index f7ee90c7e..303f4f9c3 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -51,7 +51,7 @@ class UserAdmin(AuthUserAdmin): "privacy_level", "permission_settings", "permission_library", - "permission_federation", + "permission_moderation", ] fieldsets = ( @@ -67,10 +67,9 @@ class UserAdmin(AuthUserAdmin): "is_active", "is_staff", "is_superuser", - "permission_upload", "permission_library", "permission_settings", - "permission_federation", + "permission_moderation", ) }, ), diff --git a/api/funkwhale_api/users/migrations/0013_auto_20181206_1008.py b/api/funkwhale_api/users/migrations/0013_auto_20181206_1008.py new file mode 100644 index 000000000..3c9583131 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0013_auto_20181206_1008.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.9 on 2018-12-06 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0012_user_upload_quota'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='permission_federation', + ), + migrations.RemoveField( + model_name='user', + name='permission_upload', + ), + migrations.AddField( + model_name='user', + name='permission_moderation', + field=models.BooleanField(default=False, help_text='Block/mute/remove domains, users and content', verbose_name='Moderation'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 2bc87588e..07bb4bae4 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -34,16 +34,15 @@ def get_token(): PERMISSIONS_CONFIGURATION = { - "federation": { - "label": "Manage library federation", - "help_text": "Follow other instances, accept/deny library follow requests...", + "moderation": { + "label": "Moderation", + "help_text": "Block/mute/remove domains, users and content", }, "library": { "label": "Manage library", "help_text": "Manage library, delete files, tracks, artists, albums...", }, "settings": {"label": "Manage instance-level settings", "help_text": ""}, - "upload": {"label": "Upload new content to the library", "help_text": ""}, } PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) @@ -71,9 +70,9 @@ class User(AbstractUser): subsonic_api_token = models.CharField(blank=True, null=True, max_length=255) # permissions - permission_federation = models.BooleanField( - PERMISSIONS_CONFIGURATION["federation"]["label"], - help_text=PERMISSIONS_CONFIGURATION["federation"]["help_text"], + permission_moderation = models.BooleanField( + PERMISSIONS_CONFIGURATION["moderation"]["label"], + help_text=PERMISSIONS_CONFIGURATION["moderation"]["help_text"], default=False, ) permission_library = models.BooleanField( @@ -86,11 +85,6 @@ class User(AbstractUser): help_text=PERMISSIONS_CONFIGURATION["settings"]["help_text"], default=False, ) - permission_upload = models.BooleanField( - PERMISSIONS_CONFIGURATION["upload"]["label"], - help_text=PERMISSIONS_CONFIGURATION["upload"]["help_text"], - default=False, - ) last_activity = models.DateTimeField(default=None, null=True, blank=True) @@ -252,7 +246,9 @@ def get_actor_data(user): username = federation_utils.slugify_username(user.username) return { "preferred_username": username, - "domain": settings.FEDERATION_HOSTNAME, + "domain": federation_models.Domain.objects.get_or_create( + name=settings.FEDERATION_HOSTNAME + )[0], "type": "Person", "name": user.username, "manually_approves_followers": False, diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 06fbd4cc4..d977eddbb 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -51,7 +51,7 @@ django-taggit>=0.22,<0.23 # Until this is merged pymemoize==1.0.3 -django-dynamic-preferences>=1.5,<1.6 +django-dynamic-preferences>=1.7,<1.8 pyacoustid>=1.1.5,<1.2 raven>=6.5,<7 python-magic==0.4.15 diff --git a/api/tests/common/test_scripts.py b/api/tests/common/test_scripts.py index 2004cfe57..017caad70 100644 --- a/api/tests/common/test_scripts.py +++ b/api/tests/common/test_scripts.py @@ -22,30 +22,6 @@ def test_script_command_list(command, script_name, mocker): mocked.assert_called_once_with(command, script_name=script_name, interactive=False) -def test_django_permissions_to_user_permissions(factories, command): - group = factories["auth.Group"](perms=["federation.change_library"]) - user1 = factories["users.User"]( - perms=[ - "dynamic_preferences.change_globalpreferencemodel", - "music.add_importbatch", - ] - ) - user2 = factories["users.User"](perms=["music.add_importbatch"], groups=[group]) - - scripts.django_permissions_to_user_permissions.main(command) - - user1.refresh_from_db() - user2.refresh_from_db() - - assert user1.permission_settings is True - assert user1.permission_library is True - assert user1.permission_federation is False - - assert user2.permission_settings is False - assert user2.permission_library is True - assert user2.permission_federation is True - - @pytest.mark.parametrize( "open_api,expected_visibility", [(True, "everyone"), (False, "instance")] ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 99317303c..22d8f7eba 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -12,12 +12,16 @@ from faker.providers import internet as internet_provider import factory import pytest +from django.core.management import call_command from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache, caches from django.core.files import uploadedfile from django.utils import timezone from django.test import client +from django.db import connection +from django.db.migrations.executor import MigrationExecutor from django.db.models import QuerySet + from dynamic_preferences.registries import global_preferences_registry from rest_framework import fields as rest_fields from rest_framework.test import APIClient, APIRequestFactory @@ -400,3 +404,9 @@ def spa_html(r_mock, settings): @pytest.fixture def no_api_auth(preferences): preferences["common__api_authentication_required"] = False + + +@pytest.fixture() +def migrator(transactional_db): + yield MigrationExecutor(connection) + call_command("migrate", interactive=False) diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index 9604582e5..a65b7b0cc 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -78,22 +78,6 @@ def test_get_actors_from_audience_urls(settings, db): assert str(activity.get_actors_from_audience(urls).query) == str(expected.query) -def test_get_inbox_urls(factories): - a1 = factories["federation.Actor"]( - shared_inbox_url=None, inbox_url="https://a1.inbox" - ) - a2 = factories["federation.Actor"]( - shared_inbox_url="https://shared.inbox", inbox_url="https://a2.inbox" - ) - factories["federation.Actor"]( - shared_inbox_url="https://shared.inbox", inbox_url="https://a3.inbox" - ) - - expected = sorted(set([a1.inbox_url, a2.shared_inbox_url])) - - assert activity.get_inbox_urls(a1.__class__.objects.all()) == expected - - def test_receive_invalid_data(factories): remote_actor = factories["federation.Actor"]() a = {"@context": [], "actor": remote_actor.fid, "id": "https://test.activity"} @@ -212,9 +196,6 @@ def test_outbox_router_dispatch(mocker, factories, now): "actor": actor, } - expected_deliveries_url = activity.get_inbox_urls( - models.Actor.objects.filter(pk__in=[r1.pk, r2.pk]) - ) router.connect({"type": "Noop"}, handler) activities = router.dispatch({"type": "Noop"}, {"summary": "hello"}) a = activities[0] @@ -235,8 +216,8 @@ def test_outbox_router_dispatch(mocker, factories, now): assert a.uuid is not None assert a.deliveries.count() == 2 - for url in expected_deliveries_url: - delivery = a.deliveries.get(inbox_url=url) + for actor in [r1, r2]: + delivery = a.deliveries.get(inbox_url=actor.inbox_url) assert delivery.is_delivered is False diff --git a/api/tests/federation/test_migrations.py b/api/tests/federation/test_migrations.py new file mode 100644 index 000000000..4a9ce4274 --- /dev/null +++ b/api/tests/federation/test_migrations.py @@ -0,0 +1,34 @@ +def test_domain_14_migration(migrator): + a, f, t = ("federation", "0014_auto_20181205_0958", "0015_populate_domains") + + migrator.migrate([(a, f)]) + old_apps = migrator.loader.project_state([(a, f)]).apps + Actor = old_apps.get_model(a, "Actor") + a1 = Actor.objects.create( + fid="http://test1.com", preferred_username="test1", old_domain="dOmaiN1.com" + ) + a2 = Actor.objects.create( + fid="http://test2.com", preferred_username="test2", old_domain="domain1.com" + ) + a3 = Actor.objects.create( + fid="http://test3.com", preferred_username="test2", old_domain="domain2.com" + ) + + migrator.loader.build_graph() + migrator.migrate([(a, t)]) + new_apps = migrator.loader.project_state([(a, t)]).apps + + Actor = new_apps.get_model(a, "Actor") + Domain = new_apps.get_model(a, "Domain") + + a1 = Actor.objects.get(pk=a1.pk) + a2 = Actor.objects.get(pk=a2.pk) + a3 = Actor.objects.get(pk=a3.pk) + + assert Domain.objects.count() == 2 + assert a1.domain == Domain.objects.get(pk="domain1.com") + assert a2.domain == Domain.objects.get(pk="domain1.com") + assert a3.domain == Domain.objects.get(pk="domain2.com") + + assert Domain.objects.get(pk="domain1.com").creation_date == a1.creation_date + assert Domain.objects.get(pk="domain2.com").creation_date == a3.creation_date diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 4a6131934..293675048 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -1,6 +1,8 @@ import pytest from django import db +from funkwhale_api.federation import models + def test_cannot_duplicate_actor(factories): actor = factories["federation.Actor"]() @@ -54,3 +56,44 @@ def test_actor_get_quota(factories): expected = {"total": 10, "pending": 1, "skipped": 2, "errored": 3, "finished": 4} assert library.actor.get_current_usage() == expected + + +@pytest.mark.parametrize( + "value, expected", + [ + ("Domain.com", "domain.com"), + ("hello-WORLD.com", "hello-world.com"), + ("posés.com", "posés.com"), + ], +) +def test_domain_name_saved_properly(value, expected, factories): + domain = factories["federation.Domain"](name=value) + assert domain.name == expected + + +def test_external_domains(factories, settings): + d1 = factories["federation.Domain"]() + d2 = factories["federation.Domain"]() + settings.FEDERATION_HOSTNAME = d1.pk + + assert list(models.Domain.objects.external()) == [d2] + + +def test_domain_stats(factories): + expected = { + "actors": 0, + "libraries": 0, + "tracks": 0, + "albums": 0, + "uploads": 0, + "artists": 0, + "outbox_activities": 0, + "received_library_follows": 0, + "emitted_library_follows": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + + domain = factories["federation.Domain"]() + + assert domain.get_stats() == expected diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index fe0485b52..fb151b2d7 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -43,7 +43,7 @@ def test_actor_serializer_from_ap(db): assert actor.public_key == payload["publicKey"]["publicKeyPem"] assert actor.preferred_username == payload["preferredUsername"] assert actor.name == payload["name"] - assert actor.domain == "test.federation" + assert actor.domain.pk == "test.federation" assert actor.summary == payload["summary"] assert actor.type == "Person" assert actor.manually_approves_followers == payload["manuallyApprovesFollowers"] @@ -71,7 +71,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db): assert actor.followers_url == payload["followers"] assert actor.following_url == payload["following"] assert actor.preferred_username == payload["preferredUsername"] - assert actor.domain == "test.federation" + assert actor.domain.pk == "test.federation" assert actor.type == "Person" assert actor.manually_approves_followers is None @@ -110,7 +110,7 @@ def test_actor_serializer_to_ap(): public_key=expected["publicKey"]["publicKeyPem"], preferred_username=expected["preferredUsername"], name=expected["name"], - domain="test.federation", + domain=models.Domain(pk="test.federation"), summary=expected["summary"], type="Person", manually_approves_followers=False, @@ -135,7 +135,7 @@ def test_webfinger_serializer(): actor = models.Actor( fid=expected["links"][0]["href"], preferred_username="service", - domain="test.federation", + domain=models.Domain(pk="test.federation"), ) serializer = serializers.ActorWebfingerSerializer(actor) @@ -898,7 +898,7 @@ def test_local_actor_serializer_to_ap(factories): public_key=expected["publicKey"]["publicKeyPem"], preferred_username=expected["preferredUsername"], name=expected["name"], - domain="test.federation", + domain=models.Domain.objects.create(pk="test.federation"), summary=expected["summary"], type="Person", manually_approves_followers=False, diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index 1f58055a2..ad7a577ef 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -138,3 +138,55 @@ def test_deliver_to_remote_error(factories, r_mock, now): assert delivery.is_delivered is False assert delivery.attempts == 1 assert delivery.last_attempt_date == now + + +def test_fetch_nodeinfo(factories, r_mock, now): + wellknown_url = "https://test.test/.well-known/nodeinfo" + nodeinfo_url = "https://test.test/nodeinfo" + + r_mock.get( + wellknown_url, + json={ + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "https://test.test/nodeinfo", + } + ] + }, + ) + r_mock.get(nodeinfo_url, json={"hello": "world"}) + + assert tasks.fetch_nodeinfo("test.test") == {"hello": "world"} + + +def test_update_domain_nodeinfo(factories, mocker, now): + domain = factories["federation.Domain"]() + mocker.patch.object(tasks, "fetch_nodeinfo", return_value={"hello": "world"}) + + assert domain.nodeinfo == {} + assert domain.nodeinfo_fetch_date is None + + tasks.update_domain_nodeinfo(domain_name=domain.name) + + domain.refresh_from_db() + + assert domain.nodeinfo_fetch_date == now + assert domain.nodeinfo == {"status": "ok", "payload": {"hello": "world"}} + + +def test_update_domain_nodeinfo_error(factories, r_mock, now): + domain = factories["federation.Domain"]() + wellknown_url = "https://{}/.well-known/nodeinfo".format(domain.name) + + r_mock.get(wellknown_url, status_code=500) + + tasks.update_domain_nodeinfo(domain_name=domain.name) + + domain.refresh_from_db() + + assert domain.nodeinfo_fetch_date == now + assert domain.nodeinfo == { + "status": "error", + "error": "500 Server Error: None for url: {}".format(wellknown_url), + } diff --git a/api/tests/manage/test_filters.py b/api/tests/manage/test_filters.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index df55ab823..d3b96ec22 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -13,8 +13,7 @@ def test_manage_upload_action_delete(factories): def test_user_update_permission(factories): user = factories["users.User"]( permission_library=False, - permission_upload=False, - permission_federation=True, + permission_moderation=False, permission_settings=True, is_active=True, ) @@ -22,7 +21,7 @@ def test_user_update_permission(factories): user, data={ "is_active": False, - "permissions": {"federation": False, "upload": True}, + "permissions": {"moderation": True, "settings": False}, "upload_quota": 12, }, ) @@ -32,7 +31,25 @@ def test_user_update_permission(factories): assert user.is_active is False assert user.upload_quota == 12 - assert user.permission_federation is False - assert user.permission_upload is True + assert user.permission_moderation is True assert user.permission_library is False - assert user.permission_settings is True + assert user.permission_settings is False + + +def test_manage_domain_serializer(factories, now): + domain = factories["federation.Domain"]() + setattr(domain, "actors_count", 42) + setattr(domain, "outbox_activities_count", 23) + setattr(domain, "last_activity_date", now) + expected = { + "name": domain.name, + "creation_date": domain.creation_date.isoformat().split("+")[0] + "Z", + "last_activity_date": now, + "actors_count": 42, + "outbox_activities_count": 23, + "nodeinfo": {}, + "nodeinfo_fetch_date": None, + } + s = serializers.ManageDomainSerializer(domain) + + assert s.data == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index a9920ce07..d47a231e8 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -1,6 +1,7 @@ import pytest from django.urls import reverse +from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.manage import serializers, views @@ -10,6 +11,7 @@ from funkwhale_api.manage import serializers, views (views.ManageUploadViewSet, ["library"], "and"), (views.ManageUserViewSet, ["settings"], "and"), (views.ManageInvitationViewSet, ["settings"], "and"), + (views.ManageDomainViewSet, ["moderation"], "and"), ], ) def test_permissions(assert_user_permission, view, permissions, operator): @@ -64,3 +66,49 @@ def test_invitation_view_create(factories, superuser_api_client, mocker): assert response.status_code == 201 assert superuser_api_client.user.invitations.latest("id") is not None + + +def test_domain_list(factories, superuser_api_client, settings): + factories["federation.Domain"](pk=settings.FEDERATION_HOSTNAME) + d = factories["federation.Domain"]() + url = reverse("api:v1:manage:federation:domains-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["name"] == d.pk + + +def test_domain_detail(factories, superuser_api_client): + d = factories["federation.Domain"]() + url = reverse("api:v1:manage:federation:domains-detail", kwargs={"pk": d.name}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["name"] == d.pk + + +def test_domain_nodeinfo(factories, superuser_api_client, mocker): + domain = factories["federation.Domain"]() + url = reverse( + "api:v1:manage:federation:domains-nodeinfo", kwargs={"pk": domain.name} + ) + mocker.patch.object( + federation_tasks, "fetch_nodeinfo", return_value={"hello": "world"} + ) + update_domain_nodeinfo = mocker.spy(federation_tasks, "update_domain_nodeinfo") + response = superuser_api_client.get(url) + assert response.status_code == 200 + assert response.data == {"status": "ok", "payload": {"hello": "world"}} + + update_domain_nodeinfo.assert_called_once_with(domain_name=domain.name) + + +def test_domain_stats(factories, superuser_api_client, mocker): + domain = factories["federation.Domain"]() + mocker.patch.object(domain.__class__, "get_stats", return_value={"hello": "world"}) + url = reverse("api:v1:manage:federation:domains-stats", kwargs={"pk": domain.name}) + response = superuser_api_client.get(url) + assert response.status_code == 200 + assert response.data == {"hello": "world"} diff --git a/api/tests/radios/test_filters.py b/api/tests/radios/test_filters.py deleted file mode 100644 index 89bb726af..000000000 --- a/api/tests/radios/test_filters.py +++ /dev/null @@ -1,153 +0,0 @@ -import pytest -from django.core.exceptions import ValidationError - -from funkwhale_api.music.models import Track -from funkwhale_api.radios import filters - - -@filters.registry.register -class NoopFilter(filters.RadioFilter): - code = "noop" - - def get_query(self, candidates, **kwargs): - return - - -def test_most_simple_radio_does_not_filter_anything(factories): - factories["music.Track"].create_batch(3) - radio = factories["radios.Radio"](config=[{"type": "noop"}]) - - assert radio.version == 0 - assert radio.get_candidates().count() == 3 - - -def test_filter_can_use_custom_queryset(factories): - tracks = factories["music.Track"].create_batch(3) - candidates = Track.objects.filter(pk=tracks[0].pk) - - qs = filters.run([{"type": "noop"}], candidates=candidates) - assert qs.count() == 1 - assert qs.first() == tracks[0] - - -def test_filter_on_tag(factories): - tracks = factories["music.Track"].create_batch(3, tags=["metal"]) - factories["music.Track"].create_batch(3, tags=["pop"]) - expected = tracks - f = [{"type": "tag", "names": ["metal"]}] - - candidates = filters.run(f) - assert list(candidates.order_by("pk")) == expected - - -def test_filter_on_artist(factories): - artist1 = factories["music.Artist"]() - artist2 = factories["music.Artist"]() - factories["music.Track"].create_batch(3, artist=artist1) - factories["music.Track"].create_batch(3, artist=artist2) - expected = list(artist1.tracks.order_by("pk")) - f = [{"type": "artist", "ids": [artist1.pk]}] - - candidates = filters.run(f) - assert list(candidates.order_by("pk")) == expected - - -def test_can_combine_with_or(factories): - artist1 = factories["music.Artist"]() - artist2 = factories["music.Artist"]() - artist3 = factories["music.Artist"]() - factories["music.Track"].create_batch(3, artist=artist1) - factories["music.Track"].create_batch(3, artist=artist2) - factories["music.Track"].create_batch(3, artist=artist3) - expected = Track.objects.exclude(artist=artist3).order_by("pk") - f = [ - {"type": "artist", "ids": [artist1.pk]}, - {"type": "artist", "ids": [artist2.pk], "operator": "or"}, - ] - - candidates = filters.run(f) - assert list(candidates.order_by("pk")) == list(expected) - - -def test_can_combine_with_and(factories): - artist1 = factories["music.Artist"]() - artist2 = factories["music.Artist"]() - metal_tracks = factories["music.Track"].create_batch( - 2, artist=artist1, tags=["metal"] - ) - factories["music.Track"].create_batch(2, artist=artist1, tags=["pop"]) - factories["music.Track"].create_batch(3, artist=artist2) - expected = metal_tracks - f = [ - {"type": "artist", "ids": [artist1.pk]}, - {"type": "tag", "names": ["metal"], "operator": "and"}, - ] - - candidates = filters.run(f) - assert list(candidates.order_by("pk")) == list(expected) - - -def test_can_negate(factories): - artist1 = factories["music.Artist"]() - artist2 = factories["music.Artist"]() - factories["music.Track"].create_batch(3, artist=artist1) - factories["music.Track"].create_batch(3, artist=artist2) - expected = artist2.tracks.order_by("pk") - f = [{"type": "artist", "ids": [artist1.pk], "not": True}] - - candidates = filters.run(f) - assert list(candidates.order_by("pk")) == list(expected) - - -def test_can_group(factories): - artist1 = factories["music.Artist"]() - artist2 = factories["music.Artist"]() - factories["music.Track"].create_batch(2, artist=artist1) - t1 = factories["music.Track"].create_batch(2, artist=artist1, tags=["metal"]) - factories["music.Track"].create_batch(2, artist=artist2) - t2 = factories["music.Track"].create_batch(2, artist=artist2, tags=["metal"]) - factories["music.Track"].create_batch(2, tags=["metal"]) - expected = t1 + t2 - f = [ - {"type": "tag", "names": ["metal"]}, - { - "type": "group", - "operator": "and", - "filters": [ - {"type": "artist", "ids": [artist1.pk], "operator": "or"}, - {"type": "artist", "ids": [artist2.pk], "operator": "or"}, - ], - }, - ] - - candidates = filters.run(f) - assert list(candidates.order_by("pk")) == list(expected) - - -def test_artist_filter_clean_config(factories): - artist1 = factories["music.Artist"]() - artist2 = factories["music.Artist"]() - - config = filters.clean_config({"type": "artist", "ids": [artist2.pk, artist1.pk]}) - - expected = { - "type": "artist", - "ids": [artist1.pk, artist2.pk], - "names": [artist1.name, artist2.name], - } - assert filters.clean_config(config) == expected - - -def test_can_check_artist_filter(factories): - artist = factories["music.Artist"]() - - assert filters.validate({"type": "artist", "ids": [artist.pk]}) - with pytest.raises(ValidationError): - filters.validate({"type": "artist", "ids": [artist.pk + 1]}) - - -def test_can_check_operator(): - assert filters.validate({"type": "group", "operator": "or", "filters": []}) - assert filters.validate({"type": "group", "operator": "and", "filters": []}) - with pytest.raises(ValidationError): - assert filters.validate({"type": "group", "operator": "nope", "filters": []}) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 69d338828..4b2f71bca 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -46,23 +46,22 @@ def test_get_permissions_regular(factories): def test_get_permissions_default(factories, preferences): - preferences["users__default_permissions"] = ["upload", "federation"] + preferences["users__default_permissions"] = ["library", "moderation"] user = factories["users.User"]() perms = user.get_permissions() - assert perms["upload"] is True - assert perms["federation"] is True - assert perms["library"] is False + assert perms["moderation"] is True + assert perms["library"] is True assert perms["settings"] is False @pytest.mark.parametrize( "args,perms,expected", [ - ({"is_superuser": True}, ["federation", "library"], True), - ({"is_superuser": False}, ["federation"], False), + ({"is_superuser": True}, ["moderation", "library"], True), + ({"is_superuser": False}, ["moderation"], False), ({"permission_library": True}, ["library"], True), - ({"permission_library": True}, ["library", "federation"], False), + ({"permission_library": True}, ["library", "moderation"], False), ], ) def test_has_permissions_and(args, perms, expected, factories): @@ -73,10 +72,10 @@ def test_has_permissions_and(args, perms, expected, factories): @pytest.mark.parametrize( "args,perms,expected", [ - ({"is_superuser": True}, ["federation", "library"], True), - ({"is_superuser": False}, ["federation"], False), - ({"permission_library": True}, ["library", "federation"], True), - ({"permission_library": True}, ["federation"], False), + ({"is_superuser": True}, ["moderation", "library"], True), + ({"is_superuser": False}, ["moderation"], False), + ({"permission_library": True}, ["library", "moderation"], True), + ({"permission_library": True}, ["moderation"], False), ], ) def test_has_permissions_or(args, perms, expected, factories): @@ -137,7 +136,7 @@ def test_creating_actor_from_user(factories, settings): actor = models.create_actor(user) assert actor.preferred_username == "Hello_M_world" # slugified - assert actor.domain == settings.FEDERATION_HOSTNAME + assert actor.domain.pk == settings.FEDERATION_HOSTNAME assert actor.type == "Person" assert actor.name == user.username assert actor.manually_approves_followers is False diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py index 7f72138f4..0b92f74a5 100644 --- a/api/tests/users/test_permissions.py +++ b/api/tests/users/test_permissions.py @@ -21,21 +21,21 @@ def test_has_user_permission_anonymous(anonymous_user, api_request): @pytest.mark.parametrize("value", [True, False]) def test_has_user_permission_logged_in_single(value, factories, api_request): - user = factories["users.User"](permission_federation=value) + user = factories["users.User"](permission_moderation=value) class View(APIView): - required_permissions = ["federation"] + required_permissions = ["moderation"] view = View() permission = permissions.HasUserPermission() request = api_request.get("/") setattr(request, "user", user) result = permission.has_permission(request, view) - assert result == user.has_permissions("federation") == value + assert result == user.has_permissions("moderation") == value @pytest.mark.parametrize( - "federation,library,expected", + "moderation,library,expected", [ (True, False, False), (False, True, False), @@ -44,14 +44,14 @@ def test_has_user_permission_logged_in_single(value, factories, api_request): ], ) def test_has_user_permission_logged_in_multiple_and( - federation, library, expected, factories, api_request + moderation, library, expected, factories, api_request ): user = factories["users.User"]( - permission_federation=federation, permission_library=library + permission_moderation=moderation, permission_library=library ) class View(APIView): - required_permissions = ["federation", "library"] + required_permissions = ["moderation", "library"] permission_operator = "and" view = View() @@ -59,11 +59,11 @@ def test_has_user_permission_logged_in_multiple_and( request = api_request.get("/") setattr(request, "user", user) result = permission.has_permission(request, view) - assert result == user.has_permissions("federation", "library") == expected + assert result == user.has_permissions("moderation", "library") == expected @pytest.mark.parametrize( - "federation,library,expected", + "moderation,library,expected", [ (True, False, True), (False, True, True), @@ -72,14 +72,14 @@ def test_has_user_permission_logged_in_multiple_and( ], ) def test_has_user_permission_logged_in_multiple_or( - federation, library, expected, factories, api_request + moderation, library, expected, factories, api_request ): user = factories["users.User"]( - permission_federation=federation, permission_library=library + permission_moderation=moderation, permission_library=library ) class View(APIView): - required_permissions = ["federation", "library"] + required_permissions = ["moderation", "library"] permission_operator = "or" view = View() @@ -87,6 +87,6 @@ def test_has_user_permission_logged_in_multiple_or( request = api_request.get("/") setattr(request, "user", user) result = permission.has_permission(request, view) - has_permission_result = user.has_permissions("federation", "library", operator="or") + has_permission_result = user.has_permissions("moderation", "library", operator="or") assert result == has_permission_result == expected diff --git a/dev.yml b/dev.yml index 37abba0c4..dc931386e 100644 --- a/dev.yml +++ b/dev.yml @@ -85,6 +85,8 @@ services: - redis networks: - internal + cap_add: + - SYS_PTRACE nginx: command: /entrypoint.sh env_file: diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index f072ce808..3a5bf2db8 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -76,19 +76,27 @@ class="item" :to="{name: 'content.index'}">Add content -
+
Administration
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index e8dec339a..5b138a3c6 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -1,7 +1,7 @@