Skip to content
Snippets Groups Projects
serializers.py 53.7 KiB
Newer Older
import urllib.parse
from django.core.paginator import Paginator
from django.db import transaction

from rest_framework import serializers
from funkwhale_api.common import utils as common_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 TruncatedCharField(serializers.CharField):
    def __init__(self, *args, **kwargs):
        self.truncate_length = kwargs.pop("truncate_length")
        super().__init__(*args, **kwargs)

    def to_internal_value(self, v):
        v = super().to_internal_value(v)
        if v:
            v = v[: self.truncate_length]
        return v


class MediaSerializer(jsonld.JsonLdSerializer):
Eliot Berriot's avatar
Eliot Berriot committed
    mediaType = serializers.CharField()

    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 LinkSerializer(MediaSerializer):
    type = serializers.ChoiceField(choices=[contexts.AS.Link])
    href = serializers.URLField(max_length=500)

    class Meta:
        jsonld_mapping = {
            "href": jsonld.first_id(contexts.AS.href),
            "mediaType": jsonld.first_val(contexts.AS.mediaType),
        }


class ImageSerializer(MediaSerializer):
    type = serializers.ChoiceField(choices=[contexts.AS.Image, contexts.AS.Link])
    href = serializers.URLField(max_length=500, required=False)
    url = serializers.URLField(max_length=500, required=False)

    class Meta:
        jsonld_mapping = {
            "url": jsonld.first_id(contexts.AS.url),
            "href": jsonld.first_id(contexts.AS.href),
            "mediaType": jsonld.first_val(contexts.AS.mediaType),
        }

    def validate(self, data):
        validated_data = super().validate(data)
        if "url" not in validated_data:
            try:
                validated_data["url"] = validated_data["href"]
            except KeyError:
                if self.required:
                    raise serializers.ValidationError(
                        "You need to provide a url or href"
                    )

        return validated_data


class URLSerializer(jsonld.JsonLdSerializer):
    href = serializers.URLField(max_length=500)
    mediaType = serializers.CharField(required=False)

    class Meta:
        jsonld_mapping = {
            "href": jsonld.first_id(contexts.AS.href, aliases=[jsonld.raw("@id")]),
            "mediaType": jsonld.first_val(contexts.AS.mediaType),
        }


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)}


def get_by_media_type(urls, media_type):
    for url in urls:
        if url.get("mediaType", "text/html") == media_type:
            return url


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)
    url = serializers.ListField(
        child=URLSerializer(jsonld_expand=False), required=False, min_length=0
    )
    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, allow_blank=True, allow_null=True
    )
    summary = TruncatedCharField(
        truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
        required=False,
        allow_null=True,
    )
    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)
    icon = ImageSerializer(
Eliot Berriot's avatar
Eliot Berriot committed
        allowed_mimetypes=["image/*"], allow_null=True, required=False
    )
        # 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),
            "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),
Eliot Berriot's avatar
Eliot Berriot committed
            "icon": jsonld.first_obj(contexts.AS.icon),
            "url": jsonld.raw(contexts.AS.url),
    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.manually_approves_followers is not None:
Eliot Berriot's avatar
Eliot Berriot committed
            ret["manuallyApprovesFollowers"] = instance.manually_approves_followers
        if instance.summary_obj_id:
            ret["summary"] = instance.summary_obj.rendered
        urls = []
        if instance.url:
            urls.append(
                {"type": "Link", "href": instance.url, "mediaType": "text/html"}
            )

        channel = instance.get_channel()
        if channel:
            ret["url"] = [
                {
                    "type": "Link",
                    "href": instance.channel.get_absolute_url()
                    if instance.channel.artist.is_local
                    else instance.get_absolute_url(),
                    "mediaType": "text/html",
                },
                {
                    "type": "Link",
                    "href": instance.channel.get_rss_url(),
                    "mediaType": "application/rss+xml",
                },
            ]
            include_image(ret, channel.artist.attachment_cover, "icon")
            if channel.artist.description_id:
                ret["summary"] = channel.artist.description.rendered
        else:
            ret["url"] = [
                {
                    "type": "Link",
                    "href": instance.get_absolute_url(),
                    "mediaType": "text/html",
                }
            ]
            include_image(ret, instance.attachment_icon, "icon")
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
        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"),
            "type": self.validated_data["type"],
            "name": self.validated_data.get("name"),
            "preferred_username": self.validated_data["preferredUsername"],
        url = get_by_media_type(self.validated_data.get("url", []), "text/html")
        if url:
            kwargs["url"] = url["href"]

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)
        actor = models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
        common_utils.attach_content(
            actor, "summary_obj", self.validated_data["summary"]
        )
Eliot Berriot's avatar
Eliot Berriot committed
        if "icon" in self.validated_data:
            new_value = self.validated_data["icon"]
            common_utils.attach_file(
                actor,
                "attachment_icon",
                {"url": new_value["url"], "mimetype": new_value["mediaType"]}
Eliot Berriot's avatar
Eliot Berriot committed
                if new_value
                else None,
            )
        return actor
    def validate(self, data):
        validated_data = super().validate(data)
        if "summary" in data:
            validated_data["summary"] = {
                "content_type": "text/html",
                "text": data["summary"],
            }
        else:
            validated_data["summary"] = None
        return validated_data
class APIActorSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Actor
        fields = [
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 = common_utils.set_query_parameter(conf["id"], page=1)
        current = first
        last = common_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,
    )

        # 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,
            {
                "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):
        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"],
            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"]],
    def update(self, instance, validated_data):
        return self.create(validated_data)

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 = common_utils.set_query_parameter(conf["id"], page=1)
        last = common_utils.set_query_parameter(
Eliot Berriot's avatar
Eliot Berriot committed
            conf["id"], page=page.paginator.num_pages
        )
        id = common_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():
            d["prev"] = common_utils.set_query_parameter(
Eliot Berriot's avatar
Eliot Berriot committed
                conf["id"], page=page.previous_page_number()
            )
Eliot Berriot's avatar
Eliot Berriot committed
        if page.has_next():
            d["next"] = common_utils.set_query_parameter(
Eliot Berriot's avatar
Eliot Berriot committed
                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),
    "mediaType": jsonld.first_val(contexts.AS.mediaType),
    "content": jsonld.first_val(contexts.AS.content),
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)}


def include_content(repr, content_obj):
    if not content_obj:
        return

    repr["content"] = common_utils.render_html(
        content_obj.text, content_obj.content_type
    )
    repr["mediaType"] = "text/html"


Eliot Berriot's avatar
Eliot Berriot committed
def include_image(repr, attachment, field="image"):
    if attachment:
Eliot Berriot's avatar
Eliot Berriot committed
        repr[field] = {
            "type": "Image",
            "url": attachment.download_url_original,
            "mediaType": attachment.mimetype or "image/jpeg",
        }
    else:
Eliot Berriot's avatar
Eliot Berriot committed
        repr[field] = None
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
    )
    mediaType = serializers.ChoiceField(
        choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
        default="text/html",
        required=False,
    )
    content = TruncatedCharField(
        truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
        required=False,
        allow_null=True,
    )
Eliot Berriot's avatar
Eliot Berriot committed

    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
Eliot Berriot's avatar
Eliot Berriot committed
        attributed_to_fid = validated_data.get("attributedTo")
        if attributed_to_fid:
            validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
        updated_fields = common_utils.get_updated_fields(
Eliot Berriot's avatar
Eliot Berriot committed
            self.updateable_fields, validated_data, instance
        )
        updated_fields = self.validate_updated_data(instance, updated_fields)
        if creating:
            instance, created = self.Meta.model.objects.get_or_create(