diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 3d2e20560..23d90cb6d 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -833,6 +833,10 @@ THROTTLING_RATES = { "rate": THROTTLING_USER_RATES.get("password-reset-confirm", "20/h"), "description": "Password reset confirmation", }, + "fetch": { + "rate": THROTTLING_USER_RATES.get("fetch", "200/d"), + "description": "Fetch remote objects", + }, } @@ -906,7 +910,7 @@ ACCOUNT_USERNAME_BLACKLIST = [ ] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[]) EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True) -EXTERNAL_REQUESTS_TIMEOUT = env.int("EXTERNAL_REQUESTS_TIMEOUT", default=5) +EXTERNAL_REQUESTS_TIMEOUT = env.int("EXTERNAL_REQUESTS_TIMEOUT", default=10) # XXX: deprecated, see #186 API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True) @@ -955,7 +959,11 @@ FEDERATION_OBJECT_FETCH_DELAY = env.int( MODERATION_EMAIL_NOTIFICATIONS_ENABLED = env.bool( "MODERATION_EMAIL_NOTIFICATIONS_ENABLED", default=True ) - +FEDERATION_AUTHENTIFY_FETCHES = True +FEDERATION_SYNCHRONOUS_FETCH = env.bool("FEDERATION_SYNCHRONOUS_FETCH", default=True) +FEDERATION_DUPLICATE_FETCH_DELAY = env.int( + "FEDERATION_DUPLICATE_FETCH_DELAY", default=60 * 50 +) # Delay in days after signup before we show the "support us" messages INSTANCE_SUPPORT_MESSAGE_DELAY = env.int("INSTANCE_SUPPORT_MESSAGE_DELAY", default=15) FUNKWHALE_SUPPORT_MESSAGE_DELAY = env.int("FUNKWHALE_SUPPORT_MESSAGE_DELAY", default=15) diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index b36d1cd64..34b1dc006 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -234,9 +234,11 @@ def get_updated_fields(conf, data, obj): data_value = data[data_field] except KeyError: continue - - obj_value = getattr(obj, obj_field) - if obj_value != data_value: + if obj.pk: + obj_value = getattr(obj, obj_field) + if obj_value != data_value: + final_data[obj_field] = data_value + else: final_data[obj_field] = data_value return final_data diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index bd8bfcf01..851f64a10 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -1,7 +1,13 @@ +import datetime + +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +from django.core import validators +from django.utils import timezone from rest_framework import serializers +from funkwhale_api.common import fields as common_fields from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models from funkwhale_api.users import serializers as users_serializers @@ -158,8 +164,21 @@ class InboxItemActionSerializer(common_serializers.ActionSerializer): return objects.update(is_read=True) +FETCH_OBJECT_CONFIG = { + "artist": {"queryset": music_models.Artist.objects.all()}, + "album": {"queryset": music_models.Album.objects.all()}, + "track": {"queryset": music_models.Track.objects.all()}, + "library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"}, + "upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"}, + "account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"}, +} +FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG) + + class FetchSerializer(serializers.ModelSerializer): - actor = federation_serializers.APIActorSerializer() + actor = federation_serializers.APIActorSerializer(read_only=True) + object = serializers.CharField(write_only=True) + force = serializers.BooleanField(default=False, required=False, write_only=True) class Meta: model = models.Fetch @@ -171,7 +190,63 @@ class FetchSerializer(serializers.ModelSerializer): "detail", "creation_date", "fetch_date", + "object", + "force", ] + read_only_fields = [ + "id", + "url", + "actor", + "status", + "detail", + "creation_date", + "fetch_date", + ] + + def validate_object(self, value): + # if value is a webginfer lookup, we craft a special url + if value.startswith("@"): + value = value.lstrip("@") + validator = validators.EmailValidator() + try: + validator(value) + except validators.ValidationError: + return value + + return "webfinger://{}".format(value) + + def create(self, validated_data): + check_duplicates = not validated_data.get("force", False) + if check_duplicates: + # first we check for duplicates + duplicate = ( + validated_data["actor"] + .fetches.filter( + status="finished", + url=validated_data["object"], + creation_date__gte=timezone.now() + - datetime.timedelta( + seconds=settings.FEDERATION_DUPLICATE_FETCH_DELAY + ), + ) + .order_by("-creation_date") + .first() + ) + if duplicate: + return duplicate + + fetch = models.Fetch.objects.create( + actor=validated_data["actor"], url=validated_data["object"] + ) + return fetch + + def to_representation(self, obj): + repr = super().to_representation(obj) + object_data = None + if obj.object: + object_data = FETCH_OBJECT_FIELD.to_representation(obj.object) + repr["object"] = object_data + return repr class FullActorSerializer(serializers.Serializer): diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 7a39218f9..db06e3197 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -1,5 +1,6 @@ import requests.exceptions +from django.conf import settings from django.db import transaction from django.db.models import Count @@ -10,6 +11,7 @@ from rest_framework import response from rest_framework import viewsets from funkwhale_api.common import preferences +from funkwhale_api.common import utils as common_utils from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views @@ -22,6 +24,7 @@ from . import filters from . import models from . import routes from . import serializers +from . import tasks from . import utils @@ -195,11 +198,28 @@ class InboxItemViewSet( return response.Response(result, status=200) -class FetchViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): +class FetchViewSet( + mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): queryset = models.Fetch.objects.select_related("actor") serializer_class = api_serializers.FetchSerializer permission_classes = [permissions.IsAuthenticated] + throttling_scopes = {"create": {"authenticated": "fetch"}} + + def get_queryset(self): + return super().get_queryset().filter(actor=self.request.user.actor) + + def perform_create(self, serializer): + fetch = serializer.save(actor=self.request.user.actor) + if fetch.status == "finished": + # a duplicate was returned, no need to fetch again + return + if settings.FEDERATION_SYNCHRONOUS_FETCH: + tasks.fetch(fetch_id=fetch.pk) + fetch.refresh_from_db() + else: + common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk) class DomainViewSet( diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 4f12729fc..e91d8dac9 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -21,7 +21,7 @@ class SignatureAuthFactory(factory.Factory): key = factory.LazyFunction(lambda: keys.get_key_pair()[0]) key_id = factory.Faker("url") use_auth_header = False - headers = ["(request-target)", "user-agent", "host", "date", "content-type"] + headers = ["(request-target)", "user-agent", "host", "date", "accept"] class Meta: model = requests_http_signature.HTTPSignatureAuth @@ -42,7 +42,7 @@ class SignedRequestFactory(factory.Factory): "User-Agent": "Test", "Host": "test.host", "Date": http_date(timezone.now().timestamp()), - "Content-Type": "application/activity+json", + "Accept": "application/activity+json", } if extracted: default_headers.update(extracted) diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py index 1fef43c22..e2f383fc7 100644 --- a/api/funkwhale_api/federation/library.py +++ b/api/funkwhale_api/federation/library.py @@ -9,9 +9,7 @@ def get_library_data(library_url, actor): auth = signing.get_auth(actor.private_key, actor.private_key_id) try: response = session.get_session().get( - library_url, - auth=auth, - headers={"Content-Type": "application/activity+json"}, + library_url, auth=auth, headers={"Accept": "application/activity+json"}, ) except requests.ConnectionError: return {"errors": ["This library is not reachable"]} @@ -32,7 +30,7 @@ def get_library_data(library_url, actor): def get_library_page(library, page_url, actor): auth = signing.get_auth(actor.private_key, actor.private_key_id) response = session.get_session().get( - page_url, auth=auth, headers={"Content-Type": "application/activity+json"}, + page_url, auth=auth, headers={"Accept": "application/activity+json"}, ) serializer = serializers.CollectionPageSerializer( data=response.json(), diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 820c93bae..9514f203a 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -372,7 +372,7 @@ class Fetch(models.Model): objects = FetchQuerySet.as_manager() def save(self, **kwargs): - if not self.url and self.object: + if not self.url and self.object and hasattr(self.object, "fid"): self.url = self.object.fid super().save(**kwargs) @@ -388,6 +388,11 @@ class Fetch(models.Model): contexts.FW.Track: serializers.TrackSerializer, contexts.AS.Audio: serializers.UploadSerializer, contexts.FW.Library: serializers.LibrarySerializer, + contexts.AS.Group: serializers.ActorSerializer, + contexts.AS.Person: serializers.ActorSerializer, + contexts.AS.Organization: serializers.ActorSerializer, + contexts.AS.Service: serializers.ActorSerializer, + contexts.AS.Application: serializers.ActorSerializer, } @@ -568,7 +573,7 @@ class LibraryTrack(models.Model): auth=auth, stream=True, timeout=20, - headers={"Content-Type": "application/activity+json"}, + headers={"Accept": "application/activity+json"}, ) with remote_response as r: remote_response.raise_for_status() diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 2e769f38a..2adbcbec4 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -151,6 +151,10 @@ class ActorSerializer(jsonld.JsonLdSerializer): ) class Meta: + # not strictly necessary because it's not a model serializer + # but used by tasks.py/fetch + model = models.Actor + jsonld_mapping = { "outbox": jsonld.first_id(contexts.AS.outbox), "inbox": jsonld.first_id(contexts.LDP.inbox), @@ -765,6 +769,10 @@ class LibrarySerializer(PaginatedCollectionSerializer): ) class Meta: + # not strictly necessary because it's not a model serializer + # but used by tasks.py/fetch + model = music_models.Library + jsonld_mapping = common_utils.concat_dicts( PAGINATED_COLLECTION_JSONLD_MAPPING, { @@ -795,12 +803,15 @@ class LibrarySerializer(PaginatedCollectionSerializer): return r def create(self, validated_data): - actor = utils.retrieve_ap_object( - validated_data["attributedTo"], - actor=self.context.get("fetch_actor"), - queryset=models.Actor, - serializer_class=ActorSerializer, - ) + if self.instance: + actor = self.instance.actor + else: + actor = utils.retrieve_ap_object( + validated_data["attributedTo"], + actor=self.context.get("fetch_actor"), + queryset=models.Actor, + serializer_class=ActorSerializer, + ) privacy = {"": "me", "./": "me", None: "me", contexts.AS.Public: "everyone"} library, created = music_models.Library.objects.update_or_create( fid=validated_data["id"], @@ -815,6 +826,9 @@ class LibrarySerializer(PaginatedCollectionSerializer): ) return library + def update(self, instance, validated_data): + return self.create(validated_data) + class CollectionPageSerializer(jsonld.JsonLdSerializer): type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage]) @@ -968,8 +982,13 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): allow_null=True, ) - @transaction.atomic def update(self, instance, validated_data): + return self.update_or_create(validated_data) + + @transaction.atomic + def update_or_create(self, validated_data): + instance = self.instance or self.Meta.model(fid=validated_data["id"]) + creating = instance.pk is None attributed_to_fid = validated_data.get("attributedTo") if attributed_to_fid: validated_data["attributedTo"] = actors.get_actor(attributed_to_fid) @@ -977,8 +996,11 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): self.updateable_fields, validated_data, instance ) updated_fields = self.validate_updated_data(instance, updated_fields) - - if updated_fields: + if creating: + instance, created = self.Meta.model.objects.get_or_create( + fid=validated_data["id"], defaults=updated_fields + ) + else: music_tasks.update_library_entity(instance, updated_fields) tags = [t["name"] for t in validated_data.get("tags", []) or []] @@ -1064,6 +1086,8 @@ class ArtistSerializer(MusicEntitySerializer): d["@context"] = jsonld.get_default_context() return d + create = MusicEntitySerializer.update_or_create + class AlbumSerializer(MusicEntitySerializer): released = serializers.DateField(allow_null=True, required=False) @@ -1074,10 +1098,11 @@ class AlbumSerializer(MusicEntitySerializer): ) updateable_fields = [ ("name", "title"), + ("cover", "attachment_cover"), ("musicbrainzId", "mbid"), ("attributedTo", "attributed_to"), ("released", "release_date"), - ("cover", "attachment_cover"), + ("_artist", "artist"), ] class Meta: @@ -1124,6 +1149,20 @@ class AlbumSerializer(MusicEntitySerializer): d["@context"] = jsonld.get_default_context() return d + def validate(self, data): + validated_data = super().validate(data) + if not self.parent: + validated_data["_artist"] = utils.retrieve_ap_object( + validated_data["artists"][0]["id"], + actor=self.context.get("fetch_actor"), + queryset=music_models.Artist, + serializer_class=ArtistSerializer, + ) + + return validated_data + + create = MusicEntitySerializer.update_or_create + class TrackSerializer(MusicEntitySerializer): position = serializers.IntegerField(min_value=0, allow_null=True, required=False) @@ -1293,39 +1332,66 @@ class UploadSerializer(jsonld.JsonLdSerializer): return lb actor = self.context.get("actor") - kwargs = {} - if actor: - kwargs["actor"] = actor + try: - return music_models.Library.objects.get(fid=v, **kwargs) - except music_models.Library.DoesNotExist: + library = utils.retrieve_ap_object( + v, + actor=self.context.get("fetch_actor"), + queryset=music_models.Library, + serializer_class=LibrarySerializer, + ) + except Exception: raise serializers.ValidationError("Invalid library") + if actor and library.actor != actor: + raise serializers.ValidationError("Invalid library") + return library + def update(self, instance, validated_data): + return self.create(validated_data) + + @transaction.atomic def create(self, validated_data): - try: - return music_models.Upload.objects.get(fid=validated_data["id"]) - except music_models.Upload.DoesNotExist: - pass + instance = self.instance or None + if not self.instance: + try: + instance = music_models.Upload.objects.get(fid=validated_data["id"]) + except music_models.Upload.DoesNotExist: + pass - track = TrackSerializer( - context={"activity": self.context.get("activity")} - ).create(validated_data["track"]) + if instance: + data = { + "mimetype": validated_data["url"]["mediaType"], + "source": validated_data["url"]["href"], + "creation_date": validated_data["published"], + "modification_date": validated_data.get("updated"), + "duration": validated_data["duration"], + "size": validated_data["size"], + "bitrate": validated_data["bitrate"], + "import_status": "finished", + } + return music_models.Upload.objects.update_or_create( + fid=validated_data["id"], defaults=data + )[0] + else: + track = TrackSerializer( + context={"activity": self.context.get("activity")} + ).create(validated_data["track"]) - data = { - "fid": validated_data["id"], - "mimetype": validated_data["url"]["mediaType"], - "source": validated_data["url"]["href"], - "creation_date": validated_data["published"], - "modification_date": validated_data.get("updated"), - "track": track, - "duration": validated_data["duration"], - "size": validated_data["size"], - "bitrate": validated_data["bitrate"], - "library": validated_data["library"], - "from_activity": self.context.get("activity"), - "import_status": "finished", - } - return music_models.Upload.objects.create(**data) + data = { + "fid": validated_data["id"], + "mimetype": validated_data["url"]["mediaType"], + "source": validated_data["url"]["href"], + "creation_date": validated_data["published"], + "modification_date": validated_data.get("updated"), + "track": track, + "duration": validated_data["duration"], + "size": validated_data["size"], + "bitrate": validated_data["bitrate"], + "library": validated_data["library"], + "from_activity": self.context.get("activity"), + "import_status": "finished", + } + return music_models.Upload.objects.create(**data) def to_representation(self, instance): track = instance.track diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py index 6a03438c4..8cd0c0439 100644 --- a/api/funkwhale_api/federation/tasks.py +++ b/api/funkwhale_api/federation/tasks.py @@ -14,6 +14,7 @@ from requests.exceptions import RequestException from funkwhale_api.common import preferences from funkwhale_api.common import session from funkwhale_api.common import utils as common_utils +from funkwhale_api.moderation import mrf from funkwhale_api.music import models as music_models from funkwhale_api.taskapp import celery @@ -24,6 +25,7 @@ from . import models, signing from . import serializers from . import routes from . import utils +from . import webfinger logger = logging.getLogger(__name__) @@ -285,24 +287,45 @@ def rotate_actor_key(actor): @celery.app.task(name="federation.fetch") @transaction.atomic @celery.require_instance( - models.Fetch.objects.filter(status="pending").select_related("actor"), "fetch" + models.Fetch.objects.filter(status="pending").select_related("actor"), + "fetch_obj", + "fetch_id", ) -def fetch(fetch): - actor = fetch.actor - auth = signing.get_auth(actor.private_key, actor.private_key_id) - +def fetch(fetch_obj): def error(code, **kwargs): - fetch.status = "errored" - fetch.fetch_date = timezone.now() - fetch.detail = {"error_code": code} - fetch.detail.update(kwargs) - fetch.save(update_fields=["fetch_date", "status", "detail"]) + fetch_obj.status = "errored" + fetch_obj.fetch_date = timezone.now() + fetch_obj.detail = {"error_code": code} + fetch_obj.detail.update(kwargs) + fetch_obj.save(update_fields=["fetch_date", "status", "detail"]) + url = fetch_obj.url + mrf_check_url = url + if not mrf_check_url.startswith("webfinger://"): + payload, updated = mrf.inbox.apply({"id": mrf_check_url}) + if not payload: + return error("blocked", message="Blocked by MRF") + + actor = fetch_obj.actor + if settings.FEDERATION_AUTHENTIFY_FETCHES: + auth = signing.get_auth(actor.private_key, actor.private_key_id) + else: + auth = None try: + if url.startswith("webfinger://"): + # we first grab the correpsonding webfinger representation + # to get the ActivityPub actor ID + webfinger_data = webfinger.get_resource( + "acct:" + url.replace("webfinger://", "") + ) + url = webfinger.get_ap_url(webfinger_data["links"]) + if not url: + return error("webfinger", message="Invalid or missing webfinger data") + payload, updated = mrf.inbox.apply({"id": url}) + if not payload: + return error("blocked", message="Blocked by MRF") response = session.get_session().get( - auth=auth, - url=fetch.url, - headers={"Content-Type": "application/activity+json"}, + auth=auth, url=url, headers={"Accept": "application/activity+json"}, ) logger.debug("Remote answered with %s", response.status_code) response.raise_for_status() @@ -320,8 +343,19 @@ def fetch(fetch): try: payload = response.json() except json.decoder.JSONDecodeError: + # we attempt to extract a that points + # to an activity pub resource, if possible, and retry with this URL + alternate_url = utils.find_alternate(response.text) + if alternate_url: + fetch_obj.url = alternate_url + fetch_obj.save(update_fields=["url"]) + return fetch(fetch_id=fetch_obj.pk) return error("invalid_json") + payload, updated = mrf.inbox.apply(payload) + if not payload: + return error("blocked", message="Blocked by MRF") + try: doc = jsonld.expand(payload) except ValueError: @@ -332,13 +366,13 @@ def fetch(fetch): except IndexError: return error("missing_jsonld_type") try: - serializer_class = fetch.serializers[type] + serializer_class = fetch_obj.serializers[type] model = serializer_class.Meta.model except (KeyError, AttributeError): - fetch.status = "skipped" - fetch.fetch_date = timezone.now() - fetch.detail = {"reason": "unhandled_type", "type": type} - return fetch.save(update_fields=["fetch_date", "status", "detail"]) + fetch_obj.status = "skipped" + fetch_obj.fetch_date = timezone.now() + fetch_obj.detail = {"reason": "unhandled_type", "type": type} + return fetch_obj.save(update_fields=["fetch_date", "status", "detail"]) try: id = doc.get("@id") except IndexError: @@ -350,11 +384,14 @@ def fetch(fetch): if not serializer.is_valid(): return error("validation", validation_errors=serializer.errors) try: - serializer.save() + obj = serializer.save() except Exception as e: error("save", message=str(e)) raise - fetch.status = "finished" - fetch.fetch_date = timezone.now() - return fetch.save(update_fields=["fetch_date", "status"]) + fetch_obj.object = obj + fetch_obj.status = "finished" + fetch_obj.fetch_date = timezone.now() + return fetch_obj.save( + update_fields=["fetch_date", "status", "object_id", "object_content_type"] + ) diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index 59b63e2ce..cab3baf6d 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -1,3 +1,4 @@ +import html.parser import unicodedata import re from django.conf import settings @@ -164,3 +165,39 @@ def get_actor_from_username_data_query(field, data): "domain__name__iexact": data["domain"], } ) + + +class StopParsing(Exception): + pass + + +class AlternateLinkParser(html.parser.HTMLParser): + def __init__(self, *args, **kwargs): + self.result = None + super().__init__(*args, **kwargs) + + def handle_starttag(self, tag, attrs): + if tag != "link": + return + + attrs_dict = dict(attrs) + if attrs_dict.get("rel") == "alternate" and attrs_dict.get( + "type", "application/activity+json" + ): + self.result = attrs_dict.get("href") + raise StopParsing() + + def handle_endtag(self, tag): + if tag == "head": + raise StopParsing() + + +def find_alternate(response_text): + if not response_text: + return + + parser = AlternateLinkParser() + try: + parser.feed(response_text) + except StopParsing: + return parser.result diff --git a/api/funkwhale_api/federation/webfinger.py b/api/funkwhale_api/federation/webfinger.py index 765c5e535..6b735f4f6 100644 --- a/api/funkwhale_api/federation/webfinger.py +++ b/api/funkwhale_api/federation/webfinger.py @@ -46,3 +46,12 @@ def get_resource(resource_string): serializer = serializers.ActorWebfingerSerializer(data=response.json()) serializer.is_valid(raise_exception=True) return serializer.validated_data + + +def get_ap_url(links): + for link in links: + if ( + link.get("rel") == "self" + and link.get("type") == "application/activity+json" + ): + return link["href"] diff --git a/api/funkwhale_api/moderation/management/commands/mrf_check.py b/api/funkwhale_api/moderation/management/commands/mrf_check.py index 6462bd9a0..3a8289f8f 100644 --- a/api/funkwhale_api/moderation/management/commands/mrf_check.py +++ b/api/funkwhale_api/moderation/management/commands/mrf_check.py @@ -82,7 +82,7 @@ class Command(BaseCommand): content = models.Activity.objects.get(uuid=input).payload elif is_url(input): response = session.get_session().get( - input, headers={"Content-Type": "application/activity+json"}, + input, headers={"Accept": "application/activity+json"}, ) response.raise_for_status() content = response.json() diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 7099231ba..6ccddb056 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -324,6 +324,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer): class LibraryForOwnerSerializer(serializers.ModelSerializer): uploads_count = serializers.SerializerMethodField() size = serializers.SerializerMethodField() + actor = serializers.SerializerMethodField() class Meta: model = models.Library @@ -336,6 +337,7 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer): "uploads_count", "size", "creation_date", + "actor", ] read_only_fields = ["fid", "uuid", "creation_date", "actor"] @@ -350,6 +352,12 @@ class LibraryForOwnerSerializer(serializers.ModelSerializer): {"type": "Update", "object": {"type": "Library"}}, context={"library": obj} ) + def get_actor(self, o): + # Import at runtime to avoid a circular import issue + from funkwhale_api.federation import serializers as federation_serializers + + return federation_serializers.APIActorSerializer(o.actor).data + class UploadSerializer(serializers.ModelSerializer): track = TrackSerializer(required=False, allow_null=True) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 47aa60b89..df82090bc 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -249,6 +249,7 @@ class LibraryViewSet( queryset = ( models.Library.objects.all() .filter(channel=None) + .select_related("actor") .order_by("-creation_date") .annotate(_uploads_count=Count("uploads")) .annotate(_size=Sum("uploads__size")) @@ -261,11 +262,15 @@ class LibraryViewSet( required_scope = "libraries" anonymous_policy = "setting" owner_field = "actor.user" - owner_checks = ["read", "write"] + owner_checks = ["write"] def get_queryset(self): qs = super().get_queryset() - return qs.filter(actor=self.request.user.actor) + # allow retrieving a single library by uuid if request.user isn't + # the owner. Any other get should be from the owner only + if self.action != "retrieve": + qs = qs.filter(actor=self.request.user.actor) + return qs def perform_create(self, serializer): serializer.save(actor=self.request.user.actor) @@ -599,7 +604,7 @@ class UploadViewSet( models.Upload.objects.all() .order_by("-creation_date") .prefetch_related( - "library", + "library__actor", "track__artist", "track__album__artist", "track__attachment_cover", @@ -613,7 +618,7 @@ class UploadViewSet( required_scope = "libraries" anonymous_policy = "setting" owner_field = "library.actor.user" - owner_checks = ["read", "write"] + owner_checks = ["write"] filterset_class = filters.UploadFilter ordering_fields = ( "creation_date", @@ -628,7 +633,12 @@ class UploadViewSet( if self.action in ["update", "partial_update"]: # prevent updating an upload that is already processed qs = qs.filter(import_status="draft") - return qs.filter(library__actor=self.request.user.actor) + if self.action != "retrieve": + qs = qs.filter(library__actor=self.request.user.actor) + else: + actor = utils.get_actor_from_request(self.request) + qs = qs.playable_by(actor) + return qs @action(methods=["get"], detail=True, url_path="audio-file-metadata") def audio_file_metadata(self, request, *args, **kwargs): diff --git a/api/requirements/local.txt b/api/requirements/local.txt index 3520b2e67..629b272bb 100644 --- a/api/requirements/local.txt +++ b/api/requirements/local.txt @@ -5,7 +5,7 @@ django_coverage_plugin>=1.6,<1.7 factory_boy>=2.11.1 # django-debug-toolbar that works with Django 1.5+ -django-debug-toolbar>=1.11,<1.12 +django-debug-toolbar>=2.2,<2.3 # improved REPL ipdb==0.11 diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index 5ac4d278b..b914bc67f 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -141,3 +141,65 @@ def test_api_full_actor_serializer(factories, to_api_date): serializer = api_serializers.FullActorSerializer(actor) assert serializer.data == expected + + +def test_fetch_serializer_no_obj(factories, to_api_date): + fetch = factories["federation.Fetch"]() + expected = { + "id": fetch.pk, + "url": fetch.url, + "creation_date": to_api_date(fetch.creation_date), + "fetch_date": None, + "status": fetch.status, + "detail": fetch.detail, + "object": None, + "actor": serializers.APIActorSerializer(fetch.actor).data, + } + + assert api_serializers.FetchSerializer(fetch).data == expected + + +@pytest.mark.parametrize( + "object_factory, expected_type, expected_id", + [ + ("music.Album", "album", "id"), + ("music.Artist", "artist", "id"), + ("music.Track", "track", "id"), + ("music.Library", "library", "uuid"), + ("music.Upload", "upload", "uuid"), + ("federation.Actor", "account", "full_username"), + ], +) +def test_fetch_serializer_with_object( + object_factory, expected_type, expected_id, factories, to_api_date +): + obj = factories[object_factory]() + fetch = factories["federation.Fetch"](object=obj) + expected = { + "id": fetch.pk, + "url": fetch.url, + "creation_date": to_api_date(fetch.creation_date), + "fetch_date": None, + "status": fetch.status, + "detail": fetch.detail, + "object": {"type": expected_type, expected_id: getattr(obj, expected_id)}, + "actor": serializers.APIActorSerializer(fetch.actor).data, + } + + assert api_serializers.FetchSerializer(fetch).data == expected + + +def test_fetch_serializer_unhandled_obj(factories, to_api_date): + fetch = factories["federation.Fetch"](object=factories["users.User"]()) + expected = { + "id": fetch.pk, + "url": fetch.url, + "creation_date": to_api_date(fetch.creation_date), + "fetch_date": None, + "status": fetch.status, + "detail": fetch.detail, + "object": None, + "actor": serializers.APIActorSerializer(fetch.actor).data, + } + + assert api_serializers.FetchSerializer(fetch).data == expected diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index 73dd6b80a..ab74689bf 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -1,9 +1,12 @@ +import datetime + import pytest from django.urls import reverse from funkwhale_api.federation import api_serializers from funkwhale_api.federation import serializers +from funkwhale_api.federation import tasks from funkwhale_api.federation import views @@ -170,7 +173,8 @@ def test_user_can_update_read_status_of_inbox_item(factories, logged_in_api_clie def test_can_detail_fetch(logged_in_api_client, factories): - fetch = factories["federation.Fetch"](url="http://test.object") + actor = logged_in_api_client.user.create_actor() + fetch = factories["federation.Fetch"](url="http://test.object", actor=actor) url = reverse("api:v1:federation:fetches-detail", kwargs={"pk": fetch.pk}) response = logged_in_api_client.get(url) @@ -209,3 +213,76 @@ def test_can_retrieve_actor(factories, api_client, preferences): expected = api_serializers.FullActorSerializer(actor).data assert response.data == expected + + +@pytest.mark.parametrize( + "object_id, expected_url", + [ + ("https://fetch.url", "https://fetch.url"), + ("name@domain.tld", "webfinger://name@domain.tld"), + ("@name@domain.tld", "webfinger://name@domain.tld"), + ], +) +def test_can_fetch_using_url_synchronous( + object_id, expected_url, factories, logged_in_api_client, mocker, settings +): + settings.FEDERATION_SYNCHRONOUS_FETCH = True + actor = logged_in_api_client.user.create_actor() + + def fake_task(fetch_id): + actor.fetches.filter(id=fetch_id).update(status="finished") + + fetch_task = mocker.patch.object(tasks, "fetch", side_effect=fake_task) + + url = reverse("api:v1:federation:fetches-list") + data = {"object": object_id} + response = logged_in_api_client.post(url, data) + assert response.status_code == 201 + + fetch = actor.fetches.latest("id") + + assert fetch.status == "finished" + assert fetch.url == expected_url + assert response.data == api_serializers.FetchSerializer(fetch).data + fetch_task.assert_called_once_with(fetch_id=fetch.pk) + + +def test_fetch_duplicate(factories, logged_in_api_client, settings, now): + object_id = "http://example.test" + settings.FEDERATION_DUPLICATE_FETCH_DELAY = 60 + actor = logged_in_api_client.user.create_actor() + duplicate = factories["federation.Fetch"]( + actor=actor, + status="finished", + url=object_id, + creation_date=now - datetime.timedelta(seconds=59), + ) + url = reverse("api:v1:federation:fetches-list") + data = {"object": object_id} + response = logged_in_api_client.post(url, data) + assert response.status_code == 201 + assert response.data == api_serializers.FetchSerializer(duplicate).data + + +def test_fetch_duplicate_bypass_with_force( + factories, logged_in_api_client, mocker, settings, now +): + fetch_task = mocker.patch.object(tasks, "fetch") + object_id = "http://example.test" + settings.FEDERATION_DUPLICATE_FETCH_DELAY = 60 + actor = logged_in_api_client.user.create_actor() + duplicate = factories["federation.Fetch"]( + actor=actor, + status="finished", + url=object_id, + creation_date=now - datetime.timedelta(seconds=59), + ) + url = reverse("api:v1:federation:fetches-list") + data = {"object": object_id, "force": True} + response = logged_in_api_client.post(url, data) + + fetch = actor.fetches.latest("id") + assert fetch != duplicate + assert response.status_code == 201 + assert response.data == api_serializers.FetchSerializer(fetch).data + fetch_task.assert_called_once_with(fetch_id=fetch.pk) diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 1d0ba37d9..f2fd68eb3 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -580,6 +580,37 @@ def test_music_library_serializer_from_private(factories, mocker): ) +def test_music_library_serializer_from_ap_update(factories, mocker): + actor = factories["federation.Actor"]() + library = factories["music.Library"]() + + data = { + "@context": jsonld.get_default_context(), + "audience": "https://www.w3.org/ns/activitystreams#Public", + "name": "Hello", + "summary": "World", + "type": "Library", + "id": library.fid, + "followers": "https://library.id/followers", + "attributedTo": actor.fid, + "totalItems": 12, + "first": "https://library.id?page=1", + "last": "https://library.id?page=2", + } + serializer = serializers.LibrarySerializer(library, data=data) + + assert serializer.is_valid(raise_exception=True) + + serializer.save() + library.refresh_from_db() + + assert library.uploads_count == data["totalItems"] + assert library.privacy_level == "everyone" + assert library.name == "Hello" + assert library.description == "World" + assert library.followers_url == data["followers"] + + def test_activity_pub_artist_serializer_to_ap(factories): content = factories["common.Content"]() artist = factories["music.Artist"]( @@ -610,6 +641,86 @@ def test_activity_pub_artist_serializer_to_ap(factories): assert serializer.data == expected +def test_activity_pub_artist_serializer_from_ap_create(factories, faker, now, mocker): + actor = factories["federation.Actor"]() + mocker.patch( + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor + ) + payload = { + "@context": jsonld.get_default_context(), + "type": "Artist", + "id": "https://test.artist", + "name": "Art", + "musicbrainzId": faker.uuid4(), + "published": now.isoformat(), + "attributedTo": actor.fid, + "content": "Summary", + "image": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://attachment.file", + }, + "tag": [ + {"type": "Hashtag", "name": "#Punk"}, + {"type": "Hashtag", "name": "#Rock"}, + ], + } + serializer = serializers.ArtistSerializer(data=payload) + assert serializer.is_valid(raise_exception=True) is True + + artist = serializer.save() + + assert artist.fid == payload["id"] + assert artist.attributed_to == actor + assert artist.name == payload["name"] + assert str(artist.mbid) == payload["musicbrainzId"] + assert artist.description.text == payload["content"] + assert artist.description.content_type == "text/html" + assert artist.attachment_cover.url == payload["image"]["url"] + assert artist.attachment_cover.mimetype == payload["image"]["mediaType"] + assert artist.get_tags() == ["Punk", "Rock"] + + +def test_activity_pub_artist_serializer_from_ap_update(factories, faker, now, mocker): + artist = factories["music.Artist"]() + actor = factories["federation.Actor"]() + mocker.patch( + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor + ) + payload = { + "@context": jsonld.get_default_context(), + "type": "Artist", + "id": artist.fid, + "name": "Art", + "musicbrainzId": faker.uuid4(), + "published": now.isoformat(), + "attributedTo": actor.fid, + "content": "Summary", + "image": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://attachment.file", + }, + "tag": [ + {"type": "Hashtag", "name": "#Punk"}, + {"type": "Hashtag", "name": "#Rock"}, + ], + } + serializer = serializers.ArtistSerializer(artist, data=payload) + assert serializer.is_valid(raise_exception=True) is True + serializer.save() + artist.refresh_from_db() + + assert artist.attributed_to == actor + assert artist.name == payload["name"] + assert str(artist.mbid) == payload["musicbrainzId"] + assert artist.description.text == payload["content"] + assert artist.description.content_type == "text/html" + assert artist.attachment_cover.url == payload["image"]["url"] + assert artist.attachment_cover.mimetype == payload["image"]["mediaType"] + assert artist.get_tags() == ["Punk", "Rock"] + + def test_activity_pub_album_serializer_to_ap(factories): content = factories["common.Content"]() album = factories["music.Album"]( @@ -652,39 +763,42 @@ def test_activity_pub_album_serializer_to_ap(factories): assert serializer.data == expected -def test_activity_pub_artist_serializer_from_ap_update(factories, faker): - artist = factories["music.Artist"](attributed=True) +def test_activity_pub_album_serializer_from_ap_create(factories, faker, now): + actor = factories["federation.Actor"]() + artist = factories["music.Artist"]() + released = faker.date_object() payload = { "@context": jsonld.get_default_context(), - "type": "Artist", - "id": artist.fid, + "type": "Album", + "id": "https://album.example", "name": faker.sentence(), + "cover": {"type": "Link", "mediaType": "image/jpeg", "href": faker.url()}, "musicbrainzId": faker.uuid4(), - "published": artist.creation_date.isoformat(), - "attributedTo": artist.attributed_to.fid, - "mediaType": "text/html", - "content": common_utils.render_html(faker.sentence(), "text/html"), - "image": {"type": "Image", "mediaType": "image/jpeg", "url": faker.url()}, + "published": now.isoformat(), + "released": released.isoformat(), + "artists": [ + serializers.ArtistSerializer( + artist, context={"include_ap_context": False} + ).data + ], + "attributedTo": actor.fid, "tag": [ {"type": "Hashtag", "name": "#Punk"}, {"type": "Hashtag", "name": "#Rock"}, ], } - - serializer = serializers.ArtistSerializer(artist, data=payload) + serializer = serializers.AlbumSerializer(data=payload) assert serializer.is_valid(raise_exception=True) is True - serializer.save() + album = serializer.save() - artist.refresh_from_db() - - assert artist.name == payload["name"] - assert str(artist.mbid) == payload["musicbrainzId"] - assert artist.attachment_cover.url == payload["image"]["url"] - assert artist.attachment_cover.mimetype == payload["image"]["mediaType"] - assert artist.description.text == payload["content"] - assert artist.description.content_type == "text/html" - assert sorted(artist.tagged_items.values_list("tag__name", flat=True)) == [ + assert album.title == payload["name"] + assert str(album.mbid) == payload["musicbrainzId"] + assert album.release_date == released + assert album.artist == artist + assert album.attachment_cover.url == payload["cover"]["href"] + assert album.attachment_cover.mimetype == payload["cover"]["mediaType"] + assert sorted(album.tagged_items.values_list("tag__name", flat=True)) == [ "Punk", "Rock", ] @@ -1062,6 +1176,43 @@ def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock): assert upload.modification_date == updated +def test_activity_pub_upload_serializer_from_ap_update(factories, mocker, now, r_mock): + library = factories["music.Library"]() + upload = factories["music.Upload"](library=library) + + data = { + "@context": jsonld.get_default_context(), + "type": "Audio", + "id": upload.fid, + "name": "Ignored", + "published": now.isoformat(), + "updated": now.isoformat(), + "duration": 42, + "bitrate": 42, + "size": 66, + "url": { + "href": "https://audio.file/url", + "type": "Link", + "mediaType": "audio/mp3", + }, + "library": library.fid, + "track": serializers.TrackSerializer(upload.track).data, + } + r_mock.get(data["track"]["album"]["cover"]["href"], body=io.BytesIO(b"coucou")) + + serializer = serializers.UploadSerializer(upload, data=data) + assert serializer.is_valid(raise_exception=True) + serializer.save() + upload.refresh_from_db() + + assert upload.fid == data["id"] + assert upload.duration == data["duration"] + assert upload.size == data["size"] + assert upload.bitrate == data["bitrate"] + assert upload.source == data["url"]["href"] + assert upload.mimetype == data["url"]["mediaType"] + + def test_activity_pub_upload_serializer_validtes_library_actor(factories, mocker): library = factories["music.Library"]() usurpator = factories["federation.Actor"]() @@ -1201,7 +1352,7 @@ def test_track_serializer_update_license(factories): obj = factories["music.Track"](license=None) - serializer = serializers.TrackSerializer() + serializer = serializers.TrackSerializer(obj) serializer.update(obj, {"license": "http://creativecommons.org/licenses/by/2.0/"}) obj.refresh_from_db() diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py index 7c29d4698..de90f2886 100644 --- a/api/tests/federation/test_tasks.py +++ b/api/tests/federation/test_tasks.py @@ -395,3 +395,156 @@ def test_fetch_success(factories, r_mock, mocker): assert init.call_args[0][1] == artist assert init.call_args[1]["data"] == payload assert save.call_count == 1 + + +def test_fetch_webfinger(factories, r_mock, mocker): + actor = factories["federation.Actor"]() + fetch = factories["federation.Fetch"]( + url="webfinger://{}".format(actor.full_username) + ) + payload = serializers.ActorSerializer(actor).data + init = mocker.spy(serializers.ActorSerializer, "__init__") + save = mocker.spy(serializers.ActorSerializer, "save") + webfinger_payload = { + "subject": "acct:{}".format(actor.full_username), + "aliases": ["https://test.webfinger"], + "links": [ + {"rel": "self", "type": "application/activity+json", "href": actor.fid} + ], + } + webfinger_url = "https://{}/.well-known/webfinger?resource={}".format( + actor.domain_id, webfinger_payload["subject"] + ) + r_mock.get(actor.fid, json=payload) + r_mock.get(webfinger_url, json=webfinger_payload) + + tasks.fetch(fetch_id=fetch.pk) + + fetch.refresh_from_db() + payload["@context"].append("https://funkwhale.audio/ns") + assert fetch.status == "finished" + assert fetch.object == actor + assert init.call_count == 1 + assert init.call_args[0][1] == actor + assert init.call_args[1]["data"] == payload + assert save.call_count == 1 + + +def test_fetch_rel_alternate(factories, r_mock, mocker): + actor = factories["federation.Actor"]() + fetch = factories["federation.Fetch"](url="http://example.page") + html_text = """ + + + + + + """.format( + actor.fid + ) + ap_payload = serializers.ActorSerializer(actor).data + init = mocker.spy(serializers.ActorSerializer, "__init__") + save = mocker.spy(serializers.ActorSerializer, "save") + r_mock.get(fetch.url, text=html_text) + r_mock.get(actor.fid, json=ap_payload) + + tasks.fetch(fetch_id=fetch.pk) + + fetch.refresh_from_db() + ap_payload["@context"].append("https://funkwhale.audio/ns") + assert fetch.status == "finished" + assert fetch.object == actor + assert init.call_count == 1 + assert init.call_args[0][1] == actor + assert init.call_args[1]["data"] == ap_payload + assert save.call_count == 1 + + +@pytest.mark.parametrize( + "factory_name, serializer_class", + [ + ("federation.Actor", serializers.ActorSerializer), + ("music.Library", serializers.LibrarySerializer), + ("music.Artist", serializers.ArtistSerializer), + ("music.Album", serializers.AlbumSerializer), + ("music.Track", serializers.TrackSerializer), + ], +) +def test_fetch_url(factory_name, serializer_class, factories, r_mock, mocker): + obj = factories[factory_name]() + fetch = factories["federation.Fetch"](url=obj.fid) + payload = serializer_class(obj).data + init = mocker.spy(serializer_class, "__init__") + save = mocker.spy(serializer_class, "save") + + r_mock.get(obj.fid, json=payload) + + tasks.fetch(fetch_id=fetch.pk) + + fetch.refresh_from_db() + payload["@context"].append("https://funkwhale.audio/ns") + assert fetch.status == "finished" + assert fetch.object == obj + assert init.call_count == 1 + assert init.call_args[0][1] == obj + assert init.call_args[1]["data"] == payload + assert save.call_count == 1 + + +def test_fetch_honor_instance_policy_domain(factories): + domain = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True + ).target_domain + fid = "https://{}/test".format(domain.name) + + fetch = factories["federation.Fetch"](url=fid) + tasks.fetch(fetch_id=fetch.pk) + fetch.refresh_from_db() + + assert fetch.status == "errored" + assert fetch.detail["error_code"] == "blocked" + + +def test_fetch_honor_mrf_inbox_before_http(mrf_inbox_registry, factories, mocker): + apply = mocker.patch.object(mrf_inbox_registry, "apply", return_value=(None, False)) + fid = "http://domain/test" + fetch = factories["federation.Fetch"](url=fid) + tasks.fetch(fetch_id=fetch.pk) + fetch.refresh_from_db() + + assert fetch.status == "errored" + assert fetch.detail["error_code"] == "blocked" + apply.assert_called_once_with({"id": fid}) + + +def test_fetch_honor_mrf_inbox_after_http( + r_mock, mrf_inbox_registry, factories, mocker +): + apply = mocker.patch.object( + mrf_inbox_registry, "apply", side_effect=[(True, False), (None, False)] + ) + payload = {"id": "http://domain/test", "actor": "hello"} + r_mock.get(payload["id"], json=payload) + fetch = factories["federation.Fetch"](url=payload["id"]) + tasks.fetch(fetch_id=fetch.pk) + fetch.refresh_from_db() + + assert fetch.status == "errored" + assert fetch.detail["error_code"] == "blocked" + + apply.assert_any_call({"id": payload["id"]}) + apply.assert_any_call(payload) + + +def test_fetch_honor_instance_policy_different_url_and_id(r_mock, factories): + domain = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True + ).target_domain + fid = "https://ok/test" + r_mock.get(fid, json={"id": "http://{}/test".format(domain.name)}) + fetch = factories["federation.Fetch"](url=fid) + tasks.fetch(fetch_id=fetch.pk) + fetch.refresh_from_db() + + assert fetch.status == "errored" + assert fetch.detail["error_code"] == "blocked" diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 74fa3f6f8..87590cb90 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -640,6 +640,16 @@ def test_user_can_list_their_library(factories, logged_in_api_client): assert response.data["results"][0]["uuid"] == str(library.uuid) +def test_user_can_retrieve_another_user_library(factories, logged_in_api_client): + library = factories["music.Library"]() + + url = reverse("api:v1:libraries-detail", kwargs={"uuid": library.uuid}) + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data["uuid"] == str(library.uuid) + + def test_library_list_excludes_channel_library(factories, logged_in_api_client): actor = logged_in_api_client.user.create_actor() factories["audio.Channel"](attributed_to=actor) @@ -670,9 +680,11 @@ def test_library_delete_via_api_triggers_outbox(factories, mocker): ) -def test_user_cannot_get_other_actors_uploads(factories, logged_in_api_client): +def test_user_cannot_get_other_not_playable_uploads(factories, logged_in_api_client): logged_in_api_client.user.create_actor() - upload = factories["music.Upload"]() + upload = factories["music.Upload"]( + import_status="finished", library__privacy_level="private" + ) url = reverse("api:v1:uploads-detail", kwargs={"uuid": upload.uuid}) response = logged_in_api_client.get(url) @@ -680,6 +692,19 @@ def test_user_cannot_get_other_actors_uploads(factories, logged_in_api_client): assert response.status_code == 404 +def test_user_can_get_retrieve_playable_uploads(factories, logged_in_api_client): + logged_in_api_client.user.create_actor() + upload = factories["music.Upload"]( + import_status="finished", library__privacy_level="everyone" + ) + + url = reverse("api:v1:uploads-detail", kwargs={"uuid": upload.uuid}) + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data["uuid"] == str(upload.uuid) + + def test_user_cannot_delete_other_actors_uploads(factories, logged_in_api_client): logged_in_api_client.user.create_actor() upload = factories["music.Upload"]() diff --git a/docs/conf.py b/docs/conf.py index 6b7466d64..64fcfc8e0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,18 +95,16 @@ html_theme = "sphinx_rtd_theme" # further. For a list of options available for each theme, see the # documentation. # -html_theme_options = { - 'gitlab_url': 'https://dev.funkwhale.audio/funkwhale/funkwhale' -} +html_theme_options = {"gitlab_url": "https://dev.funkwhale.audio/funkwhale/funkwhale"} html_context = { - 'display_gitlab': True, - 'gitlab_host': 'dev.funkwhale.audio', - 'gitlab_repo': 'funkwhale', - 'gitlab_user': 'funkwhale', - 'gitlab_version': 'master', - 'conf_py_path': '/docs/', + "display_gitlab": True, + "gitlab_host": "dev.funkwhale.audio", + "gitlab_repo": "funkwhale", + "gitlab_user": "funkwhale", + "gitlab_version": "master", + "conf_py_path": "/docs/", } -html_logo = 'logo.svg' +html_logo = "logo.svg" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -173,15 +171,13 @@ texinfo_documents = [ # Define list of redirect files to be build in the Sphinx build process redirect_files = [ - - ('importing-music.html', 'admin/importing-music.html'), - ('architecture.html', 'developers/architecture.html'), - ('troubleshooting.html', 'admin/troubleshooting.html'), - ('configuration.html', 'admin/configuration.html'), - ('upgrading/index.html', '../admin/upgrading.html'), - ('upgrading/0.17.html', '../admin/0.17.html'), - ('users/django.html', '../admin/django.html'), - + ("importing-music.html", "admin/importing-music.html"), + ("architecture.html", "developers/architecture.html"), + ("troubleshooting.html", "admin/troubleshooting.html"), + ("configuration.html", "admin/configuration.html"), + ("upgrading/index.html", "../admin/upgrading.html"), + ("upgrading/0.17.html", "../admin/0.17.html"), + ("users/django.html", "../admin/django.html"), ] # Generate redirect template @@ -199,16 +195,17 @@ redirect_template = """\ # Tell Sphinx to copy the files + def copy_legacy_redirects(app, docname): - if app.builder.name == 'html': + if app.builder.name == "html": for html_src_path, new in redirect_files: page = redirect_template.format(new=new) - target_path = app.outdir + '/' + html_src_path + target_path = app.outdir + "/" + html_src_path if not os.path.exists(os.path.dirname(target_path)): - os.makedirs(os.path.dirname(target_path)) - with open(target_path, 'w') as f: - f.write(page) + os.makedirs(os.path.dirname(target_path)) + with open(target_path, "w") as f: + f.write(page) def setup(app): - app.connect('build-finished', copy_legacy_redirects) + app.connect("build-finished", copy_legacy_redirects) diff --git a/docs/serve.py b/docs/serve.py index 9a381c74b..28e5020e6 100644 --- a/docs/serve.py +++ b/docs/serve.py @@ -1,13 +1,10 @@ #!/usr/bin/env python from subprocess import call + # initial make call(["python", "-m", "sphinx", ".", "/tmp/_build"]) from livereload import Server, shell server = Server() -server.watch('.', shell('python -m sphinx . /tmp/_build')) -server.serve( - root='/tmp/_build/', - liveport=35730, - port=8001, -host='0.0.0.0') +server.watch(".", shell("python -m sphinx . /tmp/_build")) +server.serve(root="/tmp/_build/", liveport=35730, port=8001, host="0.0.0.0") diff --git a/front/scripts/print-duplicates-source.py b/front/scripts/print-duplicates-source.py index 9d7733b6b..32526c257 100644 --- a/front/scripts/print-duplicates-source.py +++ b/front/scripts/print-duplicates-source.py @@ -9,21 +9,17 @@ def print_duplicates(path): contexts_by_id = collections.defaultdict(list) for e in pofile: contexts_by_id[e.msgid].append(e.msgctxt) - count = collections.Counter( - [e.msgid for e in pofile] - ) - duplicates = [ - (k, v) for k, v in count.items() - if v > 1 - ] + count = collections.Counter([e.msgid for e in pofile]) + duplicates = [(k, v) for k, v in count.items() if v > 1] for k, v in sorted(duplicates, key=lambda r: r[1], reverse=True): - print('{} entries - {}:'.format(v, k)) + print("{} entries - {}:".format(v, k)) for ctx in contexts_by_id[k]: - print(' - {}'.format(ctx)) + print(" - {}".format(ctx)) print() total_duplicates = sum([v - 1 for _, v in duplicates]) - print('{} total duplicates'.format(total_duplicates)) + print("{} total duplicates".format(total_duplicates)) + if __name__ == "__main__": parser = argparse.ArgumentParser() diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index c083d8625..21fdb8499 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -15,6 +15,7 @@ diff --git a/front/src/components/common/ActorAvatar.vue b/front/src/components/common/ActorAvatar.vue index d88dd6321..6137823a3 100644 --- a/front/src/components/common/ActorAvatar.vue +++ b/front/src/components/common/ActorAvatar.vue @@ -1,5 +1,6 @@