Skip to content
Snippets Groups Projects
serializers.py 45.3 KiB
Newer Older
  • Learn to ignore specific revisions
  • import mimetypes
    
    import urllib.parse
    
    from django.core.exceptions import ObjectDoesNotExist
    
    from django.core.paginator import Paginator
    
    from django.db import transaction
    
    
    from rest_framework import serializers
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.common import utils as funkwhale_utils
    
    from funkwhale_api.common import models as common_models
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.music import licenses
    
    from funkwhale_api.music import models as music_models
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.music import tasks as music_tasks
    
    from funkwhale_api.tags import models as tags_models
    
    from . import activity, actors, contexts, jsonld, models, tasks, utils
    
    logger = logging.getLogger(__name__)
    
    
    class LinkSerializer(jsonld.JsonLdSerializer):
        type = serializers.ChoiceField(choices=[contexts.AS.Link])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        href = serializers.URLField(max_length=500)
        mediaType = serializers.CharField()
    
    
        class Meta:
            jsonld_mapping = {
                "href": jsonld.first_id(contexts.AS.href),
                "mediaType": jsonld.first_val(contexts.AS.mediaType),
            }
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def __init__(self, *args, **kwargs):
            self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
            super().__init__(*args, **kwargs)
    
        def validate_mediaType(self, v):
            if not self.allowed_mimetypes:
                # no restrictions
                return v
            for mt in self.allowed_mimetypes:
                if mt.endswith("/*"):
                    if v.startswith(mt.replace("*", "")):
                        return v
                else:
                    if v == mt:
                        return v
            raise serializers.ValidationError(
                "Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
            )
    
    
    
    class EndpointsSerializer(jsonld.JsonLdSerializer):
        sharedInbox = serializers.URLField(max_length=500, required=False)
    
        class Meta:
            jsonld_mapping = {"sharedInbox": jsonld.first_id(contexts.AS.sharedInbox)}
    
    
    class PublicKeySerializer(jsonld.JsonLdSerializer):
        publicKeyPem = serializers.CharField(trim_whitespace=False)
    
        class Meta:
            jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
    
    
    class ActorSerializer(jsonld.JsonLdSerializer):
    
        id = serializers.URLField(max_length=500)
    
        outbox = serializers.URLField(max_length=500, required=False)
        inbox = serializers.URLField(max_length=500, required=False)
    
        type = serializers.ChoiceField(
            choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
        )
    
        preferredUsername = serializers.CharField()
        manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
        name = serializers.CharField(required=False, max_length=200)
    
        summary = serializers.CharField(max_length=None, required=False)
    
        followers = serializers.URLField(max_length=500, required=False)
    
        following = serializers.URLField(max_length=500, required=False, allow_null=True)
    
        publicKey = PublicKeySerializer(required=False)
        endpoints = EndpointsSerializer(required=False)
    
        class Meta:
            jsonld_mapping = {
                "outbox": jsonld.first_id(contexts.AS.outbox),
                "inbox": jsonld.first_id(contexts.LDP.inbox),
                "following": jsonld.first_id(contexts.AS.following),
                "followers": jsonld.first_id(contexts.AS.followers),
                "preferredUsername": jsonld.first_val(contexts.AS.preferredUsername),
                "summary": jsonld.first_val(contexts.AS.summary),
                "name": jsonld.first_val(contexts.AS.name),
                "publicKey": jsonld.first_obj(contexts.SEC.publicKey),
                "manuallyApprovesFollowers": jsonld.first_val(
                    contexts.AS.manuallyApprovesFollowers
                ),
                "mediaType": jsonld.first_val(contexts.AS.mediaType),
                "endpoints": jsonld.first_obj(contexts.AS.endpoints),
            }
    
        def to_representation(self, instance):
    
                "id": instance.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "outbox": instance.outbox_url,
                "inbox": instance.inbox_url,
                "preferredUsername": instance.preferred_username,
                "type": instance.type,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["name"] = instance.name
    
            if instance.followers_url:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["followers"] = instance.followers_url
    
            if instance.following_url:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["following"] = instance.following_url
    
            if instance.summary:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["summary"] = instance.summary
    
            if instance.manually_approves_followers is not None:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["manuallyApprovesFollowers"] = instance.manually_approves_followers
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            ret["@context"] = jsonld.get_default_context()
    
            if instance.public_key:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["publicKey"] = {
    
                    "owner": instance.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "publicKeyPem": instance.public_key,
    
                    "id": "{}#main-key".format(instance.fid),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            ret["endpoints"] = {}
    
            if instance.shared_inbox_url:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
    
            try:
                if instance.user.avatar:
                    ret["icon"] = {
                        "type": "Image",
    
                        "mediaType": mimetypes.guess_type(instance.user.avatar_path)[0],
    
                        "url": utils.full_url(instance.user.avatar.crop["400x400"].url),
                    }
            except ObjectDoesNotExist:
                pass
    
            return ret
    
        def prepare_missing_fields(self):
    
                "fid": self.validated_data["id"],
    
                "outbox_url": self.validated_data.get("outbox"),
                "inbox_url": self.validated_data.get("inbox"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "following_url": self.validated_data.get("following"),
                "followers_url": self.validated_data.get("followers"),
                "summary": self.validated_data.get("summary"),
                "type": self.validated_data["type"],
                "name": self.validated_data.get("name"),
                "preferred_username": self.validated_data["preferredUsername"],
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            maf = self.validated_data.get("manuallyApprovesFollowers")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                kwargs["manually_approves_followers"] = maf
    
            domain = urllib.parse.urlparse(kwargs["fid"]).netloc
    
            domain, domain_created = models.Domain.objects.get_or_create(pk=domain)
            if domain_created and not domain.is_local:
                # first time we see the domain, we trigger nodeinfo fetching
                tasks.update_domain_nodeinfo(domain_name=domain.name)
    
            kwargs["domain"] = domain
    
            for endpoint, url in self.validated_data.get("endpoints", {}).items():
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                if endpoint == "sharedInbox":
                    kwargs["shared_inbox_url"] = url
    
                kwargs["public_key"] = self.validated_data["publicKey"]["publicKeyPem"]
    
            except KeyError:
                pass
            return kwargs
    
    
        def validate_type(self, v):
            return v.split("#")[-1]
    
    
        def build(self):
    
            d = self.prepare_missing_fields()
            return models.Actor(**d)
    
    
        def save(self, **kwargs):
    
            d = self.prepare_missing_fields()
            d.update(kwargs)
    
            return models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
    
        def validate_summary(self, value):
            if value:
                return value[:500]
    
    
    
    class APIActorSerializer(serializers.ModelSerializer):
        class Meta:
            model = models.Actor
            fields = [
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "id",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "url",
                "creation_date",
                "summary",
                "preferred_username",
                "name",
                "last_fetch_date",
                "domain",
                "type",
                "manually_approves_followers",
    
    class BaseActivitySerializer(serializers.Serializer):
        id = serializers.URLField(max_length=500, required=False)
        type = serializers.CharField(max_length=100)
        actor = serializers.URLField(max_length=500)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
        def validate_actor(self, v):
            expected = self.context.get("actor")
            if expected and expected.fid != v:
                raise serializers.ValidationError("Invalid actor")
            if expected:
                # avoid a DB lookup
                return expected
    
                return models.Actor.objects.get(fid=v)
            except models.Actor.DoesNotExist:
                raise serializers.ValidationError("Actor not found")
    
        def create(self, validated_data):
            return models.Activity.objects.create(
                fid=validated_data.get("id"),
                actor=validated_data["actor"],
                payload=self.initial_data,
    
                type=validated_data["type"],
    
        def validate(self, data):
            data["recipients"] = self.validate_recipients(self.initial_data)
            return super().validate(data)
    
        def validate_recipients(self, payload):
            """
            Ensure we have at least a to/cc field with valid actors
            """
            to = payload.get("to", [])
            cc = payload.get("cc", [])
    
            if not to and not cc and not self.context.get("recipients"):
    
                raise serializers.ValidationError(
                    "We cannot handle an activity with no recipient"
    
    class FollowSerializer(serializers.Serializer):
    
        id = serializers.URLField(max_length=500)
        object = serializers.URLField(max_length=500)
        actor = serializers.URLField(max_length=500)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        type = serializers.ChoiceField(choices=["Follow"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            expected = self.context.get("follow_target")
    
            if self.parent:
                # it's probably an accept, so everything is inverted, the actor
                # the recipient does not matter
                recipient = None
            else:
                recipient = self.context.get("recipient")
            if expected and expected.fid != v:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Invalid target")
    
                obj = models.Actor.objects.get(fid=v)
                if recipient and recipient.fid != obj.fid:
                    raise serializers.ValidationError("Invalid target")
                return obj
    
                pass
            try:
                qs = music_models.Library.objects.filter(fid=v)
                if recipient:
                    qs = qs.filter(actor=recipient)
                return qs.get()
            except music_models.Library.DoesNotExist:
                pass
    
            raise serializers.ValidationError("Target not found")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            expected = self.context.get("follow_actor")
    
            if expected and expected.fid != v:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Invalid actor")
    
                return models.Actor.objects.get(fid=v)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Actor not found")
    
            target = self.validated_data["object"]
    
            if target._meta.label == "music.Library":
                follow_class = models.LibraryFollow
            else:
                follow_class = models.Follow
            defaults = kwargs
            defaults["fid"] = self.validated_data["id"]
    
            approved = kwargs.pop("approved", None)
            follow, created = follow_class.objects.update_or_create(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                actor=self.validated_data["actor"],
                target=self.validated_data["object"],
    
            )
            if not created:
                # We likely received a new follow when we had an existing one in database
                # this can happen when two instances are out of sync, e.g because some
                # messages are not delivered properly. In this case, we don't change
                # the follow approved status and return the follow as is.
                # We set a new UUID to ensure the follow urls are updated properly
                # cf #830
                follow.uuid = uuid.uuid4()
                follow.save(update_fields=["uuid"])
                return follow
    
            # it's a brand new follow, we use the approved value stored earlier
            if approved != follow.approved:
                follow.approved = approved
                follow.save(update_fields=["approved"])
    
            return follow
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
        def to_representation(self, instance):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "@context": jsonld.get_default_context(),
    
                "actor": instance.actor.fid,
                "id": instance.get_federation_id(),
                "object": instance.target.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "type": "Follow",
    
    class APIFollowSerializer(serializers.ModelSerializer):
    
        actor = APIActorSerializer()
        target = APIActorSerializer()
    
    
        class Meta:
            model = models.Follow
            fields = [
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "uuid",
                "id",
                "approved",
                "creation_date",
                "modification_date",
                "actor",
                "target",
    
    class AcceptFollowSerializer(serializers.Serializer):
    
        id = serializers.URLField(max_length=500, required=False)
    
        actor = serializers.URLField(max_length=500)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        type = serializers.ChoiceField(choices=["Accept"])
    
            expected = self.context.get("actor")
            if expected and expected.fid != v:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Invalid actor")
    
                return models.Actor.objects.get(fid=v)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Actor not found")
    
            # we ensure the accept actor actually match the follow target / library owner
            target = validated_data["object"]["object"]
    
            if target._meta.label == "music.Library":
                expected = target.actor
                follow_class = models.LibraryFollow
            else:
                expected = target
                follow_class = models.Follow
            if validated_data["actor"] != expected:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Actor mismatch")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                validated_data["follow"] = (
    
                    follow_class.objects.filter(
                        target=target, actor=validated_data["object"]["actor"]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    )
                    .exclude(approved=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    .get()
                )
    
            except follow_class.DoesNotExist:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("No follow to accept")
    
            return validated_data
    
        def to_representation(self, instance):
    
            if instance.target._meta.label == "music.Library":
                actor = instance.target.actor
            else:
                actor = instance.target
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "@context": jsonld.get_default_context(),
    
                "id": instance.get_federation_id() + "/accept",
    
                "actor": actor.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "object": FollowSerializer(instance).data,
    
            follow = self.validated_data["follow"]
            follow.approved = True
            follow.save()
            if follow.target._meta.label == "music.Library":
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                follow.target.schedule_scan(actor=follow.actor)
    
    
    
    class UndoFollowSerializer(serializers.Serializer):
    
        id = serializers.URLField(max_length=500)
        actor = serializers.URLField(max_length=500)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        type = serializers.ChoiceField(choices=["Undo"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            expected = self.context.get("actor")
    
    
            if expected and expected.fid != v:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Invalid actor")
    
                return models.Actor.objects.get(fid=v)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Actor not found")
    
    
        def validate(self, validated_data):
            # we ensure the accept actor actually match the follow actor
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if validated_data["actor"] != validated_data["object"]["actor"]:
                raise serializers.ValidationError("Actor mismatch")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
            target = validated_data["object"]["object"]
    
            if target._meta.label == "music.Library":
                follow_class = models.LibraryFollow
            else:
                follow_class = models.Follow
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                validated_data["follow"] = follow_class.objects.filter(
                    actor=validated_data["actor"], target=target
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            except follow_class.DoesNotExist:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("No follow to remove")
    
            return validated_data
    
        def to_representation(self, instance):
            return {
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "@context": jsonld.get_default_context(),
    
                "id": instance.get_federation_id() + "/undo",
    
                "actor": instance.actor.fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "object": FollowSerializer(instance).data,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.validated_data["follow"].delete()
    
    class ActorWebfingerSerializer(serializers.Serializer):
        subject = serializers.CharField()
    
        aliases = serializers.ListField(child=serializers.URLField(max_length=500))
    
        links = serializers.ListField()
    
        actor_url = serializers.URLField(max_length=500, required=False)
    
    
        def validate(self, validated_data):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            validated_data["actor_url"] = None
            for l in validated_data["links"]:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    if not l["rel"] == "self":
    
                        continue
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    if not l["type"] == "application/activity+json":
    
                        continue
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    validated_data["actor_url"] = l["href"]
    
                    break
                except KeyError:
                    pass
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if validated_data["actor_url"] is None:
                raise serializers.ValidationError("No valid actor url found")
    
            return validated_data
    
    
        def to_representation(self, instance):
            data = {}
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            data["subject"] = "acct:{}".format(instance.webfinger_subject)
            data["links"] = [
    
                {"rel": "self", "href": instance.fid, "type": "application/activity+json"}
    
            data["aliases"] = [instance.fid]
    
            return data
    
    
    
    class ActivitySerializer(serializers.Serializer):
    
        actor = serializers.URLField(max_length=500)
        id = serializers.URLField(max_length=500, required=False)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        type = serializers.ChoiceField(choices=[(c, c) for c in activity.ACTIVITY_TYPES])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        object = serializers.JSONField(required=False)
        target = serializers.JSONField(required=False)
    
    
        def validate_object(self, value):
            try:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                type = value["type"]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Missing object type")
    
            except TypeError:
                # probably a URL
                return value
    
            try:
                object_serializer = OBJECT_SERIALIZERS[type]
            except KeyError:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise serializers.ValidationError("Unsupported type {}".format(type))
    
    
            serializer = object_serializer(data=value)
            serializer.is_valid(raise_exception=True)
            return serializer.data
    
        def validate_actor(self, value):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            request_actor = self.context.get("actor")
    
            if request_actor and request_actor.fid != value:
    
                raise serializers.ValidationError(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "The actor making the request do not match" " the activity actor"
    
        def to_representation(self, conf):
            d = {}
            d.update(conf)
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if self.context.get("include_ap_context", True):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["@context"] = jsonld.get_default_context()
    
    
    class ObjectSerializer(serializers.Serializer):
    
        id = serializers.URLField(max_length=500)
        url = serializers.URLField(max_length=500, required=False, allow_null=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        type = serializers.ChoiceField(choices=[(c, c) for c in activity.OBJECT_TYPES])
        content = serializers.CharField(required=False, allow_null=True)
        summary = serializers.CharField(required=False, allow_null=True)
        name = serializers.CharField(required=False, allow_null=True)
        published = serializers.DateTimeField(required=False, allow_null=True)
        updated = serializers.DateTimeField(required=False, allow_null=True)
    
        to = serializers.ListField(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
    
        cc = serializers.ListField(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
    
        bto = serializers.ListField(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
    
        bcc = serializers.ListField(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            child=serializers.URLField(max_length=500), required=False, allow_null=True
        )
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
    
    def get_additional_fields(data):
        UNSET = object()
        additional_fields = {}
        for field in ["name", "summary"]:
            v = data.get(field, UNSET)
            if v == UNSET:
                continue
            additional_fields[field] = v
    
        return additional_fields
    
    
    
    PAGINATED_COLLECTION_JSONLD_MAPPING = {
        "totalItems": jsonld.first_val(contexts.AS.totalItems),
        "actor": jsonld.first_id(contexts.AS.actor),
    
        "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
    
        "first": jsonld.first_id(contexts.AS.first),
        "last": jsonld.first_id(contexts.AS.last),
        "partOf": jsonld.first_id(contexts.AS.partOf),
    }
    
    
    class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
        type = serializers.ChoiceField(choices=[contexts.AS.Collection])
    
        totalItems = serializers.IntegerField(min_value=0)
    
        actor = serializers.URLField(max_length=500, required=False)
        attributedTo = serializers.URLField(max_length=500, required=False)
    
        id = serializers.URLField(max_length=500)
        first = serializers.URLField(max_length=500)
        last = serializers.URLField(max_length=500)
    
        class Meta:
            jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
    
    
        def validate(self, validated_data):
            d = super().validate(validated_data)
            actor = d.get("actor")
            attributed_to = d.get("attributedTo")
            if not actor and not attributed_to:
                raise serializers.ValidationError(
                    "You need to provide at least actor or attributedTo"
                )
    
            d["attributedTo"] = attributed_to or actor
            return d
    
    
        def to_representation(self, conf):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            paginator = Paginator(conf["items"], conf.get("page_size", 20))
            first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
    
            current = first
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "id": conf["id"],
    
                # XXX Stable release: remove the obsolete actor field
    
                "actor": conf["actor"].fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "totalItems": paginator.count,
    
                "type": conf.get("type", "Collection"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "current": current,
                "first": first,
                "last": last,
    
            d.update(get_additional_fields(conf))
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if self.context.get("include_ap_context", True):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["@context"] = jsonld.get_default_context()
    
    class LibrarySerializer(PaginatedCollectionSerializer):
    
        type = serializers.ChoiceField(
            choices=[contexts.AS.Collection, contexts.FW.Library]
        )
    
        name = serializers.CharField()
        summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        followers = serializers.URLField(max_length=500)
    
        audience = serializers.ChoiceField(
    
            choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
    
            required=False,
            allow_null=True,
            allow_blank=True,
        )
    
    
        class Meta:
            jsonld_mapping = funkwhale_utils.concat_dicts(
                PAGINATED_COLLECTION_JSONLD_MAPPING,
                {
                    "name": jsonld.first_val(contexts.AS.name),
                    "summary": jsonld.first_val(contexts.AS.summary),
                    "audience": jsonld.first_id(contexts.AS.audience),
                    "followers": jsonld.first_id(contexts.AS.followers),
                },
            )
    
    
        def to_representation(self, library):
            conf = {
                "id": library.fid,
                "name": library.name,
                "summary": library.description,
                "page_size": 100,
    
                # XXX Stable release: remove the obsolete actor field
    
                "actor": library.actor,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "items": library.uploads.for_federation(),
    
                "type": "Library",
            }
            r = super().to_representation(conf)
            r["audience"] = (
    
                contexts.AS.Public if library.privacy_level == "everyone" else ""
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            r["followers"] = library.followers_url
    
            return r
    
        def create(self, validated_data):
    
            actor = utils.retrieve_ap_object(
    
                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"],
                actor=actor,
                defaults={
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "uploads_count": validated_data["totalItems"],
    
                    "name": validated_data["name"],
    
                    "description": validated_data.get("summary"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "followers_url": validated_data["followers"],
    
                    "privacy_level": privacy[validated_data["audience"]],
    
    class CollectionPageSerializer(jsonld.JsonLdSerializer):
        type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage])
    
        totalItems = serializers.IntegerField(min_value=0)
        items = serializers.ListField()
    
        actor = serializers.URLField(max_length=500, required=False)
        attributedTo = serializers.URLField(max_length=500, required=False)
    
        id = serializers.URLField(max_length=500)
        first = serializers.URLField(max_length=500)
        last = serializers.URLField(max_length=500)
        next = serializers.URLField(max_length=500, required=False)
        prev = serializers.URLField(max_length=500, required=False)
        partOf = serializers.URLField(max_length=500)
    
        class Meta:
            jsonld_mapping = {
                "totalItems": jsonld.first_val(contexts.AS.totalItems),
                "items": jsonld.raw(contexts.AS.items),
                "actor": jsonld.first_id(contexts.AS.actor),
    
                "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
    
                "first": jsonld.first_id(contexts.AS.first),
                "last": jsonld.first_id(contexts.AS.last),
                "next": jsonld.first_id(contexts.AS.next),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "prev": jsonld.first_id(contexts.AS.prev),
    
                "partOf": jsonld.first_id(contexts.AS.partOf),
            }
    
    
        def validate_items(self, v):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            item_serializer = self.context.get("item_serializer")
    
            if not item_serializer:
                return v
            raw_items = [item_serializer(data=i, context=self.context) for i in v]
    
            for i in raw_items:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                try:
                    i.is_valid(raise_exception=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                except serializers.ValidationError:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    logger.debug("Invalid item %s: %s", i.data, i.errors)
    
        def to_representation(self, conf):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            page = conf["page"]
            first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
    
            last = funkwhale_utils.set_query_parameter(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                conf["id"], page=page.paginator.num_pages
            )
            id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "id": id,
                "partOf": conf["id"],
    
                # XXX Stable release: remove the obsolete actor field
    
                "actor": conf["actor"].fid,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "totalItems": page.paginator.count,
                "type": "CollectionPage",
                "first": first,
                "last": last,
                "items": [
                    conf["item_serializer"](
                        i, context={"actor": conf["actor"], "include_ap_context": False}
    
                    ).data
                    for i in page.object_list
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                ],
    
            }
    
            if page.has_previous():
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["prev"] = funkwhale_utils.set_query_parameter(
                    conf["id"], page=page.previous_page_number()
                )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if page.has_next():
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["next"] = funkwhale_utils.set_query_parameter(
                    conf["id"], page=page.next_page_number()
                )
    
            d.update(get_additional_fields(conf))
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if self.context.get("include_ap_context", True):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["@context"] = jsonld.get_default_context()
    
    MUSIC_ENTITY_JSONLD_MAPPING = {
        "name": jsonld.first_val(contexts.AS.name),
        "published": jsonld.first_val(contexts.AS.published),
        "musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
    
        "tags": jsonld.raw(contexts.AS.tag),
    
    class TagSerializer(jsonld.JsonLdSerializer):
        type = serializers.ChoiceField(choices=[contexts.AS.Hashtag])
        name = serializers.CharField(max_length=100)
    
        class Meta:
            jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)}
    
        def validate_name(self, value):
            if value.startswith("#"):
                # remove trailing #
                value = value[1:]
            return value
    
    
    
    def repr_tag(tag_name):
        return {"type": "Hashtag", "name": "#{}".format(tag_name)}
    
    
    
    class MusicEntitySerializer(jsonld.JsonLdSerializer):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        id = serializers.URLField(max_length=500)
        published = serializers.DateTimeField()
        musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
        name = serializers.CharField(max_length=1000)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False)
        updateable_fields = []
    
        tags = serializers.ListField(
            child=TagSerializer(), min_length=0, required=False, allow_null=True
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
        @transaction.atomic
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def update(self, instance, validated_data):
            attributed_to_fid = validated_data.get("attributedTo")
            if attributed_to_fid:
                validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
            updated_fields = funkwhale_utils.get_updated_fields(
                self.updateable_fields, validated_data, instance
            )
    
            updated_fields = self.validate_updated_data(instance, updated_fields)
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if updated_fields:
    
                music_tasks.update_library_entity(instance, updated_fields)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
            tags = [t["name"] for t in validated_data.get("tags", []) or []]
            tags_models.set_tags(instance, *tags)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return instance
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
        def get_tags_repr(self, instance):
            return [
    
                repr_tag(item.tag.name)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name)
    
        def validate_updated_data(self, instance, validated_data):
            return validated_data
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    class ArtistSerializer(MusicEntitySerializer):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        updateable_fields = [
            ("name", "name"),
            ("musicbrainzId", "mbid"),
            ("attributedTo", "attributed_to"),
        ]
    
    
            model = music_models.Artist
    
            jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def to_representation(self, instance):
            d = {
                "type": "Artist",
                "id": instance.fid,
                "name": instance.name,
                "published": instance.creation_date.isoformat(),
                "musicbrainzId": str(instance.mbid) if instance.mbid else None,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "attributedTo": instance.attributed_to.fid
                if instance.attributed_to
                else None,
    
                "tag": self.get_tags_repr(instance),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            }
    
            if self.context.get("include_ap_context", self.parent is None):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["@context"] = jsonld.get_default_context()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return d
    
    
    class AlbumSerializer(MusicEntitySerializer):
        released = serializers.DateField(allow_null=True, required=False)
        artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        cover = LinkSerializer(
            allowed_mimetypes=["image/*"], allow_null=True, required=False
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        updateable_fields = [
            ("name", "title"),
            ("musicbrainzId", "mbid"),
            ("attributedTo", "attributed_to"),
            ("released", "release_date"),
    
            ("cover", "attachment_cover"),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        ]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
            model = music_models.Album
    
            jsonld_mapping = funkwhale_utils.concat_dicts(
                MUSIC_ENTITY_JSONLD_MAPPING,
                {
                    "released": jsonld.first_val(contexts.FW.released),
                    "artists": jsonld.first_attr(contexts.FW.artists, "@list"),
                    "cover": jsonld.first_obj(contexts.FW.cover),
                },
            )
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def to_representation(self, instance):
            d = {
                "type": "Album",
                "id": instance.fid,
                "name": instance.title,
                "published": instance.creation_date.isoformat(),
                "musicbrainzId": str(instance.mbid) if instance.mbid else None,
                "released": instance.release_date.isoformat()
                if instance.release_date
                else None,
                "artists": [
                    ArtistSerializer(
                        instance.artist, context={"include_ap_context": False}
                    ).data
                ],
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "attributedTo": instance.attributed_to.fid
                if instance.attributed_to
                else None,
    
                "tag": self.get_tags_repr(instance),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            }
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if instance.attachment_cover:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["cover"] = {
                    "type": "Link",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "href": instance.attachment_cover.download_url_original,
                    "mediaType": instance.attachment_cover.mimetype or "image/jpeg",
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                }
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if self.context.get("include_ap_context", self.parent is None):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                d["@context"] = jsonld.get_default_context()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return d
    
        def validate_updated_data(self, instance, validated_data):
            try:
                attachment_cover = validated_data.pop("attachment_cover")
            except KeyError:
                return validated_data
    
            if (
                instance.attachment_cover
                and instance.attachment_cover.url == attachment_cover["href"]
            ):
                # we already have the proper attachment
                return validated_data
            # create the attachment by hand so it can be attached as the album cover
            validated_data["attachment_cover"] = common_models.Attachment.objects.create(
                mimetype=attachment_cover["mediaType"],
                url=attachment_cover["href"],
                actor=instance.attributed_to,
            )
            return validated_data
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    class TrackSerializer(MusicEntitySerializer):
        position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
    
        disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
        album = AlbumSerializer()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        license = serializers.URLField(allow_null=True, required=False)
        copyright = serializers.CharField(allow_null=True, required=False)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        updateable_fields = [
            ("name", "title"),
            ("musicbrainzId", "mbid"),
            ("attributedTo", "attributed_to"),
            ("disc", "disc_number"),
            ("position", "position"),
            ("copyright", "copyright"),
            ("license", "license"),
        ]
    
    
            model = music_models.Track
    
            jsonld_mapping = funkwhale_utils.concat_dicts(
                MUSIC_ENTITY_JSONLD_MAPPING,
                {
                    "album": jsonld.first_obj(contexts.FW.album),
                    "artists": jsonld.first_attr(contexts.FW.artists, "@list"),
                    "copyright": jsonld.first_val(contexts.FW.copyright),
                    "disc": jsonld.first_val(contexts.FW.disc),
                    "license": jsonld.first_id(contexts.FW.license),
                    "position": jsonld.first_val(contexts.FW.position),
                },
            )
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def to_representation(self, instance):
            d = {
                "type": "Track",
                "id": instance.fid,
                "name": instance.title,
                "published": instance.creation_date.isoformat(),
                "musicbrainzId": str(instance.mbid) if instance.mbid else None,
                "position": instance.position,
    
                "disc": instance.disc_number,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "license": instance.local_license["identifiers"][0]
                if instance.local_license
                else None,
                "copyright": instance.copyright if instance.copyright else None,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                "artists": [
                    ArtistSerializer(
                        instance.artist, context={"include_ap_context": False}
                    ).data
                ],