diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 51230bfdc3bc023f6cea0c985e5524edb468f82f..97a0883384afc6f5017d0c2cf13089b7bb3b23b9 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 b8106bef49f4c377c7af28f4993038e8a9a03309..d9422f6fa4285faa2ae4b12bc991810ab6f73a15 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 48144f8ea7f737a39d94a1f1b96573557109300d..0a07fa7fee83e32b546d6229c066df59d8b8795d 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 498c76a99bff52dcacf2cf9ed4ea87aa222e49c8..211b8230a6891fbf660d69387c8fd4741a95621f 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 98bc65247c9125d6548016ec540e2b1231a1fad2..acb2e5b67b33830e57550d9484516d0071c8c9df 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 a52cf88becfafe72c8ed03190f75135af815456e..cbe0bee85fceee231b9d9d86181225aab97390b0 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 0000000000000000000000000000000000000000..98a481271c0c32d196763614641db53359fab58f --- /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 0000000000000000000000000000000000000000..7be361f871dce7dd45857243108aa44faec6ca44 --- /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 0000000000000000000000000000000000000000..0f0036c947fb3cf71ea1eb29062eb7059681d5c7 --- /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 0000000000000000000000000000000000000000..8b705e72f859a195283d16c97373f59b776cab51 --- /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 058bb9c46f578336aaeb2a432c9e93a07d5e3c37..4b7730402be9af651641c65347bc750decbe6898 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 6c4ffeb587129a55389381ef84e83fc3f95cdbf7..1cece3b97bbef1ad52a9667b456500ecc4448572 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 33f94cad3801690d957cefc31c075992ccb4bde0..4ed07aa25f9769aa41905eef402490b5536c99b4 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 7f6e328db16acce78f6e8ec4947bdcba66ad0a49..d9b9bfc1df0a1e079260095aab74b2c73cb8db75 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 9b5e24f662d9f25c39e980108b119f85e67c8470..a401381e65c797c7122bec67021cec167ac1e403 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 9f5503978c435797d012fc0595a8c8b50aa81568..26832f946e5430198a5804030241e9efaa8a6461 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 bfd5b2ef21bb3c1854ed711bb52b57f5bdc6a079..98ba220e50240bdea59a99a1d2bf9a0c25e57b9b 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 ff7561b4b1af28d2f7c26f4be31cadf8249e89d8..e36da4ee332f2fbf2c28819e19304b26a2a44502 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 f7ee90c7e50a5f700e002eb062fbcc06ee9795f9..303f4f9c3d4d575320ad53fcc2813e8c2ae7f456 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 0000000000000000000000000000000000000000..3c9583131e61d4abd032d6eec0504f01d6f67f9d --- /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 2bc87588ef0ab235ec51bce6270770148c905641..07bb4bae4c23ceb83033bdf434f619fa3e400572 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 06fbd4cc452659486cfdad4ddf748b55e5fdb6f2..d977eddbbe29fd010ac11f6e013a094f3f43f415 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 2004cfe5732448f553c327e4a0cb0c59a20b599b..017caad70abf3fa0cb4733ba7746024b0966a473 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 99317303c86aa8f9980e7dda2196fcf3e48906f8..22d8f7ebaf7b41a5249ccd1c307282a13efc03ed 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 9604582e562ac80ce98afc6087f5d30389b5f144..a65b7b0cc8cba956bdcc9ad7c5a5863a35b770d5 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 0000000000000000000000000000000000000000..4a9ce427478a07bf201370106270fdefd7a0f2c8 --- /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 4a6131934994e1e109128d07bebe38ad805587bb..2936750484a1642edc2ab0c0c84f12c39208e8c0 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 fe0485b52e2bfa9df24e4be3e94c3c9a62ed0eea..fb151b2d7d36d5823e128668ab0277fb8c5c21e0 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 1f58055a259a0469f621f85b85b44519c6a32d4d..ad7a577ef0c574263c60136ace64546dc05d3d6a 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index df55ab82393aa28a70ebdbb51c4d8650d08ca9ac..d3b96ec2217b060423367134161c316c802d5ccb 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 a9920ce0761b655074cba3a1a7e18104fea8ca49..d47a231e8beee8b96ee84e7254f3f34d0dc19890 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 89bb726aff643c7365b759b510f763b738c738d0..0000000000000000000000000000000000000000 --- 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 69d33882848313df762e5f9a9f11d8b896450fe4..4b2f71bca02fa754bdb60615fd08d3e496d58013 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 7f72138f4a14394e3a65d53deda3ab1728f32473..0b92f74a58ab7d914a0b15ceeb48b17e5abf95ae 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 37abba0c4cab3f3ff1008a45a9ea1a72a31c5dc4..dc931386e229afa0c4e737347fee442ead47a7a0 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 f072ce808fb3dc9cb7e8f0f3f2e3b924bdc2ed73..3a5bf2db8a3ddbee831bbbdf5d88c7fc1d018b3f 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -76,19 +76,27 @@ class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate>Add content</translate></router-link> </div> </div> - <div class="item" v-if="$store.state.auth.availablePermissions['settings']"> + <div class="item" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"> <header class="header"><translate>Administration</translate></header> <div class="menu"> <router-link + v-if="$store.state.auth.availablePermissions['settings']" class="item" :to="{path: '/manage/settings'}"> <i class="settings icon"></i><translate>Settings</translate> </router-link> <router-link + v-if="$store.state.auth.availablePermissions['settings']" class="item" :to="{name: 'manage.users.users.list'}"> <i class="users icon"></i><translate>Users</translate> </router-link> + <router-link + v-if="$store.state.auth.availablePermissions['moderation']" + class="item" + :to="{name: 'manage.moderation.domains.list'}"> + <i class="shield icon"></i><translate>Moderation</translate> + </router-link> </div> </div> </nav> diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index e8dec339aaa85d85172af1da44c7c11d8b4dc7b0..5b138a3c6f909d51b39352561f20b8419aae57d7 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -1,7 +1,7 @@ <template> <table class="ui compact very basic single line unstackable table"> <thead> - <tr v-if="actions.length > 0"> + <tr v-if="actionUrl && actions.length > 0"> <th colspan="1000"> <div class="ui small form"> <div class="ui inline fields"> @@ -130,8 +130,8 @@ import axios from 'axios' export default { props: { - actionUrl: {type: String, required: true}, - idField: {type: String, required: true, default: 'id'}, + actionUrl: {type: String, required: false, default: null}, + idField: {type: String, required: false, default: 'id'}, objectsData: {type: Object, required: true}, actions: {type: Array, required: true, default: () => { return [] }}, filters: {type: Object, required: false, default: () => { return {} }}, diff --git a/front/src/components/common/AjaxButton.vue b/front/src/components/common/AjaxButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..024c98515bf71bd5bc951284f28a8436d8ab5fc5 --- /dev/null +++ b/front/src/components/common/AjaxButton.vue @@ -0,0 +1,33 @@ +<template> + <button @click="ajaxCall" :class="['ui', {loading: isLoading}, 'button']"> + <slot></slot> + </button> +</template> +<script> +import axios from 'axios' + +export default { + props: { + url: {type: String, required: true}, + method: {type: String, required: true}, + }, + data () { + return { + isLoading: false, + } + }, + methods: { + ajaxCall () { + var self = this + this.isLoading = true + axios[this.method](this.url).then(response => { + self.$emit('action-done', response.data) + self.isLoading = false + }, error => { + self.isLoading = false + self.$emit('action-error', error) + }) + } + } +} +</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index f3bb383f06fdea38b185083ad5945ba376c5ea43..d5a1fb4a447eb6bf819b6fbf6e2a4c370c60ae0f 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -36,4 +36,9 @@ import CopyInput from '@/components/common/CopyInput' Vue.component('copy-input', CopyInput) +import AjaxButton from '@/components/common/AjaxButton' + +Vue.component('ajax-button', AjaxButton) + + export default {} diff --git a/front/src/components/manage/moderation/DomainsTable.vue b/front/src/components/manage/moderation/DomainsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..cddff5fa4910b9facd99772c3d5d20406f75a60a --- /dev/null +++ b/front/src/components/manage/moderation/DomainsTable.vue @@ -0,0 +1,190 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label><translate>Search</translate></label> + <input type="text" v-model="search" :placeholder="labels.searchPlaceholder" /> + </div> + <div class="field"> + <label><translate>Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ sharedLabels.filters[option[1]] }} + </option> + </select> + </div> + <div class="field"> + <label><translate>Ordering direction</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate>Ascending</translate></option> + <option value="-"><translate>Descending</translate></option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate>Name</translate></th> + <th><translate>Users</translate></th> + <th><translate>Received messages</translate></th> + <th><translate>First seen</translate></th> + <th><translate>Last activity</translate></th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.name }}">{{ scope.obj.name }}</router-link> + </td> + <td> + {{ scope.obj.actors_count }} + </td> + <td> + {{ scope.obj.outbox_activities_count }} + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date v-if="scope.obj.last_activity_date" :date="scope.obj.last_activity_date"></human-date> + <translate v-else>N/A</translate> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.count > paginateBy" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + <translate + :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> + Showing results %{ start }-%{ end } on %{ total } + </translate> + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from '@/lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' +import TranslationsMixin from '@/components/mixins/Translations' + +export default { + mixins: [OrderingMixin, TranslationsMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: '', + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['name', 'name'], + ['creation_date', 'first_seen'], + ['last_activity_date', 'last_activity'], + ['actors_count', 'users'], + ['outbox_activities_count', 'received_messages'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/federation/domains/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + labels () { + return { + searchPlaceholder: this.$gettext('Search by name...') + } + }, + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + // { + // name: 'delete', + // label: this.$gettext('Delete'), + // isDangerous: true + // } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index be35c2f3432d419123a7432ac976cb7b21d49cc0..c982c9ad7ffedea54bae8664569166ad8e705c09 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -15,6 +15,7 @@ export default { }, filters: { creation_date: this.$gettext('Creation date'), + first_seen: this.$gettext('First seen date'), accessed_date: this.$gettext('Accessed date'), modification_date: this.$gettext('Modification date'), imported_date: this.$gettext('Imported date'), @@ -30,6 +31,8 @@ export default { date_joined: this.$gettext('Sign-up date'), last_activity: this.$gettext('Last activity'), username: this.$gettext('Username'), + users: this.$gettext('Users'), + received_messages: this.$gettext('Received messages'), } } } diff --git a/front/src/router/index.js b/front/src/router/index.js index f6b4d309f2f1ef1c8b60518587feb3149d9d6841..9d4b46917afa5a12cc1efedd5b2c175de9e8bee4 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -30,6 +30,9 @@ import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersDetail from '@/views/admin/users/UsersDetail' import AdminUsersList from '@/views/admin/users/UsersList' import AdminInvitationsList from '@/views/admin/users/InvitationsList' +import AdminModerationBase from '@/views/admin/moderation/Base' +import AdminDomainsList from '@/views/admin/moderation/DomainsList' +import AdminDomainsDetail from '@/views/admin/moderation/DomainsDetail' import ContentBase from '@/views/content/Base' import ContentHome from '@/views/content/Home' import LibrariesHome from '@/views/content/libraries/Home' @@ -224,6 +227,23 @@ export default new Router({ } ] }, + { + path: '/manage/moderation', + component: AdminModerationBase, + children: [ + { + path: 'domains', + name: 'manage.moderation.domains.list', + component: AdminDomainsList + }, + { + path: 'domains/:id', + name: 'manage.moderation.domains.detail', + component: AdminDomainsDetail, + props: true + } + ] + }, { path: '/library', component: Library, diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 70dbe26babe36933a111f286dc257a8236bb2dea..1299dabfe879a76651093384c3e2b55d178e2475 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -9,10 +9,9 @@ export default { authenticated: false, username: '', availablePermissions: { - federation: false, settings: false, library: false, - upload: false + moderation: false }, profile: null, token: '', diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 4caa0f431935c52f32e0d8b73a33acc5e5f1f6d9..1ce8144c66c6e7473937a9dffc21297e5c71257a 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -27,7 +27,7 @@ @import "~semantic-ui-css/components/label.css"; @import "~semantic-ui-css/components/list.css"; @import "~semantic-ui-css/components/loader.css"; -// @import "~semantic-ui-css/components/placeholder.css"; +@import "~semantic-ui-css/components/placeholder.css"; // @import "~semantic-ui-css/components/rail.css"; // @import "~semantic-ui-css/components/reveal.css"; @import "~semantic-ui-css/components/segment.css"; @@ -251,3 +251,11 @@ button.reset { .right.floated { float: right; } + + +[data-tooltip]::after { + white-space: normal; + width: 300px; + max-width: 300px; + z-index: 999; +} diff --git a/front/src/views/admin/moderation/Base.vue b/front/src/views/admin/moderation/Base.vue new file mode 100644 index 0000000000000000000000000000000000000000..d4487339d3f41bb665e67c7034fdf974f7a72095 --- /dev/null +++ b/front/src/views/admin/moderation/Base.vue @@ -0,0 +1,23 @@ +<template> + <div class="main pusher" v-title="labels.manageDomains"> + <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> + <router-link + class="ui item" + :to="{name: 'manage.moderation.domains.list'}"><translate>Domains</translate></router-link> + </nav> + <router-view :key="$route.fullPath"></router-view> + </div> +</template> + +<script> +export default { + computed: { + labels() { + return { + manageDomains: this.$gettext("Manage domains"), + secondaryMenu: this.$gettext("Secondary menu") + } + } + } +} +</script> diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..71007a45604ee0e40235d385ca54d94b3115b298 --- /dev/null +++ b/front/src/views/admin/moderation/DomainsDetail.vue @@ -0,0 +1,298 @@ +<template> + <main> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted cloud icon"></i> + <div class="content"> + {{ object.name }} + <div class="sub header"> + <a :href="externalUrl" target="_blank" rel="noopener noreferrer" class="logo-wrapper"> + <translate>Open website</translate> + <i class="external icon"></i> + </a> + </div> + </div> + </h2> + </div> + </section> + <div class="ui vertical stripe segment"> + <div class="ui stackable three column grid"> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="info icon"></i> + <div class="content"> + <translate>Instance data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate>First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> + <tr> + <td> + <translate>Last checked</translate> + </td> + <td> + <human-date v-if="object.nodeinfo_fetch_date" :date="object.nodeinfo_fetch_date"></human-date> + <translate v-else>N/A</translate> + </td> + </tr> + + <template v-if="object.nodeinfo && object.nodeinfo.status === 'ok'"> + <tr> + <td> + <translate>Software</translate> + </td> + <td> + {{ lodash.get(object, 'nodeinfo.payload.software.name', $gettext('N/A')) }} ({{ lodash.get(object, 'nodeinfo.payload.software.version', $gettext('N/A')) }}) + </td> + </tr> + <tr> + <td> + <translate>Name</translate> + </td> + <td> + {{ lodash.get(object, 'nodeinfo.payload.metadata.nodeName', $gettext('N/A')) }} + </td> + </tr> + <tr> + <td> + <translate>Total users</translate> + </td> + <td> + {{ lodash.get(object, 'nodeinfo.payload.usage.users.total', $gettext('N/A')) }} + </td> + </tr> + </template> + <template v-if="object.nodeinfo && object.nodeinfo.status === 'error'"> + <tr> + <td> + <translate>Status</translate> + </td> + <td> + <translate>Error while fetching node info</translate> + + <span :data-tooltip="object.nodeinfo.error"><i class="question circle icon"></i></span> + </td> + </tr> + </template> + </tbody> + </table> + <ajax-button @action-done="refreshNodeInfo" method="get" :url="'manage/federation/domains/' + object.name + '/nodeinfo/'"> + <translate>Refresh node info</translate> + </ajax-button> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="feed icon"></i> + <div class="content"> + <translate>Activity</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + <tr> + <td> + <translate>Known users</translate> + </td> + <td> + {{ stats.actors }} + </td> + </tr> + <tr> + <td> + <translate>Emitted messages</translate> + </td> + <td> + {{ stats.outbox_activities}} + </td> + </tr> + <tr> + <td> + <translate>Received library follows</translate> + </td> + <td> + {{ stats.received_library_follows}} + </td> + </tr> + <tr> + <td> + <translate>Emitted library follows</translate> + </td> + <td> + {{ stats.emitted_library_follows}} + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="music icon"></i> + <div class="content"> + <translate>Audio content</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + <tr> + <td> + <translate>Artists</translate> + </td> + <td> + {{ stats.artists }} + </td> + </tr> + <tr> + <td> + <translate>Albums</translate> + </td> + <td> + {{ stats.albums}} + </td> + </tr> + <tr> + <td> + <translate>Tracks</translate> + </td> + <td> + {{ stats.tracks }} + </td> + </tr> + <tr> + <td> + <translate>Libraries</translate> + </td> + <td> + {{ stats.libraries }} + </td> + </tr> + <tr> + <td> + <translate>Uploads</translate> + </td> + <td> + {{ stats.uploads }} + </td> + </tr> + <tr> + <td> + <translate>Cached size</translate> + </td> + <td> + {{ stats.media_downloaded_size | humanSize }} + </td> + </tr> + <tr> + <td> + <translate>Total size</translate> + </td> + <td> + {{ stats.media_total_size | humanSize }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" +import lodash from '@/lodash' + +export default { + props: ["id"], + data() { + return { + lodash, + isLoading: true, + isLoadingStats: false, + object: null, + stats: null, + permissions: [], + } + }, + created() { + this.fetchData() + this.fetchStats() + }, + methods: { + fetchData() { + var self = this + this.isLoading = true + let url = "manage/federation/domains/" + this.id + "/" + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + fetchStats() { + var self = this + this.isLoadingStats = true + let url = "manage/federation/domains/" + this.id + "/stats/" + axios.get(url).then(response => { + self.stats = response.data + self.isLoadingStats = false + }) + }, + refreshNodeInfo (data) { + this.object.nodeinfo = data + this.object.nodeinfo_fetch_date = new Date() + }, + }, + computed: { + labels() { + return { + statsWarning: this.$gettext("Statistics are computed from known activity and content on your instance, and do not reflect general activity for this domain") + } + }, + externalUrl () { + return `https://${this.object.name}` + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/admin/moderation/DomainsList.vue b/front/src/views/admin/moderation/DomainsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..84fb1df4300dffecc15db22048bafc50fb0e27dc --- /dev/null +++ b/front/src/views/admin/moderation/DomainsList.vue @@ -0,0 +1,30 @@ +<template> + <main v-title="labels.domains"> + <section class="ui vertical stripe segment"> + <h2 class="ui header"><translate>Domains</translate></h2> + <div class="ui hidden divider"></div> + <domains-table></domains-table> + </section> + </main> +</template> + +<script> +import DomainsTable from "@/components/manage/moderation/DomainsTable" + +export default { + components: { + DomainsTable + }, + computed: { + labels() { + return { + domains: this.$gettext("Domains") + } + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>