Skip to content
Snippets Groups Projects
serializers.py 69.3 KiB
Newer Older
import urllib.parse
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from rest_framework import serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import models as common_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
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__)

def include_if_not_none(data, value, field):
    if value is not None:
        data[field] = value


class MultipleSerializer(serializers.Serializer):
    """
    A serializer that will try multiple serializers in turn
    """

    def __init__(self, *args, **kwargs):
        self.allowed = kwargs.pop("allowed")
        super().__init__(*args, **kwargs)

    def to_internal_value(self, v):
        last_exception = None
        for serializer_class in self.allowed:
            s = serializer_class(data=v)
            try:
                s.is_valid(raise_exception=True)
            except serializers.ValidationError as e:
                last_exception = e
            else:
                return s.validated_data

        raise last_exception


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 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 tag_list(tagged_items):
    return [
        repr_tag(item.tag.name)
        for item in sorted(set(tagged_items.all()), key=lambda i: i.tag.name)
    ]


def is_mimetype(mt, allowed_mimetypes):
    for allowed in allowed_mimetypes:
        if allowed.endswith("/*"):
            if mt.startswith(allowed.replace("*", "")):
                return True
        else:
            if mt == allowed:
                return True
    return False


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", [])
        self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False)
Eliot Berriot's avatar
Eliot Berriot committed
        super().__init__(*args, **kwargs)
        self.fields["mediaType"].required = not self.allow_empty_mimetype
        self.fields["mediaType"].allow_null = self.allow_empty_mimetype
Eliot Berriot's avatar
Eliot Berriot committed

    def validate_mediaType(self, v):
        if not self.allowed_mimetypes:
            # no restrictions
            return v
        if self.allow_empty_mimetype and not v:
            return None

        if not is_mimetype(v, self.allowed_mimetypes):
            raise serializers.ValidationError(
                "Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
            )
        return v
class LinkSerializer(MediaSerializer):
    type = serializers.ChoiceField(choices=[contexts.AS.Link])
    href = serializers.URLField(max_length=500)
    bitrate = serializers.IntegerField(min_value=0, required=False)
    size = serializers.IntegerField(min_value=0, required=False)

    class Meta:
        jsonld_mapping = {
            "href": jsonld.first_id(contexts.AS.href),
            "mediaType": jsonld.first_val(contexts.AS.mediaType),
            "bitrate": jsonld.first_val(contexts.FW.bitrate),
            "size": jsonld.first_val(contexts.FW.size),
class LinkListSerializer(serializers.ListField):
    def __init__(self, *args, **kwargs):
        kwargs.setdefault("child", LinkSerializer(jsonld_expand=False))
        self.keep_mediatype = kwargs.pop("keep_mediatype", [])
        super().__init__(*args, **kwargs)

    def to_internal_value(self, v):
        links = super().to_internal_value(v)
        if not self.keep_mediatype:
            # no further filtering required
            return links
        links = [
            link
            for link in links
            if link.get("mediaType")
            and is_mimetype(link["mediaType"], self.keep_mediatype)
        ]
        if not self.allow_empty and len(links) == 0:
            self.fail("empty")

        return links


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 BasicActorSerializer(jsonld.JsonLdSerializer):
    id = serializers.URLField(max_length=500)
    type = serializers.ChoiceField(
        choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
    )

    class Meta:
        jsonld_mapping = {}


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(
        allowed_mimetypes=["image/*"],
        allow_null=True,
        required=False,
        allow_empty_mimetype=True,
    attributedTo = serializers.URLField(max_length=500, required=False)

    tags = serializers.ListField(
        child=TagSerializer(), min_length=0, required=False, allow_null=True
    )

    category = serializers.CharField(required=False)
    # languages = serializers.Char(
    #     music_models.ARTIST_CONTENT_CATEGORY_CHOICES, required=False, default="music",
    # )
        # 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),
            "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
            "tags": jsonld.raw(contexts.AS.tag),
            "category": jsonld.first_val(contexts.SC.category),
            # "language": jsonld.first_val(contexts.SC.inLanguage),
    def validate_category(self, v):
        return (
            v
            if v in [t for t, _ in music_models.ARTIST_CONTENT_CATEGORY_CHOICES]
            else None
        )

    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
            ret["attributedTo"] = channel.attributed_to.fid
            ret["category"] = channel.artist.content_category
            ret["tag"] = tag_list(channel.artist.tagged_items.all())
        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.get("mediaType")}
Eliot Berriot's avatar
Eliot Berriot committed
                if new_value
                else None,
            )

        rss_url = get_by_media_type(
            self.validated_data.get("url", []), "application/rss+xml"
        )
        if rss_url:
            rss_url = rss_url["href"]
        attributed_to = self.validated_data.get("attributedTo")
        if rss_url and attributed_to:
            # if the actor is attributed to another actor, and there is a RSS url,
            # then we consider it's a channel
            create_or_update_channel(
                actor,
                rss_url=rss_url,
                attributed_to_fid=attributed_to,
                **self.validated_data
            )
        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
def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data):
    from funkwhale_api.audio import models as audio_models

    attributed_to = actors.get_actor(attributed_to_fid)
    artist_defaults = {
        "name": validated_data.get("name", validated_data["preferredUsername"]),
        "fid": validated_data["id"],
        "content_category": validated_data.get("category", "music") or "music",
        "attributed_to": attributed_to,
    }
    artist, created = music_models.Artist.objects.update_or_create(
        channel__attributed_to=attributed_to,
        channel__actor=actor,
        defaults=artist_defaults,
    )
    common_utils.attach_content(artist, "description", validated_data.get("summary"))
    if "icon" in validated_data:
        new_value = validated_data["icon"]
        common_utils.attach_file(
            artist,
            "attachment_cover",
            {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
            if new_value
            else None,
        )
    tags = [t["name"] for t in validated_data.get("tags", []) or []]
    tags_models.set_tags(artist, *tags)
    if created:
        uid = uuid.uuid4()
        fid = utils.full_url(
            reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
        )
        library = attributed_to.libraries.create(
            privacy_level="everyone", name=artist_defaults["name"], fid=fid, uuid=uid,
        )
    else:
        library = artist.channel.library
    channel_defaults = {
        "actor": actor,
        "attributed_to": attributed_to,
        "rss_url": rss_url,
        "artist": artist,
        "library": library,
    }
    channel, created = audio_models.Channel.objects.update_or_create(
        actor=actor, attributed_to=attributed_to, defaults=channel_defaults,
    )
    return channel


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